Reducer for related state

Keep state that changes together in one reducer, shared by Context.

From frontend-patterns, “Context + Reducer Pattern”.

Every change is a named action

Press Load, then pick a market.

<MarketProvider>Live
Action log0 actions

    No actions

    state = { markets: [], selectedMarket: null, loading: false }
    type Action =
      | { type: 'SET_MARKETS'; payload: Market[] }
      | { type: 'SELECT_MARKET'; payload: Market }
      | { type: 'SET_LOADING'; payload: boolean }
    
    function reducer(state: State, action: Action): State {
      switch (action.type) {
        case 'SET_MARKETS':
          return { ...state, markets: action.payload }
        case 'SELECT_MARKET':
          return { ...state, selectedMarket: action.payload }
        case 'SET_LOADING':
          return { ...state, loading: action.payload }
        default:
          return state
      }
    }
    
    const MarketContext = createContext<{
      state: State
      dispatch: Dispatch<Action>
    } | undefined>(undefined)
    
    export function MarketProvider({ children }: { children: React.ReactNode }) {
      const [state, dispatch] = useReducer(reducer, {
        markets: [],
        selectedMarket: null,
        loading: false
      })
    
      return (
        <MarketContext.Provider value={{ state, dispatch }}>
          {children}
        </MarketContext.Provider>
      )
    }

    Setters drift, a reducer holds

    Press Load, Select ETH, then Refresh.

    3 × useStateBad

    Consistent

    setMarkets(data) // forgot setLoading(false)
    useReducerGood

    Consistent

    dispatch({ type: 'SET_MARKETS', payload: data }) // same slip
    case 'SET_MARKETS': {
      const markets = action.payload
      const stillListed = markets.some(m => m.id === state.selectedMarket?.id)
      return {
        ...state,
        markets,
        loading: false,
        selectedMarket: stillListed ? state.selectedMarket : null
      }
    }
    case 'SELECT_MARKET':
      return state.markets.some(m => m.id === action.payload.id)
        ? { ...state, selectedMarket: action.payload }
        : state

    Guarded hook fails fast

    Untick the provider. Only one error says why.

    Bare useContextBad
    const { state } = useContext(MarketContext)
    Guarded useMarketsGood
    const { state } = useMarkets()
    export function useMarkets() {
      const context = useContext(MarketContext)
      if (!context) throw new Error('useMarkets must be used within MarketProvider')
      return context
    }

    Why it matters, pros and cons

    Why it matters

    Each transition is a named action in one pure function. Rules live there once.

    Pros

    • One place to read, test, fix.
    • Typed Action union catches bad dispatches.
    • Missing provider fails with a clear error.

    Cons

    • Overkill for one or two independent values.
    • Every consumer re-renders on any field change.
    • Server data belongs in a query library.