I recently updated a React state management library, hoping to reduce its API from two wrapper functions into a single hook. I did not succeed but I still ended up with something nice. Here is why and how.
The starting point
The library had the following API for dealing with global state
import React from 'react'
import { store, view } from 'react-easy-state'
const count = store({ num: 0 })
const increment = () => count.num++
export default view(() => <button onClick={increment}>{count.num}</button>)
... and local state.
import React, { Component } from 'react'
import { view, store } from 'react-easy-state'
class Counter extends Component {
counter = store({ num: 0 })
increment = () => counter.num++
render() {
return <button onClick={this.increment}>{this.counter.num}</button>
}
}
export default view(Counter)
Initially, I hoped to reduce the API to something like below.
import React from 'react'
import useStore from 'react-easy-state'
export default function Counter () {
const counter = useStore({ num: 0 })
const increment = () => counter.num++
return <button onClick={increment}>{counter.num}</button>
}
Notice how the
view
HOC is gone in this failed experiment.
The issues
State management libs are expected to optimize re-renders with
shouldComponentUpdate
ormemo
by default. This kind of render bailout can not be done and should not be done with hooks.Some state management libraries need to know details about the components - like what props do they expect or what data do they use from elsewhere. Hooks are not wrapping the components, so they can not provide these kinds of higher level information.
A
useStore
hook is nice for local state in function components but it does not work for global state and local state in class components.
All of the above can be solved with Higher Order Components. Hooks are still needed to enable local state with function components though.
The second iteration
After some tinkering and prototyping, I ditched the idea of reducing the API into a single hook but I tried to not extend it with new functions at least. I succeeded this time through a somewhat unorthodox hook usage.
I overloaded the store
function to behave like a hook when it is called inside a function component but keep its original behavior otherwise. As a result, I got this syntax for local state
import React from 'react'
import { view, store } from 'react-easy-state'
export default view(() => {
const count = store({ num: 0 })
const increment = () => count.num++
return <button onClick={increment}>{count.num}</button>
})
... and for global state.
import React from 'react'
import { view, store } from 'react-easy-state'
const count = store({ num: 0 })
const increment = () => count.num++
export default view(() => {
return <button onClick={increment}>{count.num}</button>
})
Notice that the only difference is the placement of the state stores.
The implementation
This is a simplified version of the view
HOC which toggles the isInsideFunctionComponent
flag to true while the wrapped function component is rendering. This could not be done with a hook.
import { memo } from 'react'
export let isInsideFunctionComponent = false
export default function view (Comp) {
return memo(props => {
isInsideFuntionComponent = true
try {
return Comp(props)
} finally {
isInsideFunctionComponent = false
}
})
}
The below snippet is a simplified version of the store
wrapper. It decides if it should behave like a local or global store based on the isInsideFunctionComponent
flag.
import { useMemo } from 'react'
import { isInsideFunctionComponent } from './view'
export default function store (obj) {
if (isInsideFunctionComponent) {
return useMemo(() => storeImplementation(obj), [])
}
return storeImplementation(obj)
}
If store
is called inside a function component it returns a memoized local state store, otherwise it returns a global store.
But ...
- It is not called
useStore
.
The useX
naming is just a convention, which can be broken with a good reason. Ergonomic API design is good enough for me.
- It is a hook behind an
if
statement.
If you are familiar with the rules of hooks the if
statement likely made you frown, but it is actually there to adhere to the rules not to break them.
Whenever store
is called from a function component the isInsideFunctionComponent
flag is true and the if
block is entered. From the function component's point of view useMemo
is not behind an if
block, it is called at the correct place every time the component is rendered.
- It is magic (aka dirty hack).
This is why I love JavaScript. One can save immense efforts with decent language knowledge and an easy mind. Simpler is better if you know what you do.
- react-easy-state is changing language behavior with ES6 Proxies.
- I have a lib built around the 'deprecated'
with
keyword and I think it is great. - I like to use sparse arrays and the
delete
keyword for storing data (in deduped priority queues). - The React team started throwing Promises.
These kind of 'hacks' are awesome as long as they do not pose edge cases and hidden pitfalls to their users.
If this article captured your interest please help by sharing it. Also, check out the React Easy State repo if you have some state to manage.
Thanks!
Top comments (0)