Typescript

Enforcing migrations for persisted Redux state using Typescript

By David Disch · 22nd of October, 2021

We recently came across a bug on a client site that occurred due to type change in our Redux store. No migration was written to reconcile and transform the old state into the new state. The change looked something like this:

// old type
type RootReducer = {
  // ...
  orderFulfillmentType: 'Pickup' | 'Delivery'
  // ...
}

// new type
type RootReducer = {
  // ...
  orderFulfillmentType: 'pickup' | 'delivery'
  // ...
}

Looks pretty reasonable? Assuming you update references to understand the new all lowercase variation of the value you wouldn't expect any issues right? But there were issues... Users who had visited the client's site prior to this change would have their Redux state persisted via redux-persist using the Web Storage API. And when the user returns to the site this old Redux state will be loaded back into memory from window.localStorage and now our app is in a state that should never exist!

Thankfully redux-persist provides the tools to deal with this problem using what it calls versions and migrations. It keeps track of an internal version of your state. If you tell redux-persist each time you change the type of your Redux store and provide it a way to transform the old state into the new state (a migration) then it will do this reconcilation before loading the old state in. Et voila! Problem solved. Maybe?

How do we enforce that these migrations and version bumps are done each time the Redux store type changes? Even a code review process will seldom catch an issue like this since the root Redux store type and any type changes below it might not be colocated in the same file. Furthermore, it's not always obvious that a type change should even require a version bump and migration.

The simplest solution we landed on was a unit test which checks the hash of the Redux store type and compares it against a hash stored in the codebase. If the hash changes but the version is not bumped then the unit test (which runs in our CI/CD pipeline) will not pass and the PR won't get merged! The version number used by redux-persist and the type file hash are colocated in the same file so you can't forget to change one without the other. Here's an example of how that unit test might look:

const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const reducerConfig = require('./reducer-config')

describe('redux-persist-typed', () => {
  it('should require a version bump when the types.ts file changes', async () => {
    const currentReducerTypeFileContents = await fs.promises.readFile(
      path.join(__dirname, 'types.ts'),
      'utf8',
    )

    const currentReducerTypeHash = (
      crypto
      .createHash('md5')
      .update(currentReducerTypeFileContents)
      .digest()
      .toString('hex')
    )

    // Update the "version" and "hash" in reducer-config.js
    expect(reducerConfig.hash).toEqual(currentReducerTypeHash)
  })
})