DEV Community

jsmanifest
jsmanifest

Posted on

State Design Pattern in JavaScript

Image description

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',
}
Enter fullscreen mode Exit fullscreen mode

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} `)
  },
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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`)
    }
Enter fullscreen mode Exit fullscreen mode

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' })
}
Enter fullscreen mode Exit fullscreen mode

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,
  }
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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)
  })
})
Enter fullscreen mode Exit fullscreen mode
increment
     should increment by 1


1 passing (1ms)
Enter fullscreen mode Exit fullscreen mode

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)
})
Enter fullscreen mode Exit fullscreen mode

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)
})
Enter fullscreen mode Exit fullscreen mode

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:

state-design-pattern-flow-diagram1.png

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

Oldest comments (0)