We’re now about 3 months into the project (with a short detour to work on itsallwidgets.com), I thought it may be helpful to share some more thoughts on using Redux to manage state in Flutter.
What about BLoC?
While I haven’t used it myself developers are clearly very happy with the BLoC pattern. My sense is the differences between Redux and BLoC are similar to Redux and MobX. I think Dart’s first class support for streams combined with the reactive nature of Flutter make the BLoC pattern a great fit. It’s worth noting that under the hood the flutter_redux library is just providing a wrapper for StreamBuilder, you don’t have to feel like you’re missing out using streams with Redux.
I’m endorsing MobX because I hear its happy users and I hate to see FUD against new approaches. But I still prefer immutability 😉
Another valid criticism of Redux is that debugging the code can require a lot of searching for matching actions. I don’t have any experience developing plugins for an IDE but I’m curious if it’s possible to build a plugin which understands the app state and reducers, enabling you to trace the Redux flow the same way you can drop down into the source code. UPDATE: turns out it’s possible… here’s the plugin.
Don’t rebuild too much
The main challenge we faced initially was preventing too much of the app from getting rebuilt when the state changed. For example, we weren’t able to keep a bottom sheet open if a setting was selected. The problem was that we were using a StoreBuilder in main.dart, this meant any change rebuilt most of the app. The solution was to move the rebuilding further down the widget tree limiting it to just the parts of the app which needed to be updated.
If you’re basing your app of off Brian Egan’s excellent Redux architecture sample take note that this approach is currently used. It can work for some apps but it’s definitely something to be aware of.
Related to this it’s important to note that all widgets, including non-visible widgets further down the navigation stack, are rebuilt when the state changes. In our app we simply keep the navigation stack short. but you can set distinct to true to only update the view when the state is updated. Note: this also requires overriding == and hashCode on the view model.
I think the main criticism of Redux is that small changes to the functionality can require large changes to the code. In my experience this is accurate. These metrics are from Brian’s comment on the Reddit post linked above.
At this point, scoped_model requires the least amount of code at ~800 LOC. Next best is simple_bloc at around ~900. Finally Redux was ~1200.
It was clear from the outset that app would require a lot of boilerplate code, rather than write it by hand we created a library to help generate it for us.
While I agree less code is generally better than more code (every one less line of code we write is one less line for a bug to hide in), our main objective is to build the best app with the fewest bugs possible. Redux provides a form of state machine for your UI/UX making it far more predictable, that benefit though does require paying a Redux tax in the form of additional lines of code.
I mentioned this in the last post but it’s too important not to mention again. If you’re building a Redux app strongly consider using built_value to handle immutability. It’s possible to build a Redux app without it however it would require writing a ton of extra code by hand.
A great feature is that it automatically adds support for object equality, this is extremely useful when managing forms. One challenge however is if the app supports creating multiple blank records they’ll end being equivalent. Our solution is to use a static counter to set a negative id for new records, once they’re saved to the server the ids are replaced.
Although built value supports the @nullable annotation I’d advise avoiding it if possible. To support creating new records in the app we use a factory constructor which sets default blank values. One of the many problems with using the @nullable annotation is a new record will appear changed by the form because the field will be set to an empty string rather than null.
It’s worth noting if you’re making a lot of changes consider using the watch command rather than the build command, it’s much quicker to update the files.
We created this sample app which demonstrates our approach to forms. To persist state while typing we listen for changes to the TextEditingController, when they’re detected we use the view model to update the store. When the user clicks ‘Save’ we run the form validation, if it passes we persist to the server.
To support localization (ie, number or date formatting) we initially passed all of the supporting data to the forms in the view model however found it required a fair amount of extra code mapping each of the fields and then passing it down. Instead we now just pass the context to a formatting utils class and use StoreProvider.of<AppState>(context).state; to access the state.
If you use this approach it’s important to move any initialization code from initState to didChangeDependencies. This is spelled out clearly in the docs for the State class.
Subclasses of State should override didChangeDependencies to perform initialization involving InheritedWidgets.
Like most design patterns done right Redux can be amazing, however if done wrong it can become painful to say the least. One critical aspect is separating application state from transient UI state. We’ve found we use a combination of dispatching actions alongside some calls to setState() for cases where we’re managing transient state.
To support changing top level settings such as the application theme or locale we use a custom AppBuilder widget, this enables to on-demand rebuild the entire app. You can read more about it here. Our top level widgets are ReduxStore > AppBuilder > MaterialApp, this enables us to rebuild the MaterialApp without affecting the store.
If you’re using a form in a dialog and the state changes the form will rebuild with the latest values from the state, this may or may not be the desired behavior. We prevent it by checking if the form has already been initialized.
In middleware functions take note of whether next(action); is at the beginning or end of the function, it will determine whether or not the state is updated before/after the code is executed.
If you’re using the flutter_redux package I’d highly recommend installing redux_logging, it automatically prints the Redux actions to the console. This is really helpful when trying to debug the app.
By default the output will include the entire state (which can get pretty big), you can override the toString method on the state class to just print the relevant info.
Flutter is amazing… whether you use Redux, BLoC, ScopedModel or something else you’ll be able to quickly build a great app. In my time with Redux I’ve found it works incredibly well for our use case (a CRUD style app). I think a key metric for these choices is whether development speeds up or slows down over time, we’ve found we’re constantly accelerating.
If you’d like to learn more I’ve also written about our overall architecture, managing complex forms and other Redux topics. You can follow @hillelcoren for my latest thoughts on Flutter. If anything’s unclear please let me know.