Flutter State Management: Beyond Packages (Part 2)

We’ll be covering reactive programming with streams using StreamBuilder and StreamController, combining and transforming complex streams.

Flutter State Management: Beyond Packages (Part 2)
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. If you’re not familiar with these methods of implementing state management, take the time to familiarize yourself by reading this article.

In this part we’ll be covering reactive programming with streams using StreamBuilder and StreamController, combining and transforming complex streams. We’ll end this part by discussing choosing the right solution.


Reactive Programming with Streams

Dart’s asynchronous programming capabilities provide a powerful foundation for reactive state management through streams. A stream is a sequence of asynchronous events that can be manipulated, transformed, and consumed by interested parties.

StreamBuilder and StreamController

Creating a Stream-based State Container
Let’s build a simple counter model using streams:

class CounterBloc { 
  // StreamController manages the stream and sink 
  final _counterController = StreamController<int>.broadcast(); 
   
  // Current state 
  int _counter = 0; 
   
  // Stream getter (output) 
  Stream<int> get counterStream => _counterController.stream; 
   
  // Methods to update state 
  void increment() { 
    _counter++; 
    _counterController.add(_counter); 
  } 
   
  void decrement() { 
    _counter--; 
    _counterController.add(_counter); 
  } 
   
  void reset() { 
    _counter = 0; 
    _counterController.add(_counter); 
  } 
   
  // Important: Always close controllers when done 
  void dispose() { 
    _counterController.close(); 
  } 
}

Building Reactive UIs with StreamBuilder
The StreamBuilder widget listens to a stream and rebuilds whenever a new event is emitted:

class CounterScreen extends StatefulWidget { 
  @override 
  _CounterScreenState createState() => _CounterScreenState(); 
} 
 
class _CounterScreenState extends State<CounterScreen> { 
  final CounterBloc _bloc = CounterBloc(); 
   
  @override 
  void dispose() { 
    _bloc.dispose(); // Prevent memory leaks 
    super.dispose(); 
  } 
 
  @override 
  Widget build(BuildContext context) { 
    return Scaffold( 
      appBar: AppBar(title: Text('Stream Counter')), 
      body: Center( 
        child: StreamBuilder<int>( 
          stream: _bloc.counterStream, 
          initialData: 0, // Initial state before any events 
          builder: (context, snapshot) { 
            if (snapshot.hasError) { 
              return Text('Error: ${snapshot.error}'); 
            } 
             
            return Column( 
              mainAxisAlignment: MainAxisAlignment.center, 
              children: [ 
                Text( 
                  'Count: ${snapshot.data}', 
                  style: TextStyle(fontSize: 24), 
                ), 
                SizedBox(height: 16), 
                Row( 
                  mainAxisAlignment: MainAxisAlignment.center, 
                  children: [ 
                    ElevatedButton( 
                      onPressed: _bloc.decrement, 
                      child: Text('-'), 
                    ), 
                    SizedBox(width: 16), 
                    ElevatedButton( 
                      onPressed: _bloc.reset, 
                      child: Text('Reset'), 
                    ), 
                    SizedBox(width: 16), 
                    ElevatedButton( 
                      onPressed: _bloc.increment, 
                      child: Text('+'), 
                    ), 
                  ], 
                ), 
              ], 
            ); 
          }, 
        ), 
      ), 
    ); 
  } 
}

Implementing a BLoC-like Pattern
The Business Logic Component (BLoC) pattern separates business logic from UI. Here’s a more comprehensive example with a to-do list:

// Data model 
class Todo { 
  final String id; 
  final String title; 
  bool completed; 
   
  Todo({ 
    required this.id, 
    required this.title, 
    this.completed = false, 
  }); 
} 
 
// BLoC for todos 
class TodoBloc { 
  // Stream for list of todos 
  final _todosController = StreamController<List<Todo>>.broadcast(); 
  List<Todo> _todos = []; 
   
  // Stream for filtered todos 
  final _filterController = StreamController<String>.broadcast(); 
  String _currentFilter = 'all'; // 'all', 'active', 'completed' 
   
  // Output streams 
  Stream<List<Todo>> get todos => _todosController.stream; 
  Stream<String> get currentFilter => _filterController.stream; 
   
  // Combined stream for UI that depends on both todos and filter 
  Stream<List<Todo>> get filteredTodos =>  
    Rx.combineLatest2<List<Todo>, String, List<Todo>>( 
      todos,  
      currentFilter, 
      (todos, filter) { 
        switch (filter) { 
          case 'active': 
            return todos.where((todo) => !todo.completed).toList(); 
          case 'completed': 
            return todos.where((todo) => todo.completed).toList(); 
          case 'all': 
          default: 
            return todos; 
        } 
      } 
    ); 
   
  TodoBloc() { 
    // Initialize streams with current values 
    _todosController.add(_todos); 
    _filterController.add(_currentFilter); 
  } 
   
  // Action methods 
  void addTodo(String title) { 
    final newTodo = Todo( 
      id: DateTime.now().millisecondsSinceEpoch.toString(), 
      title: title, 
    ); 
    _todos.add(newTodo); 
    _todosController.add(_todos); 
  } 
   
  void toggleTodo(String id) { 
    final index = _todos.indexWhere((todo) => todo.id == id); 
    if (index >= 0) { 
      _todos[index].completed = !_todos[index].completed; 
      _todosController.add(_todos); 
    } 
  } 
   
  void removeTodo(String id) { 
    _todos.removeWhere((todo) => todo.id == id); 
    _todosController.add(_todos); 
  } 
   
  void changeFilter(String filter) { 
    _currentFilter = filter; 
    _filterController.add(_currentFilter); 
  } 
   
  void dispose() { 
    _todosController.close(); 
    _filterController.close(); 
  } 
}

The UI for our Todo app:

class TodoApp extends StatefulWidget { 
  @override 
  _TodoAppState createState() => _TodoAppState(); 
} 
 
class _TodoAppState extends State<TodoApp> { 
  final TodoBloc _bloc = TodoBloc(); 
  final TextEditingController _textController = TextEditingController(); 
   
  @override 
  void dispose() { 
    _bloc.dispose(); 
    _textController.dispose(); 
    super.dispose(); 
  } 
   
  @override 
  Widget build(BuildContext context) { 
    return Scaffold( 
      appBar: AppBar(title: Text('Stream Todo')), 
      body: Column( 
        children: [ 
          // Todo input 
          Padding( 
            padding: EdgeInsets.all(16), 
            child: Row( 
              children: [ 
                Expanded( 
                  child: TextField( 
                    controller: _textController, 
                    decoration: InputDecoration( 
                      hintText: 'Add a new todo', 
                    ), 
                  ), 
                ), 
                IconButton( 
                  icon: Icon(Icons.add), 
                  onPressed: () { 
                    if (_textController.text.isNotEmpty) { 
                      _bloc.addTodo(_textController.text); 
                      _textController.clear(); 
                    } 
                  }, 
                ), 
              ], 
            ), 
          ), 
           
          // Filter buttons 
          StreamBuilder<String>( 
            stream: _bloc.currentFilter, 
            initialData: 'all', 
            builder: (context, snapshot) { 
              final currentFilter = snapshot.data!; 
              return Row( 
                mainAxisAlignment: MainAxisAlignment.center, 
                children: [ 
                  FilterButton( 
                    title: 'All', 
                    selected: currentFilter == 'all', 
                    onPressed: () => _bloc.changeFilter('all'), 
                  ), 
                  FilterButton( 
                    title: 'Active', 
                    selected: currentFilter == 'active', 
                    onPressed: () => _bloc.changeFilter('active'), 
                  ), 
                  FilterButton( 
                    title: 'Completed', 
                    selected: currentFilter == 'completed', 
                    onPressed: () => _bloc.changeFilter('completed'), 
                  ), 
                ], 
              ); 
            }, 
          ), 
           
          // Todo list 
          Expanded( 
            child: StreamBuilder<List<Todo>>( 
              stream: _bloc.filteredTodos, 
              initialData: [], 
              builder: (context, snapshot) { 
                if (snapshot.hasError) { 
                  return Center(child: Text('Error: ${snapshot.error}')); 
                } 
                 
                final todos = snapshot.data!; 
                 
                if (todos.isEmpty) { 
                  return Center(child: Text('No todos yet!')); 
                } 
                 
                return ListView.builder( 
                  itemCount: todos.length, 
                  itemBuilder: (context, index) { 
                    final todo = todos[index]; 
                    return ListTile( 
                      leading: Checkbox( 
                        value: todo.completed, 
                        onChanged: (_) => _bloc.toggleTodo(todo.id), 
                      ), 
                      title: Text( 
                        todo.title, 
                        style: TextStyle( 
                          decoration: todo.completed  
                            ? TextDecoration.lineThrough  
                            : null, 
                        ), 
                      ), 
                      trailing: IconButton( 
                        icon: Icon(Icons.delete), 
                        onPressed: () => _bloc.removeTodo(todo.id), 
                      ), 
                    ); 
                  }, 
                ); 
              }, 
            ), 
          ), 
        ], 
      ), 
    ); 
  } 
} 
 
