Flutter - 3 different state management options

Flutter - 3 different state management options

Mattia Cecchini's photo
Mattia Cecchini
·Aug 3, 2022·

12 min read

Subscribe to our newsletter and never miss any upcoming articles

Play this article

Table of contents

Let's see some of the commonly used techniques to manage states in our applications

setState

This example shows the most common situation where a stateful widget has to share its state with some stateless sub widgets and this is accomplished by passing props. If we want to update the state from a sub-widget we have to share also a method to update it, and we can pass it in props too. It's a common pattern that you can find in almost all declarative UI frontend frameworks.

class MyApp extends StatefulWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  // state
  int count = 0;

  // method to update state
  void increment() {
    setState(() {
      count++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Center(
        child: Parent(
          count: count,
          increment: increment,
        ),
      ),
    );
  }
}

class Parent extends StatelessWidget {
  final int count;
  final void Function() increment;

  const Parent({Key? key, required this.count, required this.increment})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Child(
      count: count,
      increment: increment,
    );
  }
}

class Child extends StatelessWidget {
  final int count;
  final void Function() increment;

  const Child({Key? key, required this.count, required this.increment})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: increment,
      child: Text("$count"),
    );
  }
}

For an app state that you need to modify from many different places, you’d have to pass around a lot of callbacks, and this gets old and messy pretty quickly

We can also pass Widgets as props With widget composition, we can solve part of this problem, with this trick the child component can access the state without getting it from the parent

class MyApp extends StatefulWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  // state
  int count = 0;

  // method to update state
  void increment() {
    setState(() {
      count++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Center(
        child: Parent(
          child: Child(
            count: count,
            increment: increment,
          ),
        ),
      ),
    );
  }
}

class Parent extends StatelessWidget {
  final Widget child;

  const Parent({Key? key, required this.child}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return child;
  }
}

class Child extends StatelessWidget {
  final int count;
  final void Function() increment;

  const Child({Key? key, required this.count, required this.increment})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: increment,
      child: Text("$count"),
    );
  }
}

Drilling props could also have some performance issues, because every time you update a state, all the chain of widgets from the stateful widget will be rebuilt

And what if we need to share some global states, maybe persistent through route changes, this can be hard to handle only by drilling props

So setState is useful for widget-specific, ephemeral states. For global and persistent states we need something more

Provider package

https://pub.dev/packages/provider

Flutter indeed has mechanisms for widgets to provide data and services to their descendants with some low-level widgets (InheritedWidget, InheritedNotifier, InheritedModel, and more)

But instead of using those low-level widgets, we can use this package called Provider. We just need to understand these 3 concepts:

  • ChangeNotifier
  • ChangeNotifierProvider
  • Consumer / Provider.of

ChangeNotifier

ChangeNotifier is a simple mixin included in the Flutter SDK which provides change notifications to its listeners

To notify the listeners you have to call the notifyListeners() method from ChangeNotifier every time you update the state

class Counter with ChangeNotifier {
  // state
  int count = 0;

  // increment counter
  void increment() {
    count++;
    notifyListeners();
  }
}

ChangeNotifier doesn't require widgets to work so you can test it standalone

test('increment counter', () {
    final model = Counter();
    final count = model.count;
    model.addListener(() {
      expect(model.count, greaterThan(count));
    });
    model.increment();
  });

ChangeNotifierProvider

ChangeNotifierProvider is the widget that provides an instance of a ChangeNotifier to its descendants. It comes from the Provider package.

Usually, you don’t want to place ChangeNotifierProvider higher than necessary, but just above the widgets that need to access it

ChangeNotifierProvider(
  create: (context) => Counter(),
  child: const MyApp(),
),

If you want to provide more than one ChangeNotifier, you can use MultiProvider

MultiProvider(
  providers: [
    ChangeNotifierProvider(
      create: (context) => Counter(),
    ),
    ...
  ],
  child: const MyApp(),
),

Consumer

To listen to state changes we can use the Consumer widget, specifying which model you want to access in the template

Consumer<Counter>(
  builder: (context, model, child) {
    return Column(
      children: [
        ElevatedButton(
          onPressed: model.increment,
          child: Text("${model.count}"),
        ),
        // ExpensiveWidget without rebuilding every time.
        child,
      ],
    );
  },
  child: const ExpensiveWidget(),
)
  • builder is the function that is called for rebuilding the returned widgets, whenever the ChangeNotifier notifies events (when you call notifyListeners() in your model)
    • child parameter, is there for optimization. If you have a large widget subtree under your Consumer that doesn’t change when the model changes, you can construct it once and get it through the builder.

It is best practice to put your Consumer widgets as deep in the tree as possible. You don’t want to rebuild large portions of the UI just because some detail somewhere changed.

Provider.of

Another way to access the state and attach widgets as listeners to ChangeNotifier is by calling Provider.of(context) inside the build function of a widget

...
Widget build(BuildContext context) {
  // access the state and register listener to ChangeNotifier similar to using Consumer as a wrapper of this widget
  final model = Provider.of<Counter>(context)
  ...
}

Sometimes you don't want to register a listener that rebuilds the current widget on every change, but you just want to access the current state, for example in callbacks

void myFunction() {
  final model = Provider.of<Counter>(context, listen: false);
  ...
}

provider.png

Combine providers

ProxyProvider is a provider that combines multiple values from other providers into a new object and sends the result to Provider This new object will then be updated whenever one of the providers we depend on gets updated.

MultiProvider(
  providers: [
    ChangeNotifierProvider(create: (_) => Counter()),
    ChangeNotifierProxyProvider<Counter, SubCounter>(
      create: (context) => SubCounter(0),
      update: (context, counter, subcounter) => SubCounter(counter.count),
    ),
  ],
  child: Foo(),
);
class SubCounter with ChangeNotifier {
  SubCounter(this.start);

  final int start;
  int count = 0;

  int get total => start + count;

  void increment() {
    count++;
    notifyListeners()
  }
}

Final considerations

With Provider and ChangeNotifier you can accomplish all your needs in terms of state management, but having your models and their business logic in the same class is kind of messy in complex projects

BLoC pattern

This design pattern helps to separate presentation from business logic. Following the BLoC pattern facilitates testability and reusability

Data will be flowing from the BLOC to the UI or even from UI to BLOC in form of streams

To visualize the notion of a stream you can consider a pipe with two ends, only one allowing to insert something into it. When you insert something into the pipe it flows inside and gets out from the other end The concept is the same as rxjs Observables if you are familiar with

Implementing the pattern

https://pub.dev/packages/flutter_bloc

This package uses Provider under the hood to share the streams of your changing states inside the application

Cubit and Bloc are the classes in this package to implement these streams

Both Blocs and Cubits will ignore duplicate states so it's a good practice to create comparable states, overriding ==operator and hashValue or using equatable package

It's a good practice to emit immutable objects on streams

Bloc

Implementing the Bloc class creates 2 streams, one to emit the state from the Bloc to the UI and one to emit events from the UI to the Bloc

bloc

In the constructor, we can define how the Bloc should react to the events emitted from the UI and we can set the initial state

With the on\ method, we can listen to events coming from the UI, and in the callback, we can emit a new state using the emitter that you will receive from the parameters or you can emit errors with addError method

Inside a Bloc, we can access the current state via the state getter

From the UI we can emit new events with add method exposed by the Bloc class

We can retrieve our Bloc from the UI using Provider

Events are handled by default concurrently, but we can trace them very well with the Bloc class and we can observe the Bloc and do stuff by overriding methods like:

  • onEvent
  • onChange (you will receive the old state and the new one)
  • onTransition (similar to onChange but it contains also the event that triggered the state change)
  • onError methods

You can also provide a custom EventTransformer to change the way incoming events are processed by the Bloc. Check out package bloc_concurrency for an opinionated set of event transformers

bloc_flow

class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc() : super(CounterState(0)){
    on<CounterIncrementEvent>(_increment, transformer: sequential());
  }

  void _increment(CounterEvent event, Emitter<CounterState> emit) async {
    emit(CounterState(state.value + 1));
    // addError(Exception('increment error!'), StackTrace.current);
  }

  @override
  void onEvent(CounterEvent event) {
    super.onEvent(event);
    print(event);
  }

  @override
  void onChange(Change<int> change) {
    super.onChange(change);
    print(change);
  }

  @override
  void onTransition(Transition<CounterEvent, CounterState> transition) {
    super.onTransition(transition);
    print(transition);
  }

  @override
  void onError(Object error, StackTrace stackTrace) {
    print('$error, $stackTrace');
    super.onError(error, stackTrace);
  }
}

