Over the years, I've had the privilege of working with many production react
+ redux
codebases across a number of industries. Most start off simple, but code complexity seemed to grow much faster than feature complexity. Much of this can be attributed to misunderstanding of how state management can/should be done. I hope this article demystifies state management for you.
In Control Theory engineering, one pair of equations is often used to describe dynamical systems :
x[t+1] = Ax[t] + Bu[t]
y[t ] = Cx[t]
Where it defines: u[t]
the input at time t
, x[t]
the state at time t
, y[t]
the output at time t
, and A
, B
, and C
the relevant transformation functions. Control Theory goes on to explore concepts of controllability and observability , and more, but we won't go that far in this article. Suffice to say, that there is a whole field of study behind this pair of equations.
Let's see what we can learn from it.
Consider the classic programming challenge — the todo-app. Its inputs u[t]
may be a form input, an XHR response, or a push notification. These inputs may not be in a consistent or desired format, and needs to be normalised before it can be stored as local state x[t]
, say an array of todo items. Therefore, the next state x[t+1]
is a transformation A
of the current state x[t]
combined with a transformation B
of the input u[t]
. Finally, the output y[t]
may be one of: pending todos, archived todos, or the next todo. These can be extracted from the state x[t]
with transformation C
.
redux
?redux
is the incumbent leader in state management for frontend applications. Its documentation emphasizes actions and reducers and how it works under the hood. Let's focus on the transformations instead.
The transformation B
is most commonly performed with an action dispatch. Ie, the return value (except for type: `ADD_TODO`
) of the following function is effectively Bu[t]
, the transformed input.
function addTodo (payload) {
return {
type: "ADD_TODO",
todo: parse(payload)
}
}
The transformation A
is then performed with a reducer, which combines the previous state and already transformed input to return the next state Ax[t] + Bu[t]
.
function reducer (state = [], action) {
switch (action.type) {
case "ADD_TODO":
return [ ...state, action.todo ]
default:
return state
}
}
Finally, the transformation C
is performed with one of many selectors, which returns the transformed state Cx[t]
. When used with react-redux
, this is what goes into connect()
higher order component, or the useSelector()
hook.
function pendingTodo (state) {
return state.filter(todo => !!todo.isPending)
}
function nextTodo (state) {
return state[0]
}
One very common modelling error is to omit the use of selectors, and the output y[t]
is exactly the value of the state x[t]
. Mathematically, C = I
, the identity function.
x[t+1] = Ax[t] + Bu[t]
y[t ] = x[t]
Not only does this unnecessarily grow the state, it grows the size of the transformation A
. Pending todos, archived todos, or the next todo are duplicated within the state, and can easily get out of sync.
Conversely, another common error is to store the input u[t]
(usually XHR responses) directly into the state x[t]
. Mathematically, B = I
, the identity function.
x[t+1] = Ax[t] + u[t]
y[t ] = Cx[t]
Doing so increase the complexity of the output transformation C
, and overly couples the input and output formats.
Moving where the transformation happens increases semantic-ness. The bulk of state manipulation should live in A
; while B
and C
are used only to transform the input and output respectively.
The state x[t]
directly represents the domain model , and is not confused with input or output representations.
mobx
?mobx
is another popular Javascript state management library. It heavily emphasizes reactivity by introducing concepts such as observable variables (proxies under the hood), actions to modify them, and computed attributes that are reactively updated.
Most mobx
practitioners are probably already implementing the state equations without realising it. In the example below, the @action.bound
annotated function applies the first line of the state transition equations. Then, the @computed
annotated functions apply the second line of the state transition equations.
import { action, computed, observable } from "mobx"
class Todo {
@observable todos = []
@action.bound addTodo (payload) {
this.todos.push(parse(payload))
}
@computed get pendingTodo () {
return this.todos.filter(todo => !!todo.isPending)
}
@computed get nextTodo () {
return this.todos[0]
}
}
The relationships are obvious, and very succinctly represented.
Frontend applications is not the only place where these equations could be applied. Here's an example in Ruby on Rails. See if you can spot where the transformations A
, B
, and C
are.
class TodosController < ApplicationController
def add # mounted to a POST route
@todo = Todo.new(params.require(:todo))
@todo.save
redirect_to @todo
end
def pending # mounted to a GET route
@todos = Todo.pending
end
def next # mounted to a GET route
@todo = Todo.pending.first
end
end
class Todo < ApplicationRecord
scope :pending, -> { where(pending: true) }
end
It is illustrative to consider what happens when the state x[t]
is removed. Without state, the input u[t]
passes through 3 distinct transformations to become the output y[t]
.
y[t] = CABu[t]
As an aside, Control Theory calls these systems time-invariant . But again, we're not going that way in this article.
The first transformation B
changes the input format into a local format more suitable for processing. Then the second transformation A
processes the data. Finally, the third transformation C
changes it to the desired output format. Experienced database administrators may recognise this as the Extract-Transform-Load pattern.
All the above should be intuitively obvious. It is nonetheless comforting to know that behind this software pattern, is a whole field of engineering study.
Users of redux
, unfortunately often miss the opportunity to apply this pattern because of the strong emphasis on actions and reducers, without a corresponding emphasis on selectors. mobx
users, however more instinctly see the relationships from input, to state, to output transformations. Even Ruby on Rails, or other backend MVC framework developers are accustomed to the pattern, because MVC encourages this flow.
Finally, how the state x[t]
is stored greatly affects how complex the application will become. While it is tempting to use the same format as the input u[t]
, or the output y[t]
; it is usually far more meaningful to store it in the corresponding domain model.