class FilterButton extends StatelessWidget { 
  final String title; 
  final bool selected; 
  final VoidCallback onPressed; 
   
  const FilterButton({ 
    Key? key, 
    required this.title, 
    required this.selected, 
    required this.onPressed, 
  }) : super(key: key); 
 
  @override 
  Widget build(BuildContext context) { 
    return Padding( 
      padding: EdgeInsets.symmetric(horizontal: 8), 
      child: TextButton( 
        onPressed: onPressed, 
        style: ButtonStyle( 
          backgroundColor: MaterialStateProperty.all( 
            selected ? Colors.blue.shade100 : Colors.transparent, 
          ), 
        ), 
        child: Text(title), 
      ), 
    ); 
  } 
}

Combining Streams for Complex State

Transforming Streams
Streams can be transformed using various operators. While Dart’s built-in functionality is more limited than RxDart, we can still implement useful transformations:

class SearchBloc { 
  final _queryController = StreamController<String>.broadcast(); 
  final _resultsController = StreamController<List<String>>.broadcast(); 
   
  // Input 
  Sink<String> get queryInput => _queryController.sink; 
   
  // Output 
  Stream<List<String>> get results => _resultsController.stream; 
   
  SearchBloc() { 
    // Transform the query stream to implement debounce 
    _queryController.stream 
      .transform(_debounce(Duration(milliseconds: 300))) 
      .listen(_performSearch); 
  } 
   
  // Custom debounce transformer 
  StreamTransformer<String, String> _debounce(Duration duration) { 
    Timer? timer; 
    return StreamTransformer<String, String>.fromHandlers( 
      handleData: (data, sink) { 
        timer?.cancel(); 
        timer = Timer(duration, () => sink.add(data)); 
      }, 
      handleDone: (sink) { 
        timer?.cancel(); 
        sink.close(); 
      }, 
    ); 
  } 
   
  void _performSearch(String query) async { 
    // Simulate API call 
    await Future.delayed(Duration(milliseconds: 200)); 
     
    final results = [ 
      'Result 1 for $query', 
      'Result 2 for $query', 
      'Result 3 for $query', 
    ]; 
     
    _resultsController.add(results); 
  } 
   
  void dispose() { 
    _queryController.close(); 
    _resultsController.close(); 
  } 
}

Usage in a widget:

class SearchScreen extends StatefulWidget { 
  @override 
  _SearchScreenState createState() => _SearchScreenState(); 
} 
 
class _SearchScreenState extends State<SearchScreen> { 
  final SearchBloc _bloc = SearchBloc(); 
   
  @override 
  void dispose() { 
    _bloc.dispose(); 
    super.dispose(); 
  } 
   
