Flutter State Management: Beyond Packages (Part 3)

We’ll build a complete custom state management solution that’s lightweight, type-safe, and scalable.

Flutter State Management: Beyond Packages (Part 3)
Image created by author

A Quick Recap

In the first part of this mini-series on Flutter state management, we covered understanding Flutter’s built-in state management tools using StatefulWidget, setState and InheritedWidget. We also covered Flutter’s observer pattern implementations using ChangeNotifier, ValueNotifier and ValueListenableBuilder.

In the second part we covered reactive programming with streams using StreamBuilder and StreamController, combining and transforming complex streams, and discussed choosing the right solution.

If you’re not familiar with any of these methods of implementing state management, take the time to familiarize yourself by reading these articles first.

Continuing on…

Now, in Part 3, we’ll put it all together by building a complete custom state management solution that’s lightweight, type-safe, and scalable. Our goal is to create a solution that provides the convenience of popular packages while maintaining transparency and control over the implementation.

The Complete Custom Solution

Our state management solution consists of several key components:

  1. StateStore: A generic container for state that notifies listeners when the state changes
  2. StateManager: A centralized registry for all state stores
  3. StateProvider: Makes state stores available throughout the widget tree
  4. StateConsumer and StateSelector: Widgets that rebuild when state changes

Let’s dive into the implementation of each component.

The StateStore Class

At the heart of our solution is the StateStore<T> class, which holds state of any type and provides methods to update it:

import 'dart:async'; 
 
/// A generic store for state management. 
class StateStore<T> { 
  // Current state 
  T _state; 
 
  // Stream controller for state updates 
  final _stateController = StreamController<T>.broadcast(); 
 
  // Constructor 
  StateStore(this._state) { 
    // Initialize the stream with current state 
    _stateController.add(_state); 
  } 
 
  // State getters 
  T get state => _state; 
  Stream<T> get stream => _stateController.stream; 
 
  // Update state 
  void update(T newState) { 
    _state = newState; 
    _stateController.add(_state); 
  } 
 
  // Update state with a reducer function 
  void updateWith(T Function(T currentState) reducer) { 
    final newState = reducer(_state); 
    update(newState); 
  } 
 
  void dispose() { 
    _stateController.close(); 
  } 
}

The StateStore class leverages Dart streams to provide reactivity. When the state changes, all listeners are notified. The updateWith method is particularly useful as it allows for updating state based on the current state, similar to React’s setState updater function.

State Classes

Before we proceed with the rest of our solution, let’s look at a typical state class that would be managed by our StateStore:

class ProductState { 
  final List<Product> products; 
  final bool isLoading; 
  final String? error; 
  final String? selectedCategory; 
 
  const ProductState({ 
    this.products = const [], 
    this.isLoading = false, 
    this.error, 
    this.selectedCategory, 
  }); 
 
  List<String> get categories { 
    final allCategories = <String>{}; 
    for (final product in products) { 
      allCategories.addAll(product.categories); 
    } 
    return allCategories.toList()..sort(); 
  } 
 
  List<Product> get filteredProducts { 
    if (selectedCategory == null || selectedCategory!.isEmpty) { 
      return products; 
    } 
    return products.where((p) => p.categories.contains(selectedCategory)).toList(); 
  } 
 
  ProductState copyWith({ 
    List<Product>? products, 
    bool? isLoading, 
    String? error, 
    String? selectedCategory, 
    bool clearError = false, 
    bool clearCategory = false, 
  }) { 
    return ProductState( 
      products: products ?? this.products, 
      isLoading: isLoading ?? this.isLoading, 
      error: clearError ? null : error ?? this.error, 
      selectedCategory: clearCategory ? null : selectedCategory ?? this.selectedCategory, 
    ); 
  } 
}

Our state classes follow these principles:
 — Immutability: State objects are immutable
 — Computed properties: Derived state is exposed through getters
 — Copy-with pattern: A copyWith method for efficient state updates

StateManager

As applications grow, managing multiple state stores becomes challenging. To solve this, we introduce the StateManager class, which centralizes store registration and access:

import 'package:flutter/widgets.dart'; 
import 'state_store.dart'; 
 
/// A class to hold state store instances by their type 
class StateManager { 
  final Map<Type, StateStore<dynamic>> _stores = {}; 
 
  /// Register a store with a specific type 
  void register<T>(StateStore<T> store) { 
    _stores[T] = store; 
  } 
 
  /// Get a store by type 
  StateStore<T> get<T>() { 
    final store = _stores[T]; 
    if (store == null) { 
      throw FlutterError( 
        'No StateStore<$T> was found in the StateManager. ' 
        'Make sure to register it before accessing.', 
      ); 
    } 
    return store as StateStore<T>; 
  } 
 
  /// Dispose all registered stores 
  void dispose() { 
    for (final store in _stores.values) { 
      store.dispose(); 
    } 
    _stores.clear(); 
  } 
}

The StateManager maps state types to their respective state stores. This approach allows us to retrieve stores by their state type, which is both type-safe and convenient.

StateProvider

To make our state stores available throughout the widget tree, we implement a StateProvider using an InheritedWidget:

import 'package:flutter/widgets.dart'; 
import 'state_manager.dart'; 
import 'state_store.dart'; 
 
/// A widget that provides multiple state stores to the widget tree 
class StateProvider extends StatefulWidget { 
  final StateManager stateManager; 
  final Widget child; 
 
  const StateProvider({ 
    Key? key, 
    required this.stateManager, 
    required this.child, 
  }) : super(key: key); 
 
  @override 
  State<StateProvider> createState() => _StateProviderState(); 
 
  /// Get a state store from the context 
  static StateStore<T> of<T>(BuildContext context) { 
    final provider = context.dependOnInheritedWidgetOfExactType<_InheritedState>(); 
    if (provider == null) { 
      throw FlutterError('No StateProvider found in context'); 
    } 
    return provider.stateManager.get<T>(); 
  } 
} 
 
class _StateProviderState extends State<StateProvider> { 
  @override 
  void dispose() { 
    widget.stateManager.dispose(); 
    super.dispose(); 
  } 
 
  @override 
  Widget build(BuildContext context) { 
    return _InheritedState( 
      stateManager: widget.stateManager, 
      child: widget.child, 
    ); 
  } 
} 
 
/// InheritedWidget that holds the state manager 
class _InheritedState extends InheritedWidget { 
  final StateManager stateManager; 
 
  const _InheritedState({ 
    Key? key, 
    required this.stateManager, 
    required Widget child, 
  }) : super(key: key, child: child); 
 
  @override 
  bool updateShouldNotify(_InheritedState old) { 
    return stateManager != old.stateManager; 
  } 
}

The StateProvider serves as a single entry point for accessing all state stores in the application. Note how the of<T>() static method uses generics to provide type-safe access to the appropriate store.

StateConsumer and StateSelector

To connect our UI to the state, we create two specialized widgets. The first is a StateConsumer, the implementation of which is below:

import 'package:flutter/widgets.dart'; 
import 'store_provider.dart'; 
 
/// Consumer widget that rebuilds when a specific state store changes 
class StateConsumer<T> extends StatelessWidget { 
  final Widget Function(BuildContext context, T state) builder; 
 
  const StateConsumer({ 
    Key? key, 
    required this.builder, 
  }) : super(key: key); 
 
  @override 
  Widget build(BuildContext context) { 
    final store = StateProvider.of<T>(context); 
 
    return StreamBuilder<T>( 
      stream: store.stream, 
      initialData: store.state, 
      builder: (context, snapshot) { 
        final data = snapshot.data; 
        if (data == null) { 
          return Center(child: CircularProgressIndicator()); 
        } 
        return builder(context, data); 
      }, 
    ); 
  } 
}

The second is more specialized in that it only triggers a rebuild when a specific part changes. We call this widget the StateSelector:

/// Selector that rebuilds only when selected value from a specific state store changes 
class StateSelector<T, R> extends StatefulWidget { 
  final R Function(T state) selector; 
  final Widget Function(BuildContext context, R selectedValue) builder; 
 
  const StateSelector({super.key, required this.selector, required this.builder}); 
 
  @override 
  State<StateSelector<T, R>> createState() => _StateSelectorState<T, R>(); 
} 
 
class _StateSelectorState<T, R> extends State<StateSelector<T, R>> { 
  late R _selectedValue; 
  Widget? _lastBuiltWidget; 
  bool _didUpdateDependencies = false; 
 
  @override 
  void didChangeDependencies() { 
    super.didChangeDependencies(); 
    if (!_didUpdateDependencies) { 
      final store = StateProvider.of<T>(context); 
      _selectedValue = widget.selector(store.state); 
      _didUpdateDependencies = true; 
    } 
  } 
 
  @override 
  Widget build(BuildContext context) { 
    final store = StateProvider.of<T>(context); 
 
    return StreamBuilder<T>( 
      stream: store.stream, 
      initialData: store.state, 
      builder: (context, snapshot) { 
        final data = snapshot.data; 
        if (data == null) { 
          return Center(child: CircularProgressIndicator()); 
        } 
 
        // Extract the value using the selector 
        final newSelectedValue = widget.selector(data); 
 
        // Only rebuild if the selected value has changed 
        if (_lastBuiltWidget == null || !_areEqual(newSelectedValue, _selectedValue)) { 
          debugPrint('Rebuilding ${widget.runtimeType} because selected value changed'); 
          _selectedValue = newSelectedValue; 
          _lastBuiltWidget = widget.builder(context, _selectedValue); 
        } else { 
          debugPrint('Skipping rebuild of ${widget.runtimeType} - selected value unchanged'); 
        } 
 
        return _lastBuiltWidget!; 
      }, 
    ); 
  } 
 
  // Custom equality check that handles various types 
  bool _areEqual(R a, R b) { 
    if (a == b) return true; 
 
    // Handle lists (deep comparison) 
    if (a is List && b is List) { 
      if (a.length != b.length) return false; 
      for (int i = 0; i < a.length; i++) { 
        if (a[i] != b[i]) return false; 
      } 
      return true; 
    } 
 
    // Handle maps (deep comparison) 
    if (a is Map && b is Map) { 
      if (a.length != b.length) return false; 
      for (final key in a.keys) { 
        if (!b.containsKey(key) || a[key] != b[key]) return false; 
      } 
      return true; 
    } 
 
    return false; 
  } 
}

The StateSelector has the following powerful features:

  1. True Selective Rebuilding: The StateSelector only rebuilds when the selected portion of the state actually changes, not when the entire state object changes.
  2. Widget Caching: By caching the built widget, we avoid unnecessary rebuilding work even when the StreamBuilder itself rebuilds.
  3. Deep Equality Checking: Our custom equality checking handles complex data structures like lists and maps, making the selector work reliably with all types of state.
  4. Better Memory Efficiency: By minimizing rebuilds, we reduce the CPU and memory overhead of our application.

This kind of optimization is exactly what makes custom state management solutions powerful. You have full control over how and when widgets rebuild, allowing you to fine-tune performance in ways that might not be possible with some packages.

The StateSelector is particularly valuable for:

  • Widgets that depend on a small part of a larger state object
  • Lists or grids where each item only cares about its own data
  • Complex UIs where rebuilding the entire screen would be expensive
  • Components that need to react to specific state changes (like a badge showing cart count)

In summary:

  • StateConsumer rebuilds whenever the entire state changes
  • StateSelector uses a selector function to extract specific parts of the state and only rebuilds when that selection changes

These consumer widgets wrap Flutter’s StreamBuilder to provide a more convenient API for our state management pattern.

Putting It All Together

Now let’s see how all these components work together in a complete application. We’ll create a small e-commerce app with product listing, filtering, and shopping cart functionality.

Setting Up Stores

First, we initialize our stores and register them with the StateManager:

// In our app.dart file 
import 'package:flutter/material.dart'; 
import 'state/store_manager.dart'; 
import 'state/states/auth_state.dart'; 
import 'state/states/cart_state.dart'; 
import 'state/states/product_state.dart'; 
import 'state/state_provider.dart'; 
import 'state/state_store.dart'; 
 
class ShopApp extends StatelessWidget { 
  // Create a state manager and register all state stores 
  final stateManager = StateManager() 
    ..register<AuthState>(StateStore<AuthState>(AuthState())) 
    ..register<ProductState>(StateStore<ProductState>(ProductState())) 
    ..register<CartState>(StateStore<CartState>(CartState())); 
 
  ShopApp({super.key}); 
 
  @override 
  Widget build(BuildContext context) { 
    // Use a single provider for all state stores 
    return StateProvider( 
      stateManager: stateManager, 
      child: MaterialApp( 
        title: 'Shop App', 
        theme: ThemeData( 
          primarySwatch: Colors.blue, 
          visualDensity: VisualDensity.adaptivePlatformDensity, 
        ), 
        home: HomeScreen(), 
        debugShowCheckedModeBanner: false, 
      ), 
    ); 
  } 
}

Accessing State

Let’s look at our HomeScreen implementation to see how we access and update state:

class HomeScreen extends StatefulWidget { 
  const HomeScreen({super.key}); 
 
