Disclaimer: this post series is discussing alpha features of React. A lot of this is up in the air and is being actively discussed by the React team and the community. The features, problems, solutions, opinions and decisions that these posts discuss might not be final. This is as a summary of the trade-offs being discovered and discussed. I find these behind the scenes discussions to be interesting and educational. Writing these posts is my way of understanding the topics in more depth. Finally, the content is interleaved with with my personal experience and opinions. I hope you enjoy!
This post is Part 2 of a series of posts looking at the new React APIs —Hooks and Context and how they compare to Redux. Make sure to check out Part 1.
Now that React has a few new primitives, could we and should we avoid using
Redux altogether? The useContext
hook is “live” now, it observes the changes
to the context value and rerenders the consuming components. This is great for
reacting to state changes. The useReducer
can be used for updating state in a
robust manner by dispatching actions.
Can these be combined to avoid the need for external libraries? Yes and no.
As I’ve mentioned in the previous post, I really like the direction the React
team is taking, Hooks are an amazing, expressive way of writing React
components. Having these new primitives such as useContext
or useReducer
is
great for simpler applications and components. These primitives are also great
building blocks for more specialised use cases.
I find using Redux like central stores for storing application data and/or state very convenient. It’s a bit like creating a client side database for your application that you can query for data and update with actions. So can you create a central store using new React APIs?
In principle, yes, you can combine useContext
and useReducer
APIs to create
your own Redux like store. Many people blogged and tweeted about this, here’s
one example of such implementation:
import React, { createContext, useContext, useReducer } from 'react'
const StoreContext = React.createContext()
export function Store({ initialState, reducer, children }) {
const [state, dispatch] = useReducer(reducer, initialState)
return <StoreContext.Provider value={{ state, dispatch }}>{children}</StoreContext.Provider>
}
export function useStore() {
const { state, dispatch } = useContext(StoreContext)
return [state, dispatch]
}
Pretty simple! And here’s how we’d use it:
import React from 'react'
import ReactDOM from 'react-dom'
import { Store, useStore } from './store'
const initialState = { count: 1 }
function reducer(state, action) {
if (action === 'INCREMENT') {
return Object.assign({ ...state, count: state.count + 1 })
}
if (action === 'DECREMENT') {
return Object.assign({ ...state, count: state.count - 1 })
}
return state
}
function App() {
const [{ count }, dispatch] = useStore()
return (
<div>
<h1>Count: {count}</h1>
<button onClick={() => dispatch('INCREMENT')}>Increment</button>
<AnotherCounter />
</div>
)
}
function AnotherCounter() {
const [{ count }, dispatch] = useStore()
return (
<div>
<h1>Count: {count}</h1>
<button onClick={() => dispatch('DECREMENT')}>Decrement</button>
</div>
)
}
ReactDOM.render(
<Store initialState={initialState} reducer={reducer}>
<App />
</Store>,
document.querySelector('#root')
)
So it works! But what are the limitations of this approach?
For over two years I’ve been working on a store similar to Redux – tiny-atom. By building several apps with largish state trees, many state transitions, fairly intricate UIs and user interactions, we’ve found the following to be important requirements for optimal performance:
- Avoid rerendering the app on each state change. Instead batch the changes and rerender at most once per frame/tick by default.
- Avoid rerendering children components twice if both parent and children components need to rerender because of a state change.
- Avoid rerendering components if unrelated parts of state change. Only rerender if the change is in the mapped state.
Our simple store implementation above does OK on these requirements, but it’s not perfect.
Our implementation batches rapid state changes and rerenders only once if the dispatch originates in a user event handler. If the dispatch happens outside of an event, for example in a fetch callback, the rerenders are not batched.
It does well on the second requiremnt. It doesn’t render children components
multiple times, even if both parent and children components are subscribed to
the store using the useStore
hook. That’s because React’s Context ensures the
top down render order and only a single pass.
The third requirement is missed entirely. There is no ability in our implementation to subscribe to slices of state to avoid rerendering the entire application on each state change. That is largely what discussion on facebook/react#14110 is about.
But is that such a bad thing? My current thinking is that state management libraries like Redux, tiny-atom, react-refetch or react-apollo give you quite a bit more functionality, more expressive APIs and it’s ok to use them. These libraries can combine React primitives in more complex ways to achieve their own goals. React itself does not necessarily need to get any more complicated.
For an example, see the hook implementation of tiny-atom react bindings. The idea roughly is the following:
- Use context to get access to the store instance
- Use a ref to store previously mapped state and use it for diffing later
- Use an effect to subscribe to store changes
- Debounce each store change into at most once per frame
- When store changes, map the state and diff against previous mapped state
- If state is different, update the local state, which is what causes rerender
- If parent rerenders the component, cancel the child’s scheduled update
This implementation satisfies all of the 3 requirements we stated earlier.
I’d be very interested to hear feedback on this implementation (ping me on Twitter). In particular:
- I’m keeping track of rendering order in a
ref
. This is so that I could push store subscriptions in order that the components were rendered, because it’s important to always rerender parents before rerendering children. Think of a modal that is conditionally rendered by App and also renders some data from the store. If you remove that data, the App should rerender first to remove the modal, or else the modal might throw an exception when it’s not able to find the data in the store. Is keeping track of rendering order in a ref safe? Is there a better way to achieve this? If you subscribe usinguseEffect
, the children subscrib first, so that doesn’t work. Update: there’s another, possibly better way to achieve this using theunstable_batchedUpdates
function. - Will this implementation fail in React’s concurrent mode, because of how it always reads the latest state of the store? I’m wondering if something bad could happen when store changes, component rerenders, concurrent react interupts and dismisses the result, store changes again, react resumes rendering component, but the store now has a different value?
In Part 3 we explore how Redux used the new React APIs internally, what issues it ran into and whether Redux is a good approach for UI development to begin with.