  @override 
  Widget build(BuildContext context) { 
    return Scaffold( 
      appBar: AppBar(title: Text('Stream Search')), 
      body: Column( 
        children: [ 
          Padding( 
            padding: EdgeInsets.all(16), 
            child: TextField( 
              decoration: InputDecoration( 
                labelText: 'Search', 
                prefixIcon: Icon(Icons.search), 
              ), 
              onChanged: (query) => _bloc.queryInput.add(query), 
            ), 
          ), 
          Expanded( 
            child: StreamBuilder<List<String>>( 
              stream: _bloc.results, 
              initialData: [], 
              builder: (context, snapshot) { 
                if (snapshot.hasError) { 
                  return Center(child: Text('Error: ${snapshot.error}')); 
                } 
                 
                final results = snapshot.data!; 
                 
                if (results.isEmpty) { 
                  return Center(child: Text('No results')); 
                } 
                 
                return ListView.builder( 
                  itemCount: results.length, 
                  itemBuilder: (context, index) { 
                    return ListTile( 
                      title: Text(results[index]), 
                    ); 
                  }, 
                ); 
              }, 
            ), 
          ), 
        ], 
      ), 
    ); 
  } 
}

Building Your Own State Management Solution

Now let’s combine what we’ve learned to create a lightweight, reusable state management solution that’s easy to understand and maintain.

Creating a Custom Store Class

First, let’s build a generic Store class that can manage any type of state:

class Store<T> { 
  // Current state 
  T _state; 
   
  // Stream controller for state updates 
  final _stateController = StreamController<T>.broadcast(); 
   
  // Constructor 
  Store(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(); 
  } 
}

Let’s create a wrapper widget to provide the store to the widget tree:

class StoreProvider<T> extends StatefulWidget { 
  final Store<T> store; 
  final Widget child; 
   
  const StoreProvider({ 
    Key? key, 
    required this.store, 
    required this.child, 
  }) : super(key: key); 
   
  @override 
  _StoreProviderState<T> createState() => _StoreProviderState<T>(); 
   
  // Helper to retrieve the store from context 
  static Store<T> of<T>(BuildContext context) { 
    final provider = context.findAncestorWidgetOfExactType<StoreProvider<T>>(); 
    assert(provider != null, 'No StoreProvider<$T> found in context'); 
    return provider!.store; 
  } 
} 
 
class _StoreProviderState<T> extends State<StoreProvider<T>> { 
  @override 
  void dispose() { 
    widget.store.dispose(); 
    super.dispose(); 
  } 
   
  @override 
  Widget build(BuildContext context) { 
    return widget.child; 
  } 
}

And a consumer widget to easily rebuild on state changes:

class StoreConsumer<T> extends StatelessWidget { 
  final Widget Function(BuildContext context, T state) builder; 
  final Store<T>? store; 
   
  const StoreConsumer({ 
    Key? key, 
    required this.builder, 
    this.store, 
  }) : super(key: key); 
   
  @override 
  Widget build(BuildContext context) { 
    final storeToUse = store ?? StoreProvider.of<T>(context); 
     
    return StreamBuilder<T>( 
      stream: storeToUse.stream, 
      initialData: storeToUse.state, 
      builder: (context, snapshot) { 
        return builder(context, snapshot.data!); 
      }, 
    ); 
  } 
}

Let’s use our custom solution with a simple counter app:

// Counter state 
class CounterState { 
  final int count; 
   
  CounterState({this.count = 0}); 
   
  CounterState copyWith({int? count}) { 
    return CounterState( 
      count: count ?? this.count, 
    ); 
  } 
} 
 
// Actions 
void incrementCounter(Store<CounterState> store) { 
  store.updateWith((state) => state.copyWith(count: state.count + 1)); 
} 
 
void decrementCounter(Store<CounterState> store) { 
  store.updateWith((state) => state.copyWith(count: state.count - 1)); 
} 
 
void resetCounter(Store<CounterState> store) { 
  store.update(CounterState()); 
} 
 
// App 
class MyApp extends StatelessWidget { 
  @override 
  Widget build(BuildContext context) { 
    return MaterialApp( 
      title: 'Custom Store Demo', 
      home: StoreProvider<CounterState>( 
        store: Store<CounterState>(CounterState()), 
        child: CounterScreen(), 
      ), 
    ); 
  } 
} 
 
