Disclaimer: This article is not an introduction to redux. There are a number of good resources to get started with redux (see below) and I suggest you begin with them. This article isn’t about react either. The term “lifecycle” is used in context of redux data flow and operations.
In just three years, redux has become quite a popular choice among frontend developers as the state management library of choice. Even more so in the react ecosystem.
It’s amazing (and impressive, really) how small and simple the codebase is. And yet it can handle highly complex situations with easily. Perhaps this simplicity of redux is a major driving factor for its widespread popularity.
In the next part, we’ll explore how middleware affect the redux lifecycle and the data flow.
Internally, redux only works with synchronous data flow and doesn’t come with many “moving parts” or magic methods like some other solutions.
The most basic redux setup can be thought of as a single loop of state updates that is made up of just 2 components:
Actions are accessory objects that are required to figure out how to update the state for the next iteration.
The reducer function takes in the previous state and the dispatched action as its arguments and returns the next state. If there are no changes needed, it returns the previous state as-is. Otherwise, it creates new state and returns it.
It is a common practice to split our state tree into multiple slices and write a separate reducer for each state slice. Actions may or may not be concerned with multiple state slices. This process of breaking our reducers into smaller and easier to understand pieces in a process called Decomposition.
Independent Reducer Lifecycles
We can now combine several independent reducers back into a single reducer (often called the “root” reducer) and create a redux store using it.
import { combineReducers } from 'redux'
import fooReducer from './foo'import barReducer from './bar'const rootReducer = combineReducers({foo: fooReducer,bar: barReducer,})
Alternatively, we could exclude the ‘Reducer’ suffix from our imports to make the code lighter.
import foo from './foo'import bar from './bar'const rootReducer = combineReducers({foo,bar,})
Basic Redux Lifecycle
When an action is dispatched, the root reducer receives it and passes the same action object to all reducers for them to react to it independently. However, instead of passing the entire state tree to each reducer, redux will only pass the reducer’s exclusive state slice. The return value from each reducer is then merged back into the complete state tree.
This is the basic redux architecture and it is the most common one as well. With careful planning and smart division of the the app state most small-scale apps can work with this architecture without any added complexities.
As your state tree grows complex, you may find yourself in situations where you need to share state between different reducers. A quick fix is to combine those reducers into one. However, doing so usually creates fat reducers that don’t scale well.
If we want keep our reducers separate, we need to figure out whether we need to share the current state slice or the updated version.
Since reducers are just functions, we can pass them any number of arguments as we want. If a reducer requires state slice from another reducer, we can pass it in as the third argument.
Current-State Dependent Reducers
We cannot combine dependent reducers using the combineReducers()
utility that ships with redux.
Once you go past the core use case for
combineReducers
, it’s time to use more “custom” reducer logic — Redux docs
Since reducers are just functions, we can pass in the state of a different reducer as a third argument.
import foo from './foo'import bar from './bar'function rootReducer(state, action) {const fooState = foo(state, action)const barState = bar(state, action, state.foo)
return {foo: fooState,bar: barState,}}
Combining Current-State Dependent Reducers
Generally, when a reducer depends on another reducer’s state, it requires the updated state. Personally, I have never come across a situation where the old state is required. If you know of any such experiences, do share them in the comments.
Updated-State Dependent Reducers
The code looks almost the same as it was in the previous section. We have made only a small change. Instead of passing the old state in state.foo
, we are now passing the updated state in fooState
.
import foo from './foo'import bar from './bar'function rootReducer(state, action) {const fooState = foo(state, action)const barState = bar(state, action, foo_State_)
return {foo: fooState,bar: barState,}}
Combining Updated-State Dependent Reducers
Dependence on updated state of other reducers means that there is a vertical hierarchy of reducer invocation. This doesn’t mean much for trivial cases but the idea does hint the possibility of chaining reducers together. However, reducer chaining will increase the code complexity exponentially.
Note: Another alternative to the “shared-slice updates” issue would be to simply put more data into the action. You can read more about it here.
Now that we’ve seen some patterns of laying out reducers and combining them in isolation, let’s see what they look like in union. Here’s a list of key things to observe:
Complete Redux Lifecycle
In all of the drawings, I intentionally left out action creator(s) and the store to keep them clean and free from bloat. As you know: