Context, Redux, Zustand & Your Very Own State Store
In client applications, it's common for components to hold state for user interactions. For example, an open or closed modal or a search box containing a filter. When multiple components need access to tidbits of global client state, stores are a valuable alternative to context.
Context
const SearchContext = React.createContext(null)
const useSearch = () => {
const ctx = React.useContext(SearchContext)
if (!ctx) {
throw new Error('use search must be used inside of a SearchProvider')
}
return ctx
}
const SearchProvider = (props: {children: React.ReactNode}) => {
const state = React.useState('')
return (
<SearchContext.Provider value={state}>
{props.children}
</SearchContext.Provider>
)
}
const App = () => {
return (
<SearchProvider>
<SearchBox />
<SearchResults />
</SearchProvider>
)
}
Redux
Redux implements the observer pattern using a single store. The useSelector hook allows components to subscribe to a selected portion of state, and useDispatch allows components to update it.
const searchSlice = createSlice({
name: 'search',
initialState: {
query: '',
},
reducers: {
setQuery: (state, action) => {
state.query = action.payload
},
},
})
const store = configureStore({
reducer: {
[searchSlice.name]: searchSlice.reducer,
},
})
type RootState = ReturnType<typeof store.getState>
type AppDispatch = typeof store.dispatch
const useSearch = () => {
const state = useSelector((state: RootState) => state.search.query)
const dispatch: AppDispatch = useDispatch()
const setState = (q: string) => dispatch(search.actions.setQuery(q))
return [state, setState] as const
}
const App = () => {
return (
<Provider store={store}>
<SearchBox />
<SearchResults />
</Provider>
)
}
Zustand
A common complaint about Redux is it's verbose and requires a lot of boilerplate to use. Another option is to use many smaller stores for each feature using Zustand.
const searchStore = create<{query: string}>(() => ({
query: '',
}))
const useSearch = () => {
const state = searchStore((s) => s.query)
const setState = (query: string) => search.setState({query})
return [state, setState] as const
}
Your Own State Store
Let's create a function to produce our own store to understand how this works. When a consumer subscribes to the store, it registers a side effect that runs when the stores state changes. When someone updates the store, it calls all subscribers with this new state.
const createStore = <T>(initialState: T) => {
// a subscriber is a function that can be called with state
type Subscriber = (state: T) => unknown
// the state of the store is created with a copy of the initialState
let state = structuredClone(initialState)
// subscribers is a set of callbacks used to notify consumers of the store when the state changes
const subscribers = new Set<Subscriber>()
return {
// publishing to the store sets its new state and tells it to notify all consumers that there has been an update to the state
publish: (next: T) => {
state = next
for (const subscriber of subscribers) {
subscriber(state)
}
},
// subscribing to the store lets the consumer register what it should do when the state updates
subscribe: (subscriber: Subscriber) => {
subscribers.add(subscriber)
// when the consumer is done listening to store updates, it can call this function to unsubscribe from the store
return () => subscribers.delete(subscriber)
},
getState: () => state,
}
}
Connecting Your Store to React
This works great, but how does this help us with state in React? To connect updates from a store to a React component, you need a subscription to the store that can call setState
. Then when anyone publishes to the searchStore
, it will notify SearchResults
by calling its setState
.
const SearchResults = () => {
const [state, setState] = React.useState(searchStore.getState)
React.useEffect(() => searchStore.subscribe(setState), [])
return results
.filter((r) => r.includes(state))
.map((r) => <span key={r.key}>{r.name}</span>)
}
Using React 18's useSyncExternalStore
This pattern for subscribing to external state in React was so common that React 18 introduced useSyncExternalStore as a standard to support concurrent rendering.
const SearchResults = () => {
const state = useSyncExternalStore(
searchStore.subscribe,
searchStore.getState
)
return results
.filter((r) => r.includes(state))
.map((r) => <span key={r.key}>{r.name}</span>)
}
What if the store has its own use state hook that components could consume so they could be unaware of useSyncExternalStore
? Let's add this custom hook to createStore
.
const createStore = <T>(initialState: T) => {
// a subscriber is a function that can be called with state
type Subscriber = (state: T) => unknown
// the state of the store is created with a copy of the initialState
let state = structuredClone(initialState)
// subscribers is a set of callbacks used to notify consumers of the store when the state changes
const subscribers = new Set<Subscriber>()
const store = {
// publishing to the store sets its new state and tells it to notify all consumers that there has been an update to the state
publish: (next: T) => {
state = next
for (const subscriber of subscribers) {
subscriber(state)
}
},
// subscribing to the store lets the consumer register what it should do when the state updates
subscribe: (subscriber: Subscriber) => {
subscribers.add(subscriber)
// when the consumer is done listening to store updates, it can call this function to unsubscribe from the store
return () => subscribers.delete(subscriber)
},
getState: () => state,
useState: () => {
const state = useSyncExternalStore(store.subscribe, store.getState)
return [state, store.publish] as const
},
}
return store
}
Now the SearchBox
has a clean way to subscribe and update the external store
const SearchBox = () => {
const [query, setQuery] = searchStore.useState()
return <input value={query} onChange={(e) => setQuery(e.target.value)} />
}
Also, because the store is located in memory outside of React, it can be used in plain functions too
const getFilteredResults = () => {
const query = searchStore.getState()
return results.filter((r) => r.includes(state))
}
The observer pattern is a useful approach for storing and managing state in client applications. By creating a store to contain state, it is possible to effectively manage state across multiple components or even outside of React. While Redux and Zustand are popular libraries for client state stores, it is also possible to create your very own (even one that uses selectors like Redux).