class CounterScreen extends StatelessWidget { 
  @override 
  Widget build(BuildContext context) { 
    return Scaffold( 
      appBar: AppBar(title: Text('Custom Store Counter')), 
      body: Center( 
        child: StoreConsumer<CounterState>( 
          builder: (context, state) { 
            return Text( 
              'Count: ${state.count}', 
              style: TextStyle(fontSize: 24), 
            ); 
          }, 
        ), 
      ), 
      floatingActionButton: Column( 
        mainAxisSize: MainAxisSize.min, 
        children: [ 
          FloatingActionButton( 
            heroTag: 'increment', 
            child: Icon(Icons.add), 
            onPressed: () => incrementCounter( 
              StoreProvider.of<CounterState>(context) 
            ), 
          ), 
          SizedBox(height: 8), 
          FloatingActionButton( 
            heroTag: 'decrement', 
            child: Icon(Icons.remove), 
            onPressed: () => decrementCounter( 
              StoreProvider.of<CounterState>(context) 
            ), 
          ), 
          SizedBox(height: 8), 
          FloatingActionButton( 
            heroTag: 'reset', 
            child: Icon(Icons.refresh), 
            onPressed: () => resetCounter( 
              StoreProvider.of<CounterState>(context) 
            ), 
          ), 
        ], 
      ), 
    ); 
  } 
}

In the final section, I’ll provide a conclusion that summarizes the approaches we’ve covered and offers guidance on when to use each one.


Choosing the Right Approach

Throughout this article, we’ve explored several built-in approaches to state management in Flutter, from simple setState and ValueNotifier to more complex solutions with streams and custom stores. Each approach has its strengths and ideal use cases.

Comparison of Approaches

A comparison of state management approaches in table format.

When to Build Custom vs. Use Existing Solutions

Build Custom When:

  1. Learning and Understanding: Building your own solutions deepens your understanding of Flutter’s reactivity model.
  2. Simple Requirements: If your app’s state management needs are minimal, custom solutions can be more lightweight.
  3. Specific Needs: When you have unique requirements that aren’t well-served by existing packages.
  4. Package Avoidance: In projects where minimizing dependencies is a priority.
  5. Control: When you need complete control over how state changes propagate through your app.

Use Existing Packages When:

  1. Team Familiarity: Your team already knows and works efficiently with a specific package.
  2. Time Constraints: You need to deliver quickly and don’t have time to build and test custom solutions.
  3. Complex State Logic: Your app has complex state interdependencies, middleware needs, or time-travel debugging requirements.
  4. Standardization: You want to follow widely-adopted patterns that new team members can quickly understand.
  5. Maintenance: You benefit from community maintenance, bug fixes, and improvements.

Best Practices for Any State Management Approach

Regardless of which approach you choose, follow these principles for maintainable state management:

  1. Keep state as local as possible: Don’t make everything global. Use the most local scope that works for your use case.
  2. Separate UI from logic: Business logic should be independent of UI concerns, making it easier to test and maintain.
  3. Be consistent: Choose an approach and use it consistently throughout your app. Mixing too many approaches creates confusion.
  4. Avoid deeply nested state: Flatter state structures are easier to update and reason about.
  5. Dispose properly: Always clean up resources (controllers, streams, notifiers) to prevent memory leaks.
  6. Test your state logic: Business logic separated from UI is easier to test.
  7. Document your patterns: Make sure team members understand how state flows through your application.
  8. Optimize rebuilds: Only rebuild the parts of the UI that depend on changed state.

Real-World Implementation Strategy

In practice, many Flutter applications benefit from a hybrid approach:

  • Local UI state: Use setState or ValueNotifier for simple UI state like toggle buttons, form inputs, or animation controls.
  • Component state: Use ChangeNotifier or custom classes with ValueListenableBuilder for state that belongs to a specific section of your app.
  • Application state: Use a more structured approach like your custom store, streams, or a state management package for data that needs to be accessed throughout your app.

This has been Part 2 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. In Part 3 we’ll build a custom state management solution.