Flutter State Management: Riverpod, Bloc, Signals, GetX

Sandro Maglione

Sandro Maglione

Mobile development

Which state management package should you use? In this post we compare riverpod, bloc, signals, and get to understand how they work and what are their pro and cons:

  • What packages to install in pubspec.yaml
  • How to define state
  • How to provide the state to a flutter app
  • How to read and update the state

Required packages

Riverpod

Since riverpod uses code generation we need to add some dependencies required to annotate classes (riverpod_annotation) and run code generation (riverpod_generator and build_runner).

It is also recommended to add riverpod_lint to prevent common issues and simplify repetitive tasks:

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
 
  flutter_riverpod: ^2.4.9
  riverpod_annotation: ^2.3.3
 
dev_dependencies:
  flutter_test:
    sdk: flutter
 
  riverpod_generator: ^2.3.9
  build_runner: ^2.4.8
  custom_lint: ^0.5.8
  riverpod_lint: ^2.3.7

Bloc

Bloc with flutter requires to add flutter_bloc. If you want to use annotations (for example @immutable) you also need to install meta:

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
 
  flutter_bloc: ^8.1.3
  meta: ^1.10.0

Signals

signals is a single package, no need of anything else:

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
 
  signals: ^2.1.10

GetX

get is also a single package:

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
 
  get: ^4.6.6

Defining state

Riverpod

riverpod uses code generation to define providers.

For simple state values you just need to annotate a function with @riverpod:

@riverpod
GridSettings gridSettings(GridSettingsRef ref) {
  return const GridSettingsDefault();
}

This works the same if the function returns a Future:

@riverpod
Future<Dictionary> dictionary(DictionaryRef ref) async {
  return Dictionary.init();
}

ref allows you to access other providers (for dependency injection):

@riverpod
GridRepository gridRepository(GridRepositoryRef ref) {
  final gridSettings = ref.watch(gridSettingsProvider);
  return GridRepositoryImpl(
    Random(),
    gridSettings,
    const EnglishAlphabet(),
  );
}

ref.watch allows to listen and react to changes of the watched provider

For more complex values that change over time you need to:

  • Define and annotate a class with @riverpod
  • Add extends _$NameOfTheClass
  • Provide a build method used to initialize the provider state
@riverpod
class BoardNotifier extends _$BoardNotifier {
  @override
  Board build(GridRepository gridRepository) =>
      Board.init(gridRepository.generateGrid);
 
  /// ....

Riverpod will generate different providers (Provider, FutureProvider, StateNotifierProvider).

Using code generations allows to define all providers in the same way (@riverpod) regardless of their internal definition.

Bloc

Defining a full bloc requires 3 files:

  • State definition (_state)
  • Events definition (_event)
  • Bloc implementation (_bloc)

Define a new folder for each bloc containing 3 files for state, events, and bloc.Define a new folder for each bloc containing 3 files for state, events, and bloc.

Note: Events are not necessary if you use cubit instead of a full bloc

State is usually defined as a sealed class, listing all the possible (finite) states:

@immutable
sealed class DictionaryState {
  const DictionaryState();
}
 
class InitDictionary extends DictionaryState {}
 
class LoadingWords extends DictionaryState {}
 
class InvalidDictionary extends DictionaryState {
  final Object e;
  const InvalidDictionary(this.e);
}
 
class ValidDictionary extends DictionaryState {
  final Dictionary dictionary;
  const ValidDictionary(this.dictionary);
}

State does not need to be a sealed class, it can be any value that changes over time:

class Gesture {
  final Set<GridIndex> indexes;
  const Gesture._(this.indexes);
 
  factory Gesture.empty() => const Gesture._({});
 
  /// πŸ‘‡ Change the state by adding a new [GridIndex]
  Gesture add(GridIndex gridIndex) => Gesture._({...indexes, gridIndex});
 
  bool isSelected(GridIndex index) => indexes.contains(index);
}
 
///                                         πŸ‘‡ Bloc state
class GestureBloc extends Bloc<GestureEvent, Gesture> {

Events instead are always defined as sealed class. They represent all possible actions that change the state:

@immutable
sealed class GestureEvent {
  const GestureEvent();
}
 
class OnPanStart extends GestureEvent {
  final DragStartDetails details;
  const OnPanStart(this.details);
}
 
class OnPanUpdate extends GestureEvent {
  final DragUpdateDetails details;
  const OnPanUpdate(this.details);
}
 
class OnPanEnd extends GestureEvent {}

Signals

A signal is defined by wrapping any value with signal:

final gridSettings = signal(const GridSettingsDefault());

The signals package provides some other functions for specific values:

  • Future (futureSignal)
  • List (listSignal)
  • Set (setSignal)
  • Map (mapSignal)
  • Iterable (iterableSignal)
  • Stream (streamSignal)
final dictionary = futureSignal<Dictionary>(
  () => Dictionary.init(),
);

When a value is derived from another you instead use computed:

final gridRepository = computed(
  () => GridRepositoryImpl(
    Random(),
    /// πŸ‘‡ This will react to updates to `gridSettings`
    gridSettings.value,
    const EnglishAlphabet(),
  ),
);

GetX

Using get you can turn any value into an observable by calling .obs:

final gridSettingsObs = const GridSettingsDefault().obs;

obs wraps the value into a Rx that internally handles reactive state updates

For more complex states instead you create a class that extends GetxController:

class GestureController extends GetxController {
  var gesture = Gesture.empty(); /// πŸ‘ˆ Define internal state
 
