State Design Pattern in JavaScript
jsmanifest
Posted on April 7, 2022
The State Pattern ensures an object to behave in a predictable, coordinated way depending on the current "state" of the application.
A behavior is defined on a state object that is responsible for running some handler when the overall state transitions to its own state. The interface that these state objects operate on is called the Context
.
The way this pattern works in practice is that by delegating the work of certain actions to the state objects that represent a piece of the state, the action that represents the piece of the state is responsible for updating it from their handling of that state.
This means that the Context
may have one or more handlers but ultimately the state objects that hold a reference to the Context
trigger state changes entirely amongst themselves one at a time.
This is because state objects define handlers that triggers actions that can determine what the next state transitions to based on what happens from the handler.
What Problems Does The State Pattern Solve?
The most important problem it solves is when your state becomes large and there are many cases. It becomes hard to debug issues when our application's state can change in many ways especially when our application becomes enormous.
redux is a library that is succeessful in providing an easy-to-use, predictable interface to solve complex state issues.
Implementation
Pretend we are implementing some sort of state where we will be working with a counter:
const state = {
counter: 0,
color: 'green',
}
The counter starts at 0
and every second we will increment the counter by 1
. The color stays "green"
if the counter is less than 5
. If the counter is between 5
and 7
the color will be "orange"
. And finally, if the counter is 8
or higher the color will be set to "red"
.
Without the state pattern this can be implemented with something like this:
function start({ onEachInterval }) {
let color = 'green'
let counter = 0
let intervalRef = setInterval(() => {
counter++
if (color > 5) {
if (color < 8) color = 'orange'
else color = 'red'
}
onEachInterval({ counter, color })
}, 1000)
setTimeout(() => {
clearInterval(intervalRef)
console.log(`Timer has ended`)
}, 10000)
}
start({
onEachInterval({ counter, color }) {
console.log(`The current counter is ${counter} `)
},
})
It's pretty simple and gets the job done. Since this code is very short there's no need to implement the state pattern because it would be overkill.
Lets say that our code grows to 5000 lines overtime. Think about it. Do you think you would have an easy time unit testing your program? You won't if your code is perfect everytime time but there's really no such thing as a developer who never mistakes in large applications. There's bound to be some errors at some point so it is at our best interest that we should be careful and make wise decisions when writing code. Code should always be easy to test.
That's why the State Pattern is useful because it is easily testable and is scalable for applications with large or complex state.
When we run that code snippet we get this:
The current counter is 1
The current counter is 2
The current counter is 3
The current counter is 4
The current counter is 5
The current counter is 6
The current counter is 7
The current counter is 8
The current counter is 9
Timer has ended
Which means our code is working. Inside our start
function the implementation is written once but there's hardly any control. Control is also another benefit of the State Pattern.
Lets see how this looks like using the State Pattern:
function createStateApi(initialState) {
const ACTION = Symbol('_action_')
let actions = []
let state = { ...initialState }
let fns = {}
let isUpdating = false
let subscribers = []
const createAction = (type, options) => {
const action = { type, ...options }
action[ACTION] = true
return action
}
const setState = (nextState) => {
state = nextState
}
const o = {
createAction(type, handler) {
const action = createAction(type)
if (!fns[action.type]) fns[action.type] = handler
actions.push(action)
return action
},
getState() {
return state
},
send(action, getAdditionalStateProps) {
const oldState = state
if (isUpdating) {
return console.log(`Subscribers cannot update the state`)
}
try {
isUpdating = true
let newState = {
...oldState,
...getAdditionalStateProps?.(oldState),
...fns[action.type]?.(oldState),
}
setState(newState)
subscribers.forEach((fn) => fn?.(oldState, newState, action))
} finally {
isUpdating = false
}
},
subscribe(fn) {
subscribers.push(fn)
},
}
return o
}
const stateApi = createStateApi({ counter: 0, color: 'green' })
const changeColor = stateApi.createAction('changeColor')
const increment = stateApi.createAction('increment', function handler(state) {
return {
...state,
counter: state.counter + 1,
}
})
stateApi.subscribe((oldState, newState) => {
if (oldState.color !== newState.color) {
console.log(`Color changed to ${newState.counter}`)
}
})
stateApi.subscribe((oldState, newState) => {
console.log(`The current counter is ${newState.counter}`)
})
let intervalRef = setInterval(() => {
stateApi.send(increment)
const state = stateApi.getState()
const currentColor = state.color
if (state.counter > 8 && currentColor !== 'red') {
stateApi.send(changeColor, (state) => ({ ...state, color: 'red' }))
} else if (state.counter >= 5 && currentColor !== 'orange') {
stateApi.send(changeColor, (state) => ({ ...state, color: 'orange' }))
} else if (state.counter < 5 && currentColor !== 'green') {
stateApi.send(changeColor, (state) => ({ ...state, color: 'green' }))
}
}, 1000)
setTimeout(() => {
clearInterval(intervalRef)
console.log(`Timer has ended`)
}, 10000)
There's a couple things to pick from the example.
The line const ACTION = Symbol('_action_')
is not used on the rest of the code but I wanted to mention that it's a good practice to use this strategy to validate that the actions being sent to the send
method are actual actions that are intended to update the state.
For example we can immediately do this validation at the beginning of our send
method:
send(action, getAdditionalStateProps) {
if (!(ACTION in action)) {
throw new Error(`The object passed to send is not a valid action object`)
}
const oldState = state
if (isUpdating) {
return console.log(`Subscribers cannot update the state`)
}
If we don't do this our code can be more error prone because we can just pass in any object like this and it will still work:
function start() {
send({ type: 'increment' })
}
This may seem like a positive thing but we want to make sure that the only actions that trigger updates to state are specifically those objects produced by the interface we provide publicly to them via createAction
. For debugging purposely we want to narrow down the complexity and be ensured that errors are coming from the right locations.
The next thing we are going to look at are these lines:
const increment = stateApi.createAction('increment', function handler(state) {
return {
...state,
counter: state.counter + 1,
}
})
Remember earlier we state (no pun intended) that:
The way this pattern works in practice is that by delegating the work of certain actions to the state objects that represent a piece of the state, the action that represents the piece of the state is responsible for updating it from their handling of that state.
We defined an increment
action that is responsible for incrementing it every second when consumed via send
. It receives the current state
and takes the return values to merge onto the next state.
We're now able to isolated and unit test this behavior for this piece of state easily:
npx mocha ./dev/state.test.js
const { expect } = require('chai')
const { createStateApi } = require('./patterns')
describe(`increment`, () => {
it(`should increment by 1`, () => {
const api = createStateApi({ counter: 0 })
const increment = api.createAction('increment', (state) => ({
...state,
counter: state.counter + 1,
}))
expect(api.getState()).to.have.property('counter').to.eq(0)
api.send(increment)
expect(api.getState()).to.have.property('counter').to.eq(1)
})
})
increment
✔ should increment by 1
1 passing (1ms)
In our first example we had the implementation hardcoded into the function. Again, unit testing that function is going to be difficult. We won't able to isolate separate parts of the code like we did here.
Isolation is powerful in programming. State Pattern lets us isolate. Isolation provides wider range of possibilities to compose pieces together which is easily achievable now:
it(`should increment by 5`, () => {
const api = createStateApi({ counter: 0 })
const createIncrementener = (amount) =>
api.createAction('increment', (state) => ({
...state,
counter: state.counter + amount,
}))
const increment = createIncrementener(5)
expect(api.getState()).to.have.property('counter').to.eq(0)
api.send(increment)
expect(api.getState()).to.have.property('counter').to.eq(5)
})
Remember, we also mentioned that the State Pattern is scalable. As our application grows in size the pattern protects us with useful compositional capabilities to fight the scalability:
it(`should increment from composed math functions`, () => {
const addBy = (amount) => (counter) => counter + amount
const multiplyBy = (amount) => (counter) => counter * amount
const api = createStateApi({ counter: 0 })
const createIncrementener = (incrementBy) =>
api.createAction('increment', (state) => ({
...state,
counter: incrementBy(state.counter),
}))
const applyMathFns =
(...fns) =>
(amount) =>
fns.reduceRight((acc, fn) => (acc += fn(acc)), amount)
const increment = api.createAction(
'increment',
createIncrementener(applyMathFns(addBy(5), multiplyBy(2), addBy(1))),
)
api.send(increment)
expect(api.getState()).to.have.property('counter').to.eq(11)
})
The moral of the story? The State Pattern works.
The bigger picture
To finalize this post here is a visual perspective of the State Design Pattern:
Conclusion
And that concludes the end of this post! I hope you found this to be valuable and look out for more in the future!
Find me on medium
Posted on April 7, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
September 17, 2024
April 20, 2022