Minimal #1 - the UI state
Overview
I assume you're aware of Minimal, and you want to use it to implement a scalable and maintainable architecture. I also assume you (may) have watched my presentation about Flutter Architecture Components.
In this post I'd like to show you the first step, where you want to define the UI state. It could be done also the other way around, starting from the data layer and then implementing everything until you reach the UI and its state, but empirically I found out that starting from a well defined UI state will help you to reason better about your code.
The code of the Pokemon app can be found here.
State, in brief
With Minimal you have 3 players:
- a state
- a notifier which holds your state
- a manager which allows you to access your notifier
A state can be anything, from a single primitive to a very complex class. When the state contains the information needed for a UI to build, it's called a UI state. There are 2 types of UI states:
- ephemeral state
- app state
You can't handle app wide states with setState(), you'll need a state management solution for them, like Provider, Riverpod, or Minimal.
It's in this context that you need to define your UI state; for more info please refer to my presentation or my slides.
Immutable UI state
It is quite a common convention to define the UI state as immutable, even if it could be debatable; for instance Thomas demonstrated state doesn't have to be immutable, and I pointed out to Christian that Freezed is not truly immutable. The same applies for Dart Mappable and many others packages. Nevertheless, I think defining your UI state immutable is a good healthy habit, with the purpose of trying to reduce side effects; for more experienced developers it may be unnecessary, but I still would recommend to define it immutable most of the time if not always.
That given, you can use packages like Freezed or Dart Mappable or many others, to define your immutable UI state. There are also IDE's plugins which can generate the code for you. The only implicit assumption is that you'll overwrite equality and hashcode properly, as any state management solution (included Minimal) relies on them internally to compare two different states.
UI as a finite state machine
In practice, when you want to define your UI state, I recommend to start to look at the UI first. Here I reason about a whole page, but the same can be applied for a complex widget, so for the purpose of this post one full screen page or a small part of it are completely interchangeable.
Let's imagine an app where you want to show a list of items, for instance a list of Pokemon. Definitely the list of Pokemon is part of the UI state. Also, initially you'll have to fetch them from the server, so until the fetch is successfully done, you'll have an empty state with no Pokemon.
Let's call such states s0, s1, s2, and so on; the number of the state indicates the number of items shown on UI.
The transition from s0 to s1 requires fetching new data, so there's a step in between to show a loading indicator, which we could call s0L (loading); so we have s0 -> s0L -> s1.
The fetching from s0 to s1 could also fail, so there's another step instead of s1 to show an error indicator before we go back to s0, which we could call s0E (error); so we have s0 -> s0L -> s0E -> s0.
The same can be applied for any state, so we have an almost infinite state machine representation of our UI:
This representation is a good mental model which reflects the very famous Flutter's formula "UI is a function of (immutable) state":
UI = f ( state )
Let's see next how to implement such a UI state.
Implementing the UI state
In the previous section we understood the UI state must contain a list of Pokemon, the loading, and the error. So the class to represent the Pokemon page UI state could be:
When this class is instantiated, it contains no Pokemon, no loading and no error, so it represents our initial state s0, which is what is passed to the notifier as initial value:
s0 is what the UI draws the very first time the page it's built, before it does anything else, before it even start the fetching, because the build() method should always draw and nothing else. Any async operation, like fetching, is done by the notifier, so the UI draws s0 (UI builds), then with a post frame callback triggers the fetching (or alternatively at a button's click), which moves the state to s0L (UI rebuilds) and only when the fetching is completed the state is moved to either s1 (fetching successful, UI rebuilds) or to s0E (fetching unsuccessful, UI rebuilds) and then s0 (UI rebuilds).
Building the UI state
For each change in the state, the UI needs to rebuild, so let's have a look at how the build() method can handle to build the appropriate widgets given a state.
Usually if you want to show a spinner, and then a list of items when successful, or an error page when unsuccessful, you'll have something along the lines of:
but, in our case we want both the loading and the error to be non-blocking for the user, so that the user can still navigate into the details page and come back to the Pokemon page while there is a fetching ongoing.
So a better approach in this case is:
whereas each widget will build only when a selected part of the state changes, avoiding unnecessary rebuilds.
The Pokemon list widget doesn't need to build when the whole UI state changes, it needs to build only when the Pokemon items change, and when that happens, it will build either the empty list page or the Pokemon list page. This can be done selecting part of the UI state for changes:
Similarly, the loading indicator widget doesn't need to build when the whole UI state changes, it needs to build only when the isLoading changes, and when that happens, it will build either the loading indicator or an invisible dot:
This way the loading indicator appears and disappears on top of the Pokemon list, thanks to the stack, in a non blocking manner, and it builds only when there is a fetching ongoing, avoiding unnecessary rebuilds when other parts of the UI state change.
Building the widgets based on a UI state which represents a finite state machine satisfies a very performant requirement, because the build() method only builds, and the asynchronicity of any operation and the business logic are delegated to the notifier.
Updating the UI state
We mentioned the UI state is immutable, so to mutate it a new hard copy needs to be created, and then the notifier needs to notify about the mutation. This represents one of the arrows in the finite state machine diagram, when you transition from a state to the next one: sN -> sN+1.
When you want to stop the loading, either because the fetching succeeded or failed, you'll set isLoading to false, at the same time setting also the Pokemon items or the error fields respectively:
In case of an error, for instance, the Pokemon list is kept from the previous state, allowing you to access it at any point.
Non UI state
So far we focused on UI states, but as I mentioned earlier not all states are UI states. For instance you want to have an auth notifier, to keep track of the authentication state, so that you can show parts of the UI only when authenticated, and redirect the routing when non authenticated:
When the state is so simple that you can represent it as a primitive, you don't need even to define a class for it, and you can just define the type in the notifier. For instance a counter's state can be implemented as an integer:
Conclusion
In this post I have shown you the first step to use Minimal, to define the UI state. I briefly gave you the context for the state and its immutability, gave you a solid mental model of a state as a finite state machine, showed you how to implement it, how to build the UI based on it, and how to update it, and last but not least gave you also examples of non UI states.
In the next post I will show you how to create a notifier to hold your state.
Thank you so much for taking the time to write this wonderful article! Your insights into defining and managing UI state with Minimal are incredibly helpful, and I really appreciate the detailed examples and explanations. It's clear you've put a lot of effort into this, and it has made the concepts much easier to grasp. Looking forward to the next post.
ReplyDelete