  /// ...

There is more.

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 700+ readers.

Using state with Flutter

Riverpod

Riverpod requires to wrap the entire app with ProviderScope:

void main() => runApp(const ProviderScope(child: MyApp()));

You then use a ConsumerWidget to access ref to watch/read providers:

class Grid extends ConsumerWidget {
  const Grid({super.key});
 
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final dictionaryAsync = ref.watch(dictionaryProvider);
 
    final gridRepository = ref.watch(gridRepositoryProvider);
    final gesture = ref.watch(gestureNotifierProvider);
    final gridSettings = ref.watch(gridSettingsProvider);
    final board = ref.watch(boardNotifierProvider(gridRepository));
 
    final gestureNotifier = ref.watch(gestureNotifierProvider.notifier);
 
    return /// ...
  }
}

Bloc

Bloc requires to define all blocs using a provider (BlocProvider or RepositoryProvider):

Widget build(BuildContext context) {
  return MaterialApp(
    title: 'Bloc State Management',
    home: Scaffold(
      body: BlocProvider(
        create: (context) => DictionaryBloc()..add(OnInitDictionary()),
        child: /// ...

You can then use BlocBuilder, BlocListener, BlocConsumer, context.read, context.watch to read the bloc state:

/// No need to use `ref` or [ConsumerWidget]
class Grid extends StatelessWidget { 
  const Grid({super.key});
 
  @override
  Widget build(BuildContext context) {
    final gestureBloc = context.read<GestureBloc>();
    final gestureBlocState = context.watch<GestureBloc>().state;
    final gridSettings = context.watch<GridSettings>();
    final boardBloc = context.watch<BoardBloc>();
    return /// ...

Signals

With signals listening to state changes requires wrapping a widget with Watch.

You can then simply access any signal state using .value:

final dictionary = futureSignal<Dictionary>(
  () => Dictionary.init(),
);
 
class Grid extends StatelessWidget {
  const Grid({super.key});
 
  @override
  Widget build(BuildContext context) {
    return Watch((context) {
      final dictionaryAsync = dictionary.value;
      /// ...

GetX

GetX allows to listen to any observable value (.obs) by wrapping an observable state with the Obx widget:

final gridSettingsObs = const GridSettingsDefault().obs;
 
Obx(
  () => GridLayout(
    gridSettings: gridSettingsObs.value,
    onPanStart: (details) => gestureController.onPanStart(
      gridSettingsObs.value,
      details,
    ),
    /// ...

For controller classes you can call Get.put inside build:

class Grid extends StatelessWidget {
  const Grid({super.key});
 
  @override
  Widget build(BuildContext context) {
    final dictionaryController = Get.put(DictionaryController());
    /// ....

If you prefer to use widgets you can instead use GetBuilder:

GetBuilder(
  init: GestureController(),
  builder: (gestureController) => /// ...

Update the state

Riverpod

You can modify the state with riverpod by updating the state value inside a class provider (StateNotifierProvider).

Riverpod will react to the state change and update the widget state:

@riverpod
class GestureNotifier extends _$GestureNotifier {
  @override
  Gesture build() => Gesture.empty();
 
  void onPanStart(GridSettings gridSettings, DragStartDetails details) {
    final pos = _panIndex(gridSettings, details.localPosition);
    state = state.add(pos.index);
  }

Bloc

Bloc uses events to trigger state changes.

A bloc provides an add method where you pass an event:

Widget build(BuildContext context) {
  final gestureBlocState = context.watch<GestureBloc>().state;
  final gridSettings = context.watch<GridSettings>();
  final boardBloc = context.watch<BoardBloc>();
  
  final gestureBloc = context.read<GestureBloc>();
  return GridLayout(
    onPanStart: (details) {
      gestureBloc.add(OnPanStart(details));
    },

Inside the bloc you define how to handle all events using on.

You then call emit with the updated state:

class GestureBloc extends Bloc<GestureEvent, Gesture> {
  final GridSettings _gridSettings;
 
  GestureBloc(this._gridSettings) : super(Gesture.empty()) {
    on<OnPanStart>(
      (event, emit) {
        final pos = _panIndex(event.details.localPosition);
        emit(state.add(pos.index));
      },
    );

Signals

A signal can be updated by simply assigning its value:

onPanStart: (details) {
  final pos = _panIndex(gridSettings.value, details.localPosition);
  gesture.value = gesture.value.add(pos.index);
}

Changing value will trigger an update for the signal and all the values that depend on it (computed).

GetX

A GetX controller can store some mutable state (var).

You can update this state and call update to trigger a UI change with the new value:

GetX will rebuild GetBuilder each time update is called

class GestureController extends GetxController {
  var gesture = Gesture.empty();
 
  void onPanStart(GridSettings gridSettings, DragStartDetails details) {
    final pos = _panIndex(
      gridSettings,
      details.localPosition,
    );
 
    gesture = gesture.add(pos.index);
    update();
  }

This is it!

You now have a complete overview of how each package works for all the most common requirements to manage the state of your flutter app.

You can now compare each solution and choose the most appropriate for your project 🀝

πŸ’‘ Make sure to check out also all the extra features that each package offers (caching, routing, devtools, effects and more)

If you are interested to learn more, every week I publish a new open source project and share notes and lessons learned in my newsletter. You can subscribe here below πŸ‘‡

Thanks for reading.

πŸ‘‹γƒ»Interested in learning more, every week?

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 700+ readers.