@immutable
abstract class CounterEvent {}

class CounterIncrementEvent extends CounterEvent {}

@immutable
class CounterState {
  final int value;

  const CounterState(this.value);

  @override
  int get hashCode => value.hashCode;

  @override
  bool operator ==(Object other) {
    if (other is CounterState) {
      return other.value == value;
    }
    return false;
  }
}

bloc_test is a usefull package for testing Blocs https://pub.dev/packages/bloc_test

blocTest<CounterBloc, CounterState>(
  'increment counter',
  build: () => CounterBloc(),
  act: (bloc) => bloc
    ..add(CounterIncrementEvent())
    ..add(CounterIncrementEvent()),
  expect: () => <CounterState>[
    CounterState(1),
    CounterState(2),
  ],
);

Cubit

This is a subset of the Bloc class, it reduces complexity and simplifies boilerplate for managing simpler states It eliminates the event stream and uses functions instead, to update the state

cubit

We can define a new Cubit specifying the object type to be transmitted into the stream (in this case CounterState) and emitting new values with emit method or emitting errors with addError

In the constructor, it requires an initial state

Inside a Cubit, we can access the current state via the state getter

We can observe the Cubit and do stuff like in Bloc but we can override only the onChange and onError methods

Compared with Bloc we lose some traceability to reduce complexity

cubit_flow

class CounterCubit extends Cubit<CounterState> {
  CounterCubit() : super(const CounterState(0));

  void increment() {
    emit(CounterState(state.value + 1));
    // addError(Exception('increment error!'), StackTrace.current);
  }

  @override
  void onChange(Change<CounterState> change) {
    super.onChange(change);
    print(change);
  }

  @override
  void onError(Object error, StackTrace stackTrace) {
    print('$error, $stackTrace');
    super.onError(error, stackTrace);
  }
}

@immutable
class CounterState {
  final int value;

  const CounterState(this.value);

  @override
  int get hashCode => value.hashCode;

  @override
  bool operator ==(Object other) {
    if (other is CounterState) {
      return other.value == value;
    }
    return false;
  }
}

You can test it in the same way as Bloc with the bloc_test package

blocTest<CounterCubit, CounterState>(
  'increment counter',
  build: () => CounterCubit(),
  act: (bloc) => bloc
    ..increment()
    ..increment(),
  expect: () => <CounterState>[
    CounterState(1),
    CounterState(2),
  ],
);

If you don't know if you should implement your state as Cubit or Bloc, just start with Cubit and the eventually refactor to Bloc on needs

Combine Blocs

You should not do this, sibling dependencies between two entities in the same architectural layer should be avoided because are hard to maintain, no Bloc should know about any other Bloc

But if you have to combine Blocs and get notified of state changes, we can subscribe to other Bloc dependencies in the Bloc constructor These dependencies have to be provided in the constructor's arguments

class FilteredCounterBloc extends Bloc<FilteredCounterEvent, FilteredCounterState> {
  final counterBloc CounterBloc;
  StreamSubscription counterSubscription;

  FilteredCounterBloc({@required this.counterBloc}) {
    counterSubscription = counterBloc.listen((state) {
      if (state is CounterState) {
        add(this.counterUpdated);
      }
    });
  }