  @override 
  State<HomeScreen> createState() => _HomeScreenState(); 
} 
 
class _HomeScreenState extends State<HomeScreen> { 
  final ApiService _apiService = ApiService(); 
  late StateStore<ProductState> _productStore; 
  bool _isInitialized = false; 
 
  @override 
  void didChangeDependencies() { 
    super.didChangeDependencies(); 
     
    // Only initialize once 
    if (!_isInitialized) { 
      _productStore = StateProvider.of<ProductState>(context); 
      _loadProducts(); 
      _isInitialized = true; 
    } 
  } 
 
  Future<void> _loadProducts() async { 
    // Set loading state 
    _productStore.updateWith((state) => state.copyWith(isLoading: true, clearError: true)); 
 
    try { 
      final products = await _apiService.getProducts(); 
       
      // Check if the widget is still mounted before updating state 
      if (!mounted) return; 
       
      _productStore.updateWith((state) => state.copyWith(products: products, isLoading: false)); 
    } catch (e) { 
      // Check if the widget is still mounted before updating state 
      if (!mounted) return; 
       
      _productStore.updateWith((state) => state.copyWith(isLoading: false, error: e.toString())); 
    } 
  } 
 
  @override 
  Widget build(BuildContext context) { 
    return Scaffold( 
      appBar: AppBar( 
        title: Text('Shop App'), 
        actions: [_buildCartButton()], 
      ), 
      body: RefreshIndicator( 
        onRefresh: _loadProducts, 
        child: Column( 
          children: [ 
            _buildCategoriesFilter(), 
            Expanded(child: _buildProductGrid()), 
          ], 
        ), 
      ), 
    ); 
  } 
   
  // Other methods... 
}

Note how we access the product store using StateProvider.of<ProductState>(context) and update it with _productStore.updateWith(). The updateWith method accepts a reducer function that receives the current state and returns the new state.

Rendering State with StateConsumer

Now let’s see how StateConsumer is used to render the product grid:

Widget _buildProductGrid() { 
  return StateConsumer<ProductState>( 
    builder: (context, state) { 
      if (state.isLoading) { 
        return Center(child: CircularProgressIndicator()); 
      } 
 
      if (state.error != null) { 
        return Center( 
          child: Column( 
            mainAxisAlignment: MainAxisAlignment.center, 
            children: [ 
              Text('Error: ${state.error}'), 
              SizedBox(height: 16), 
              ElevatedButton( 
                onPressed: _loadProducts, 
                child: Text('Retry'), 
              ), 
            ], 
          ), 
        ); 
      } 
 
      final products = state.filteredProducts; 
 
      if (products.isEmpty) { 
        return Center(child: Text('No products found')); 
      } 
 
      return GridView.builder( 
        padding: EdgeInsets.all(16), 
        gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( 
          crossAxisCount: 2, 
          childAspectRatio: 0.7, 
          crossAxisSpacing: 10, 
          mainAxisSpacing: 10, 
        ), 
        itemCount: products.length, 
        itemBuilder: (ctx, i) => ProductCard( 
          product: products[i], 
          onTap: () => _navigateToProductDetail(products[i]), 
        ), 
      ); 
    }, 
  ); 
}

The StateConsumer rebuilds whenever the ProductState changes, allowing us to show loading indicators, error messages, or the product grid based on the current state.

Optimizing Rebuilds with StateSelector

For more fine-grained control over rebuilds, we use StateSelector. Here’s how we use it for the cart button in the app bar:

Widget _buildCartButton() { 
  return StateSelector<CartState, int>( 
    selector: (state) => state.itemCount, 
    builder: (context, itemCount) { 
      return Stack( 
        alignment: Alignment.center, 
        children: [ 
          IconButton( 
            icon: Icon(Icons.shopping_cart), 
            onPressed: () { 
              Navigator.of(context).push( 
                MaterialPageRoute(builder: (_) => CartScreen()), 
              ); 
            }, 
          ), 
          if (itemCount > 0) 
            Positioned( 
              top: 8, 
              right: 8, 
              child: Container( 
                padding: EdgeInsets.all(2), 
                decoration: BoxDecoration( 
                  color: Colors.red, 
                  borderRadius: BorderRadius.circular(10), 
                ), 
                constraints: BoxConstraints( 
                  minWidth: 16, 
                  minHeight: 16, 
                ), 
                child: Text( 
                  '$itemCount', 
                  style: TextStyle( 
                    color: Colors.white, 
                    fontSize: 10, 
                  ), 
                  textAlign: TextAlign.center, 
                ), 
              ), 
            ), 
        ], 
      ); 
    }, 
  ); 
}

