Reducer vs. Finite State Machines: Understanding the Paradigm Shift
Ibrahim Ayuba
Posted on March 25, 2024
Alex boarded the bus with two of his kids. The kids were disturbing everyone around, but Alex remained silent. At this point, everyone grew angry with Alex, assuming he wouldn't discipline his kids, thus deeming him unreasonable.
Then, a fellow passenger shouted at Alex about the whole situation. Absent-minded, Alex realized what was happening and apologized to everyone. He added that his wife, the mother of the kids, had just passed away, and he was deeply troubled, oblivious to his surroundings as a result.
At this point, everyone around understood the situation and felt remorseful for hastily judging Alex's character without understanding his circumstances. If they had known about Alex's state, they would likely have behaved differently. Instead, they focused on the event—the playing children—and reacted, something most of them later regretted.
This phenomenon, from the field of Social Psychology, is called the Fundamental Attribution Error (FAE). It involves judging a person's behavior solely based on their disposition, such as temperament and personality traits, while completely ignoring their situation:
In social psychology, fundamental attribution error, also known as correspondence bias or attribution effect, is a cognitive attribution bias where observers underemphasize situational and environmental factors for the behavior of an actor while overemphasizing dispositional or personality factors.
Source: Fundamental attribution error (Wikipedia)
In this article, we'll delve into the paradigm shift between the reducer and finite state machines models and explore how the FAE can help us make more sense of these patterns.
The Reducer Paradigm
In our earlier story, the fellow passengers didn't take into account Alex's state before reacting to the children's disturbance. Their response was solely based on the event.
Similarly, the reducer model operates on an event-first approach, focusing solely on the episode and responding accordingly. React's useReducer()
hook and state management libraries like Redux rely on this paradigm.
When developing a straightforward application like a counter, the reducer pattern poses no challenges. You can easily employ React's useReducer()
hook and design logic for actions such as ADD_COUNT
, REDUCE_COUNT
, and RESET
without needing to consider additional states—life is good.
However, what about more complex applications, such as an API-call logic that can exist in multiple states? The reducer logic for such an app might resemble the following:
const reducerFetcher = (state, action) => {
switch (action.type) {
case "LOAD_DATA": {
return { ...state, status: "loading" }
}
case "LOADED": {
return { ...state, status: "success" }
}
case "CANCEL": {
return { ...state, status: "idle" };
}
case "RELOAD": {
return { ...state, status: "loading" };
}
case "ERROR": {
return {...state, status: "error" }
}
default: {
return state;
}
}
};
By simply examining this logic, you might notice some issues. For instance, if the LOAD_DATA
event fires while the app is already in a loading state, the application would initiate the loading process anew. This indicates that the reducer is open to processing any event at all times, which is not efficient. We need to take into account not only the event but also the current state to ensure more effective handling.
The Finite State Machine Paradigm
Back to our story. If the passengers had been aware of or inquired about Alex’s mood, they would have known how to react more appropriately. This could have spared them the burden of anger and frustration, as well as the embarrassing moment when they learned about the man’s situation.
The finite state machines pattern circumvents the Fundamental Attribution Error by prioritizing a state-first approach. This model evaluates the current state before determining how to respond to an event.
Every application you've ever built operates within a finite number of states, whether you've consciously acknowledged it or not. Take, for example, an audio player. At any given time, it can only be in one of several states: idle
, loading
, playing
, or paused
.
Each state permits a subset of all possible events within the application. Events can also trigger a change in state, known as a transition. In addition to transitions, events can prompt actions such as invoking a function, generating another event, or executing asynchronous operations.
Now, let's reimplement the same API-call logic we previously discussed using the useReducer()
hook, but this time adhering to the principles of finite state machines. There are two ways to accomplish this:
The Implicit Way
const reducerFetcher = (state, action) => {
switch (action.type) {
case "FETCH": {
if (state.status === "idle") {
return { ...state, status: "loading" };
}
return state;
}
case "FETCHED": {
if (state.status === "loading") {
return { ...state, status: "success" };
}
return state;
}
case "CANCEL": {
if (state.status === "loading") {
return { ...state, status: "idle" };
}
return state;
}
case "RELOAD": {
if (state.status === "success" || state.status === "error") {
return { ...state, status: "loading" };
}
return state;
}
case "ERROR": {
if(state.status === "loading"){
return {...state, status: "error" }
}
}
default: {
return state;
}
}
};
Indeed, while we continue to evaluate events, we now prioritize checking the state before reacting to them, ensuring that certain events are restricted to specific states, which aligns with our objective.
However, the logic becomes convoluted, making it challenging to discern the current mode of our application and the events associated with individual states. Moreover, as the application scales, the potential for nested if/else statements increases, making it difficult to manage and prone to errors.
The Explicit Way (Better Approach)
const initialState = {
status: "idle",
data: undefined,
error: undefined,
};
const fsmReducer = (state, action) => {
switch (state.status) {
case "idle": {
if (action.type === "FETCH") {
return { ...state, status: "loading" };
}
return state;
}
case "loading": {
if (action.type === "FETCHED") {
return { ...state, status: "success" };
}
if (action.type === "CANCEL") {
return { ...state, status: "idle" };
}
if (action.type === "ERROR") {
return { ...state, status: "error" };
}
return state;
}
case "success": {
if (action.type === "RELOAD") {
return { ...state, status: "loading" };
}
return state;
}
case "error": {
if (action.type === "RELOAD") {
return { ...state, status: "loading" };
}
return state;
}
}
};
Instead of switching based on the event, we're now switching based on the state. This approach allows us to reason with our logic more easily because the states and their associated allowed events are clearly defined.
However, upon closer inspection, we're still applying the reducer pattern, but this time only locally within individual states.
Embracing the well-established finite state machine paradigm can significantly alleviate the pains of development:
The traditional techniques (such as the event-action paradigm) neglect the context and result in code riddled with a disproportionate number of convoluted conditional logic (in C/C++, coded as deeply nested if-else or switch-case statements). If you could eliminate even a fraction of these conditional branches, the code would be much easier to understand, test, and maintain, and the sheer number of convoluted execution paths through the code would drop radically, perhaps by orders of magnitude.
Source: Who Moved My State?
Conclusion
Finite state machines offer a robust solution for managing complex behaviors in event-driven applications. While the reducer pattern provides a solid foundation, FSMs elevate this concept by promoting a structured state-first approach, thereby minimizing the requirement for convoluted logic.
This post marks the first installment of a four-part series, encompassing everything you need to begin constructing frontend applications using the robust principles of finite state machines. We've established the groundwork for comprehending FSMs in this article and will delve deeper in the next. Subsequent articles will concentrate on leveraging XState, a finite state machines library for JavaScript runtimes, for developing frontend applications.
If you found this article informative and are intrigued by the FSMs pattern in JavaScript, keep an eye out for the upcoming articles in this series. Stay tuned for more insights and practical applications!
Posted on March 25, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.