  void counterUpdate(CounterState state) {
    // do something
  }
  // ...

Widgets

BlocProvider

Same as with Provider we have to provide our Blocs to be accessible in our application You don’t want to place BlocProvider higher than necessary, but just above the widgets that need to access it

BlocProvider(
  create: (context) => CounterCubit(), // or CounterBloc()
  child: MyApp(),
)

If you want to provide more than one Bloc, you can use MultiBlocProvider

MultiBlocProvider(
  providers: [
    BlocProvider(
      create: (context) => CounterCubit(),
    ),
    BlocProvider(
      create: (context) => CounterBloc(),
    ),
    ...
  ],
  child: const MyApp(),
),

RepositoryProvider is a Flutter widget that provides a repository to its children via RepositoryProvider.of(context). It is used as dependency injection (DI) widget so that a single instance of a repository can be provided to multiple widgets within a subtree. BlocProvider should be used to provide Blocs whereas RepositoryProvider should only be used for repositories. If you want to provide more than one Repository, you can use MultiRepositoryProvider

BlocBuilder, BlocListener, and other widgets to react to state changes

To listen to state changes we can use the BlocBuilder widget specifying which Bloc you want to access and the emitted data type in the template

BlocBuilder<CounterCubit, CounterState>(
  builder: (context, state) {
    return ElevatedButton(
      onPressed: context.read<CounterCubit>().increment,
      child: Text("${state.value}"),
    );
  },
)

BlocBuilder<CounterBloc, CounterState>(
  builder: (context, state) {
    return ElevatedButton(
      onPressed: () => context.read<CounterBloc>().add(CounterIncrementEvent()),
      child: Text("${state.value}"),
    );
  },
)

Use BlocListener (or MultiBlocListener) if the UI child isn't directly affected by state changing and you just need to do something in response to state changes like navigation or showing modals etc...

 BlocListener<CounterCubit, CounterState>(
  listener: (context, state) {
    if(state.value == 10) {
      Navigator.of(context).pushNamed("/someroute");
    }
  },
  child: ChildWidget(),
)

You can use BlocConsumer if you need both builder and listener

BlocConsumer<CounterCubit, CounterState>(
  listener: (context, state) {
    if(state.value == 10) {
      Navigator.of(context).pushNamed("/someroute");
    }
  },
  builder: (context, state) {
    return Text("${state.value}");
  },
)

You can use BlocSelector if you need to react only to a part of state changing

BlocSelector<CounterCubit, CounterState, bool>(
  selector: (state) {
    return state.value > 0;
  },
  builder: (context, state) {
    return Text("is greater than zero? ${state}");
  },
)
BlocProvider.of and context shortcuts

As anticipated, we can access our Blocs and Cubits from the UI using Provider

final cubit = BlocProvider.of<CounterCubit>(context);

Or more simply using context

// do not use it in the build method to access the state, use it to access the Bloc in callbacks
final cubit = context.read<CounterCubit>();

If we want the widget to be notified of state change

// use it in the build method to rebuild the widget on state changes, not in callbacks only to access the state
final cubit = context.watch<CounterCubit>();

In addition, context.select can be used to retrieve part of a state and react to changes only when the selected part changes.

// use it in build method to rebuild the widget on selected state changes, not in callbacks only to access the state
final isCounterGreaterThenZero = context.select((CounterBloc b) => b.state.value > 0);
Observe all blocs

BlocObserver can be used to observe all Blocs

class MyBlocObserver extends BlocObserver {
  @override
  void onCreate(BlocBase bloc) {
    super.onCreate(bloc);
    print('onCreate -- ${bloc.runtimeType}');
  }

  @override
  void onEvent(Bloc bloc, Object? event) {
    super.onEvent(bloc, event);
    print('onEvent -- ${bloc.runtimeType}, $event');
  }

  @override
  void onChange(BlocBase bloc, Change change) {
    super.onChange(bloc, change);
    print('onChange -- ${bloc.runtimeType}, $change');
  }

  @override
  void onTransition(Bloc bloc, Transition transition) {
    super.onTransition(bloc, transition);
    print('onTransition -- ${bloc.runtimeType}, $transition');
  }

  @override
  void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
    print('onError -- ${bloc.runtimeType}, $error');
    super.onError(bloc, error, stackTrace);
  }

  @override
  void onClose(BlocBase bloc) {
    super.onClose(bloc);
    print('onClose -- ${bloc.runtimeType}');
  }
}

void main() {
  BlocOverrides.runZoned(
    () {
      // Bloc instances will use MyBlocObserver instead of the default BlocObserver.
    },
    blocObserver: MyBlocObserver(),
  );
}

Insights

Flutter dev - state management options

Bloc library documentation

Bloc implementation examples

About Us

This article is part of the Mònade Developers Blog, written by Mattia Cecchini.

 
Share this