The StateSelector only rebuilds when the itemCount changes, not when any other part of the cart state changes. This is more efficient than using StateConsumer for the entire cart state.

Adding Items to Cart

Let’s see how we update state when adding items to the cart:

// From our ProductCard widget 
void _addToCart(StateStore<CartState> cartStore) { 
  cartStore.updateWith((state) { 
    final currentItems = List<CartItem>.from(state.items); 
    final index = currentItems.indexWhere((item) => item.product.id == product.id); 
 
    if (index >= 0) { 
      // Update existing item 
      currentItems[index] = currentItems[index].copyWith( 
        quantity: currentItems[index].quantity + 1, 
      ); 
    } else { 
      // Add new item 
      currentItems.add(CartItem(product: product)); 
    } 
 
    return state.copyWith(items: currentItems); 
  }); 
}

We use the updateWith method to update the cart state based on its current value. This ensures our state updates are safe and predictable.

Advanced Patterns and Techniques

Let’s explore some advanced patterns and techniques that can enhance our custom state management solution.

Middleware for Side Effects

For handling side effects like API calls, we can implement a simple middleware pattern:

typedef Middleware<T> = Future<void> Function( 
  StateStore<T> store, 
  T state, 
  void Function() next, 
); 
 
class StateStoreWithMiddleware<T> extends StateStore<T> { 
  final List<Middleware<T>> _middleware; 
 
  StateStoreWithMiddleware(T initialState, this._middleware) : super(initialState); 
 
  @override 
  void update(T newState) { 
    _runMiddleware(0, newState, () { 
      super.update(newState); 
    }); 
  } 
 
  void _runMiddleware(int index, T newState, void Function() next) { 
    if (index >= _middleware.length) { 
      next(); 
      return; 
    } 
 
    _middleware[index](this, newState, () { 
      _runMiddleware(index + 1, newState, next); 
    }); 
  } 
}

This allows us to intercept state updates and perform side effects:

// Logging middleware 
final loggingMiddleware = <T>(StateStore<T> store, T state, void Function() next) async { 
  print('Updating state: $state'); 
  next(); 
  print('State updated: ${store.state}'); 
}; 
 
// API middleware 
final apiMiddleware = (StateStore<ProductState> store, ProductState state, void Function() next) async { 
  next(); // Update the state first 
   
  if (state.shouldRefreshProducts) { 
    try { 
      final products = await apiService.getProducts(); 
      store.updateWith((s) => s.copyWith( 
        products: products, 
        shouldRefreshProducts: false, 
      )); 
    } catch (e) { 
      store.updateWith((s) => s.copyWith( 
        error: e.toString(), 
        shouldRefreshProducts: false, 
      )); 
    } 
  } 
}; 
 
// Usage 
final productStore = StateStoreWithMiddleware<ProductState>( 
  ProductState(), 
  [loggingMiddleware, apiMiddleware], 
);

Persistent State

For persisting state across app restarts, we can extend our StateStore class:

class PersistentStateStore<T> extends StateStore<T> { 
  final String key; 
  final Future<void> Function(String key, T state) saveState; 
  final Future<T?> Function(String key) loadState; 
 
  PersistentStateStore({ 
    required T initialState, 
    required this.key, 
    required this.saveState, 
    required this.loadState, 
  }) : super(initialState) { 
    _init(); 
  } 
 
  Future<void> _init() async { 
    final savedState = await loadState(key); 
    if (savedState != null) { 
      update(savedState); 
    } 
  } 
 
  @override 
  void update(T newState) { 
    super.update(newState); 
    saveState(key, newState); 
  } 
} 
 
// Usage with shared_preferences 
final cartStore = PersistentStateStore<CartState>( 
  initialState: CartState(), 
  key: 'cart', 
  saveState: (key, state) async { 
    final prefs = await SharedPreferences.getInstance(); 
    final json = jsonEncode(state.toJson()); 
    await prefs.setString(key, json); 
  }, 
  loadState: (key) async { 
    final prefs = await SharedPreferences.getInstance(); 
    final json = prefs.getString(key); 
    if (json == null) return null; 
    return CartState.fromJson(jsonDecode(json)); 
  }, 
);

Computed Selectors with Memoization

For complex derived state, we can implement memoized selectors:

class MemoizedStateSelector<T, R> { 
  final R Function(T state) _selector; 
  T? _lastState; 
  R? _lastResult; 
 
  MemoizedStateSelector(this._selector); 
 
  R select(T state) { 
    if (_lastState != state) { 
      _lastResult = _selector(state); 
      _lastState = state; 
    } 
    return _lastResult as R; 
  } 
} 
 
// Usage 
final getTotalPriceSelector = MemoizedStateSelector<CartState, double>( 
  (state) => state.items.fold( 
    0, (sum, item) => sum + item.product.price * item.quantity 
  ), 
); 
 
// In widget 
StateSelector<CartState, double>( 
  selector: (state) => getTotalPriceSelector.select(state), 
  builder: (context, total) => Text('\$${total.toStringAsFixed(2)}'), 
)

Comparing with Third-Party Solutions

Now that we’ve built our custom solution, let’s compare it with popular third-party packages:

Custom Solution vs. Provider

Similarities: Both use InheritedWidget under the hood, support type-safe access to state
 — Differences: Our solution uses streams for reactivity instead of ChangeNotifier
 — Pros of Custom: Full control over implementation, no external dependencies
 — Cons of Custom: Less community support, might miss optimizations present in Provider

Custom Solution vs. Riverpod

Similarities: Both offer fine-grained reactivity and state selection
 — Differences: Riverpod doesn’t require BuildContext, has more advanced providers
 — Pros of Custom: Simpler API, easier to understand implementation
 — Cons of Custom: Lacks advanced features like provider overrides and auto-disposal

Custom Solution vs. BLoC

Similarities: Both use streams for reactivity
 — Differences: BLoC strictly separates events from state, has more formal structure
 — Pros of Custom: Less boilerplate, more straightforward implementation
 — Cons of Custom: Less formal separation of concerns, lacks testing utilities

Custom Solution vs. GetX

Similarities: Both aim to simplify state management and reduce boilerplate
 — Differences: GetX combines state management with routing, DI, and other features
 — Pros of Custom: Better separation of concerns, more transparent implementation
 — Cons of Custom: Requires more manual implementation of features GetX provides out-of-box

When to Use Each Approach

Use a Custom Solution When:

  • You want to understand state management fundamentals
  • You need a lightweight solution with minimal dependencies
  • Your app has straightforward state requirements
  • You value transparent implementation over advanced features

Use Third-Party Packages When:

  • You need more advanced features (time-travel debugging, state persistence)
  • You want community support and established patterns
  • You’re working with a larger team that needs standardization
  • Performance optimizations are critical

Try the Example Project

Screenshots of the example project

We’ve implemented our custom state management solution in a fully functional Flutter e-commerce application. This practical example demonstrates all the concepts discussed in this article series, from the core StateStore class to optimized UI rebuilds with selectors.

The project includes:

  • Complete implementation of the custom state management system
  • A multi-screen e-commerce application with product listing and cart functionality
  • Examples of different state update patterns
  • Integration with ShadCN UI for a polished user interface

You can explore, run, and modify the project by cloning the repository:

git clone https://github.com/dartfoundry/state-management-example

This repository serves as both a reference implementation and a starting point for your own projects. By examining the code, you’ll gain deeper insights into how our custom state management approach works in a real-world application context.


Conclusion

Building a custom state management solution in Flutter is not only achievable but can also be a valuable learning experience. Our solution leverages Flutter’s built-in capabilities to create a lightweight, type-safe, and reactive state management system.

Flutter’s flexibility allows for many approaches to state management, and there’s no one-size-fits-all solution. By understanding the core principles of state management — such as the store pattern, reactivity through streams, and efficient UI updates — you gain insights that apply regardless of which state management solution you ultimately choose.

The built-in state management tools we’ve explored are powerful enough for many applications, and they provide a solid foundation even if you eventually adopt a state management package. In fact, most packages are just convenient wrappers around these core concepts.

Remember that the goal of state management is not to use the most sophisticated solution, but to create a predictable, maintainable flow of data through your application. Whether you build your own solution or adopt a third-party package, these principles remain the same.


This has been Part 3 of our “Flutter State Management: Beyond Packages” series. Don’t forget to check out Part 1 for an introduction to Flutter’s built-in state management and Part 2 for reactive programming with streams. In Part 4 we’ll explore testing our state management components to ensure they work reliably and efficiently by writing unit and integration tests.