Using Events, States, and Streams for State Management in Flutter

In Flutter, managing the state of your application effectively is crucial for building robust and maintainable apps. Using Events, States, and Streams for state management provides a structured and reactive approach. This method is commonly associated with architectural patterns like BLoC (Business Logic Component) and Redux. This blog post will delve into how to implement this state management strategy using Flutter.

Why Use Events, States, and Streams for State Management?

  • Predictability: Changes to the state are predictable and follow a defined path, making it easier to debug and maintain.
  • Separation of Concerns: Business logic is separated from the UI, promoting cleaner and more testable code.
  • Reactivity: The UI automatically updates in response to state changes via Streams, making the app highly responsive.
  • Centralized State: State is managed in a central location, reducing complexity and making it easier to reason about the app’s behavior.

Core Concepts

Before diving into implementation, let’s define the key concepts:

  • Event: An action triggered by the user or system, signaling that something has happened. Examples include button presses, data fetching requests, or timer expirations.
  • State: Represents the data or condition of the application at a specific point in time. The UI renders based on the current state.
  • Stream: A sequence of asynchronous data that allows you to listen for and react to changes. In this context, States are emitted through Streams to update the UI.

Implementation Steps

To demonstrate using Events, States, and Streams for state management, we’ll create a simple counter app. Here’s how to implement it:

Step 1: Define Events

Create an abstract class or enum for defining events. This class represents the different actions that can occur in your app.


// Event
abstract class CounterEvent {}

class IncrementEvent extends CounterEvent {}

class DecrementEvent extends CounterEvent {}

Step 2: Define States

Create a class for defining the different states of your app. This class represents the various forms your UI can take.


// State
class CounterState {
  final int counter;

  CounterState({required this.counter});
}

Step 3: Create the BLoC (or State Manager)

Create a BLoC (Business Logic Component) class to manage the state and handle events. The BLoC class uses a StreamController to emit states and responds to events.
Create a file named `counter_bloc.dart` with the following code:


import 'dart:async';

// Event
abstract class CounterEvent {}

class IncrementEvent extends CounterEvent {}

class DecrementEvent extends CounterEvent {}

// State
class CounterState {
  final int counter;

  CounterState({required this.counter});
}

// BLoC
class CounterBloc {
  int _counter = 0;

  // Stream to handle state
  final _stateController = StreamController<CounterState>();
  Stream<CounterState> get state => _stateController.stream;

  // Stream to handle events
  final _eventController = StreamController<CounterEvent>();
  Sink<CounterEvent> get eventSink => _eventController.sink;

  CounterBloc() {
    _eventController.stream.listen(_mapEventToState);
  }

  void _mapEventToState(CounterEvent event) {
    if (event is IncrementEvent) {
      _counter++;
    } else if (event is DecrementEvent) {
      _counter--;
    }

    _stateController.add(CounterState(counter: _counter));
  }

  void dispose() {
    _stateController.close();
    _eventController.close();
  }
}

Step 4: Integrate BLoC with the UI

Integrate the BLoC class with the Flutter UI using a StreamBuilder to listen for state changes and update the UI accordingly. Here’s the Flutter widget code:


import 'package:flutter/material.dart';
import 'dart:async';

// Event
abstract class CounterEvent {}

class IncrementEvent extends CounterEvent {}

class DecrementEvent extends CounterEvent {}

// State
class CounterState {
  final int counter;

  CounterState({required this.counter});
}

// BLoC
class CounterBloc {
  int _counter = 0;

  // Stream to handle state
  final _stateController = StreamController<CounterState>();
  Stream<CounterState> get state => _stateController.stream;

  // Stream to handle events
  final _eventController = StreamController<CounterEvent>();
  Sink<CounterEvent> get eventSink => _eventController.sink;

  CounterBloc() {
    _eventController.stream.listen(_mapEventToState);
  }

  void _mapEventToState(CounterEvent event) {
    if (event is IncrementEvent) {
      _counter++;
    } else if (event is DecrementEvent) {
      _counter--;
    }

    _stateController.add(CounterState(counter: _counter));
  }

  void dispose() {
    _stateController.close();
    _eventController.close();
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Counter App',
      home: CounterPage(),
    );
  }
}

class CounterPage extends StatefulWidget {
  @override
  _CounterPageState createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  final _bloc = CounterBloc();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Flutter Counter App'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'Counter Value:',
              style: TextStyle(fontSize: 20),
            ),
            StreamBuilder<CounterState>(
              stream: _bloc.state,
              initialData: CounterState(counter: 0),
              builder: (context, snapshot) {
                return Text(
                  '${snapshot.data?.counter}',
                  style: TextStyle(fontSize: 40, fontWeight: FontWeight.bold),
                );
              },
            ),
            SizedBox(height: 20),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                FloatingActionButton(
                  onPressed: () => _bloc.eventSink.add(IncrementEvent()),
                  child: Icon(Icons.add),
                  tooltip: 'Increment',
                ),
                SizedBox(width: 20),
                FloatingActionButton(
                  onPressed: () => _bloc.eventSink.add(DecrementEvent()),
                  child: Icon(Icons.remove),
                  tooltip: 'Decrement',
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  @override
  void dispose() {
    super.dispose();
    _bloc.dispose();
  }
}

Explanation:

  • The CounterPage is a StatefulWidget that creates an instance of CounterBloc.
  • The StreamBuilder listens to the state stream of the BLoC and updates the UI whenever a new state is emitted.
  • The FloatingActionButtons send IncrementEvent and DecrementEvent to the BLoC via the eventSink.

Step 5: Run the App

Run the Flutter application to see the counter app in action. You should be able to increment and decrement the counter using the provided buttons, with the UI updating reactively based on the state changes.

Benefits of This Approach

  • Clear Separation of Concerns: BLoC architecture ensures the separation of business logic from UI.
  • Testability: BLoC components are easily testable since their input and output are clearly defined by events and states.
  • Reusability: BLoC components can be reused across different parts of the application.
  • Maintainability: Code becomes more organized and maintainable due to the clear structure and predictability of state management.

Conclusion

Using Events, States, and Streams for state management in Flutter, often associated with BLoC or similar patterns, provides a robust, predictable, and reactive architecture for managing your app’s state. This approach helps to build scalable, testable, and maintainable applications. While it may require a bit more setup initially, the long-term benefits for code organization and maintainability are significant.