Teague
Teague Stockwell
Software Engineer
Seattle, WA

Context, Redux, Zustand & Your Very Own State Store

Teague Stockwell
Teague Stockwell2022-12-31
space web

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).