Core concepts
Actions
The following is an exploration of what Actions are conceptually and how they are used in Flutter Forge.
Concept
Actions are actually pretty simple conceptually. They can be thought of as a special type of Message that gets sent from a Widget to its Store so that it can appropriately change the State the Store manages. It is also important to understand that Actions are specifically Messages that represent an action a user performed in the Widget or a result of an Effect.
This concept is not new. In fact, it is taken from the Redux pattern which we use inside the scope of a component within Flutter Forge.
As you can see in the diagram above it shows that Actions flow to State, which the View then consumes by updating itself for the user. In turn often triggering the user to take an Action and continuing the loop.
In Practice
User Interaction Actions
To explore this further let's imagine that we have a simple counter Widget where we have a child Text
widget that is showing the current count and an Increment Button that when tapped increments the count. Something like the following.
In this example there is only one interaction (a.k.a. action) that a user can take. That is to tap the Increment Button.
In Flutter Forge to facilitate this we need to formally define this interaction as an Action.
To facilitate better Type Safety and better guidance from the compiler Flutter Forge requires that each component define an abstract/sealed base Action type for all of that component's Actions to fall under. In the context of our Counter example this would look as follows.
sealed class CounterWidgetAction implements ReducerAction {}
Once we have our base Action we simply define an Action that extends that base. Continuing our example, creating an Action for the user tapping the increment button we would do the following.
class CounterWidgetIncrementButtonTapped extends CounterWidgetAction {}
Naming
Earlier we mentioned that an Action is nothing more than a Message to represent an action a user performed in the Widget. This is important and actions should be named as such. CounterWidgetIncrementButtonTapped
. If we break this down the CounterWidget
prefix is just there to a namespace, as Dart doesn't natively support the concept of a namespace. The IncrementButtonTapped
describes the interaction that a user took. This is exactly what we want to see.
However, people often try to name Actions based on the impact they might have on the State. For example people would often try and name this action, CounterWidgetIncrementByOne
. This is bad and incorrect because we have broken an abstraction. Now the view has knowledge of specifically how the user's interaction is going to be mutating state. We distinctly do not want this. We want separation of concerns so that when we need to change the behavior that occurs when a user taps the increment button we only have to do it in one place. If we named our Action based on the state change, then we would need to rename the Action when the behavior around state change is updated. We would also then have to update the view to use the newly named action.
If on the other hand we name our actions based on the user interaction in the view, then we would only have to change the behavior and not rename the action or even touch the UI.
Effect Result Actions
The other use for Actions is to define results of a EffectTask, conceptually just an Effect. An effect is simply a task to specifically handle non-pure (side effecting) work. This generally includes things like, interacting with the file system, interacting with the networking, etc.
When handling an Action in the Reducer you return a ReducerTuple which is composed of State and an array of zero or more EffectTasks. The following is an example of a defined Effect.
class SomeWidgetEffects {
static final loadNameEffect =
EffectTask<State, Environment, LoadOnComponentInitAction>(
(state, environment, context) async {
final name = await environment.getName();
return ObtainedName(name);
});
}
In the above example we have a class called SomeWidgetEffects
. This only exists to provide a namespace as Dart doesn't support the concept of a namespace. Then inside that we have defined an Effect named loadNameEffect
. This is the actual EffectTask. Inside the EffectTask we can see that it is calling an async method named getName
on the injected environment and then returning an Action named ObtainedName
along with the associated name. Note: Effects don't have to return an Action. It is actually pretty common to have what we think of as Fire and Forget Effects.
Naming
Actions that are the result of an Effect should be named based on the context of the Effect. This means it shouldn't be named based on any knowledge of things outside the Effect, and it shouldn't be named based on State change. This follows the same principles and reasoning of the naming of Actions that represent user interactions.
In the above example we have an Action named ObtainedName
. This is an example of a good name for this type of Action.
But again people often tend to name Actions based on State change resulting in something like, SetName
, which is a bad example.
Actions & Data
Often time user interactions have data related to them. For example if we had a slider in our component we would want to relay the information about the slider value through the action. To do this in Flutter Forge you simply add the data as members of the action. This would look as follows.
class CounterWidgetSliderChanged extends CounterWidgetAction {
CounterWidgetSliderChanged(this.value);
final int value;
}
The above is just a simple example with a single value. But actions in Flutter Forge are just classes used for identification of type and data storage. So you can make the data storage as complex as you need or want. In practice, they stay relatively small, but technically there are no limitations enforced by Flutter Forge.
Sending Actions
The other place you will interact with actions within Flutter Forge is from within the Widget. This is done when the user interacted with the Widget, and we want to send an Action to the Store so that the Store can handle it appropriately. The following is an example of this.
OutlinedButton(
onPressed: () => viewStore.send(CounterWidgetIncrementButtonTapped()),
child: const Text("increment"))
In the above we have a button that when it is pressed we are sending the CounterWidgetIncrementButtonTapped
action to the store.