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:
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
:
dependencies:
flutter:
sdk: flutter
flutter_bloc: ^8.1.3
meta: ^1.10.0
Signals
signals
is a single package, no need of anything else:
dependencies:
flutter:
sdk: flutter
signals: ^2.1.10
GetX
get
is also a single package:
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.
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 aRx
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 π€©
Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.
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 timeupdate
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.