Persisting React component state

In this post, I share my experience attempting to persist React component states across navigations. A naive solution is turned more general, and more elegant, until… the TypeScript compiler betrayed me.

TL;DR JavaScript minimizer broke my TypeScript code

When writing a React functional component, we may use useState like this:

1
2
3
4
5
6
class SomePageState { }

export default function SomePage() {
const [ state, setState ] = useState(new SomePageState())
return (<div></div>)
}

Unfortunately, the state is gone if the user navigates to another page, and comes back later.
This is annoying when SomePage is displaying some data that should persist in the browsing session, like a list of search results.

To tackle this, we can store the state in global and tries to retrieve it whenever the component is mounted. Of course, we have to store it whenever we set it.

1
2
3
4
5
6
7
8
9
10
11
12
export default function SomePage() {
const key = "SomePageState"
//@ts-ignore
const storedState = global[key] || new SomePageState()
const [ state, setState ] = useState(storedState)
const updateState = () => {
//@ts-ignore
global[key] = state
setState({ ...state })
}
return (<div></div>)
}

This thing works like a charm. The only downside is that we now have about 10 lines of identical code at the beginning of each component. Let’s extract a function.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function useGlobalState<T>(type: (new () => T), key: string) {
//@ts-ignore
const storedState = global[key] || new SomePageState()
const [ state, setState ] = useState(storedState)
const updateState = () => {
//@ts-ignore
global[key] = state
setState({ ...state })
}

return [ state, updateState ]
}

export default function SomePage() {
const [ state, updateState ] = useGlobalState(SomePageState, "SomePageState")
return (<div></div>)
}

Looks nice! Now we can export useGlobalState<T> from, say, Utils.ts and use it whenever we need with a single line.

The aggresive pursuit of elegance

I stared at this line:

1
const [ state, updateState ] = useGlobalState(SomePageState, "SomePageState")

And five other identical lines with different state types (ThisPageState, ThatPageState) and keys, and asked myself,

If all my state types have unique names, why not use their names as the key by default?

Then, I can just write:

1
const [ state, updateState ] = useGlobalState(SomePageState)

Elegant! And as it turns out, the change is very simple:

1
2
function useGlobalState<T>(type: (new () => T), optionalKey: string|null = null) {
const key = optionalKey || type.name

And the remaining are the same. Perfect, not?

The pitfall

The above code works very well in development. As soon as I ran npm run build and tried it on production, things collapsed.
Can you guess what terrible production-only bug did I encounter?

Answer: when navigating from ThisPage to ThatPage, the latter retrieved an object of ThisPageState.
In other words, it used the same global key, which was e. It took me a few minutes wondering:

e? Where does e come from? Why does it look like those nonsense in minimized JavaScript code? Oh, wait…

Upon realizing it, I installed react-app-rewired in dev and wrote in config-override.js:

1
2
3
config.optimization = {
minimize: false
}

And the problem went away. I don’t know who to blame:

  • The TypeScript compiler, that generated JavaScript code that works fine but fails after minimization, or
  • The minimizer, that generated code with different behavior,
  • Myself, who wrote code that rely on strange TypeScript features, which are expected to fail after losing types in JavaScript.

At the moment, I don’t have the time to further investigate. Any comment on this issue is welcome.