On state management in Flutter
And why you might want to consider Redux.
By Craig — 01 May 2020
We’re almost ready to start coding our Flutter app. Where do we begin?
Model, View, Confusion
One of the most difficult and time-consuming aspects of app development is managing state.
Traditionally, app developers have followed the approach known as Model View Controller. We created hierarchies of views, bound them to variables in controller classes, and carefully updated those views whenever data in the underlying model changed. If the presence of some data in a view depended on ten different conditions, then we had to remember to update it whenever any one of those conditions changed. As our apps grew more advanced, they started incorporating all manner of unpredictable potential state changes, especially from asynchronous and background events, and we struggled to keep up.
Most of the pioneering work in trying to solve this problem has come from our web developer friends, and it’s from their world that we’ve seen the rise of the “reactive” programming style, in which the relationships between data are described statically, and changes propagate automatically through these relationships. It’s often compared to a spreadsheet containing formulae, where changing the value in one cell can trigger a propagation of changes to anywhere else in the sheet. It’s also a style being rapidly adopted by Apple in SwiftUI and by Google with Data Bindings.
Flutter feels reactive
Flutter makes it virtually impossible to work in the traditional MVC approach I’ve described above, and very straightforward indeed to adopt some form of reactive programming.
That’s because it treats the entire UI hierarchy as a tree of lightweight views-widgets in the Flutter vernacular-that’s entirely bound to some state. When the state changes, any part of the tree that represents it must be rebuilt, and the tree gets very deep because the standard way of applying padding, alignment, and even styling is via nesting widgets inside others that confer these properties and do nothing else. Here’s an example of a simple row with a Text widget and some padding.
Padding(
child: Row(
children: <Widget>[
Text(user.name)
],
))
There’s no obvious way to get a reference to that innermost Text
widget, so how do we update it? The answer is that we make a change to user.name
, and trigger a rebuild of the widget tree. It seems wasteful, but Flutter renders its views itself rather than relying on any native UI SDK, and its authors have optimised it to work in this way. It works very well!
Deciding how not to do it
In Flutter, basic state management is achieved using a function called setState()
, which you call to rebuild a widget once the data underlying its properties have changed. This suffices for many, if not most apps, and it’s more powerful than many give it credit for.
But to me, it feels like we’re doing unnecessary work. If we’re aiming for a reactive programming style, I shouldn’t have to tell my widgets when their underlying data has changed. It’s also not trivial to update widgets that aren’t direct descendants of where you’re calling setState()
: you need to implement callbacks, which feels like all the passing-around-delegates we traditionally had to do on iOS. You can’t entirely discard setState()
; I’m using it in some widgets, primarily where I need animation or “micro-states” like UI interaction effects. But we’re truly spoiled for choice when it comes to alternative state management patterns in Flutter, so we needn’t settle!
Scoped Model is a simple way of passing model data down to widgets, but I didn’t like the approach of having each view model be responsible for “broadcasting” that its data has changed, via the notifyListeners()
function.
Google, for its part, recommends us to consider the BLoC pattern for complex apps. I won’t go into it here, but you can find nice tutorials online. My objection to it is that it appears to require too many manual steps, including a need to override Flutter’s dispose()
method. It also pushes you to use RxDart, which is to my eye overly complicated.
Perhaps in my rejection of BLoC and RxDart you can sense that I’m not comfortable with the “extremes” of reactive programming. I find it hard to conceptualise an app as a confluence of data streams flowing and intersecting, and easier to think of it as an entity which presents to the user one of several discrete states, between which there are predictable and easy-to-understand transitions backed by business logic. It so happens that there is a state management pattern which models apps just like this, and it’s called Redux.
Choosing Redux
There are great explanations of Redux all over the Internet so I won’t get too deeply into this, but the main idea is that you bind your UI to the properties of a state object that’s simply representation—no logic—and apply changes to the state in functions called reducers. Reducers are “pure”, which means that given the same inputs they always produce the same output. The inputs to a reducer are simply the state itself and an action which indicates what business logic has to be performed. The output is a new and changed copy of the state. The state and reducers are both registered with a store which is responsible for passing actions to reducers and—since this is Flutter—triggering rebuilds of the widget tree on state changes. The widgets for their part observe properties of the state and dispatch actions to the store.
It looks complicated, but it boils down to some pretty simple principles, and might be represented thusly:
The advantages are:
- predictable journey from widget action to state change, and from state change to view update;
- it’s clear what bits go where: I don’t need to dive deep into my widget tree to find where the business logic is hidden, because I know it’s all in the reducers;
- since states are copied rather than mutated, it’s trivial to log all state changes when they pass out of reducers and even “time travel” through past states for debugging purposes;
- being able to dispatch actions from anywhere in the widget tree liberates us from the need for delegates and callbacks.
Applying Redux
First up, we need a Redux implementation for Flutter. The de facto standard at the moment is the Flutter Redux library by Brian Egan (he’s also responsible for Scoped Model and about a million other Dart packages and is generally just a Very Smart Guy). Here’s a nice tutorial which I found very helpful: https://flutterbyexample.com/redux-app-getting-to-start.
Project structure
Here’s the structure I’ve opted for:
Some decisions made here were:
-
A folder for Redux-specific parts of the app, but excluding state, which comprises dumb model objects with some convenient copying methods. Keeping state away from the rest of the app removes the temptation to add any “intelligence” there, and keeps the code clean if I ever want to switch away from Redux to another architecture.
-
In Flutter everything is a widget, but for ease of code navigation I chose to treat some top-level widgets as “screens”, and to put these in a separate folder.
The first screen, plus Redux
The opening screens of the app I’m working on display a navigable hierarchy of “challenges”—quizzes on different aspects of the Chinese language—that are organised into groups and above that difficulty levels. All of this data has to be loaded from a JSON file.
Flutter supports performing the necessary file operations asynchronously (we’re going to copy it from the app’s assets into its documents folder), so the final problem is how to perform background work from Redux.
There are a few solutions and the simplest is to use middleware. Middleware is commonly encountered in Redux-based applications where some action has to take place that won’t affect the state—this is known as a “side effect”—or might cause the state to change but only later (like a network request). Middlewares (for there can be as many as you need) see every dispatched action and have the chance to do something with it, but won’t ever modify the app state themselves.
We’re going to write an AssetsMiddleware
class that prepares the data structures we need to display the hierarchy of levels, groups and quizzes.
class AssetsPrepareLevelsAction { }
class AssetMiddleware implements MiddlewareClass<AppState> {
final String pathToAssets;
AssetMiddleware(this.pathToAssets);
@override
Future call(Store<AppState> store, action, NextDispatcher next) async {
if (action is AssetsPrepareLevelsAction) {
String pathToLevels = await Asset("$pathToAssets/levels", "levels").prepare();
String levelsString = await File(pathToLevels).readAsString();
List<dynamic> levelsJson = jsonDecode(levelsString);
store.dispatch(LoadLevelsAction(Level.levelHeirarchyFromJson(levelsJson)));
}
next(action); // don't forget, since other middlewares might look at this action
}
}
}
If you’re curious about Asset
, you can see that in this Gist.
Here’s how the main entry point into the app looks now:
void main() {
final store = new Store<AppState>(
AppStateReducer(),
initialState: AppState.initial(),
middleware: [
AssetMiddleware("assets"),
AudioMiddleware(),
]);
runApp(new App(
store: store,
));
store.dispatch(AssetsPrepareLevelsAction());
}
You can see that there’s another middleware class here named AudioMiddleware
: playing audio files is another good candidate for middleware since it produces plenty of side effects!
The middleware picks up the AssetsPrepareLevelsAction
, prepares the level data and finally dispatches LoadLevelsAction
, which is handled by a reducer to update the app state so that our widget tree can display the loaded levels.
Phew!
Next steps
Next time, we’ll take a look at some other ways you can improve your Redux workflow in Flutter. Stay tuned!