Zustand

repository·main·Indexed 13 days ago

https://github.com/pmndrs/zustand

A small, fast, and scalable state-management solution for React based on simplified Flux principles. Version 5.0.14 provides a hook-based API that avoids boilerplate and provider-wrapping, supporting immutable state updates, async actions, and vanilla JavaScript environments. It includes middleware for Immer, Redux DevTools, and persistence, and supports TypeScript for type-safe state and actions.

Tokens
69K
Snippets
185
Records
228
Agent score
98%

What's inside Zustand

  1. Introduction to Zustand

    main
    Zustand is a tiny, predictable state management library designed for React with hooks-first ergonomics. It provides a minimal API that allows you to create a store with a single hook, subscribe to state using selectors, and avoid the boilerplate or provider-based patterns common in other libraries. It is designed to be safe under React concurrency (avoiding zombie children and tearing) and works across React, React Native, and vanilla JavaScript environments.
  2. Core concepts of Zustand state management

    main

    Master the fundamentals of reading and updating state within a Zustand store.

    Key topics include:

    • Updating state: Techniques for updating primitive values, objects, and nested state.
    • No store actions: A pattern for defining state updates outside the store for simpler logic.
    • Slices pattern: How to split a large, monolithic store into smaller, composable slices.
    • Immutable state and merging: Understanding how Zustand handles state merging and when manual spreading is required.
    • Maps and Sets: Best practices for using Map and Set inside your Zustand state correctly.
  3. Best practices for using Zustand with Next.js

    main

    When using Zustand in Next.js, you must account for server-side rendering (SSR) and the hybrid routing model. To avoid common issues, follow these core principles:

    • Avoid Global Stores: Because a Next.js server handles multiple requests simultaneously, a global Zustand store (module state) would be shared across different users' requests. Instead, create a store per request using a factory function.
    • Use React Context for Client-side State: To ensure the store is reset during SPA routing and to avoid hydration errors, initialize the store at the component level using a Context provider.
    • Do not use Zustand in React Server Components (RSC): RSCs cannot use hooks or context and are not meant to be stateful. They should not read from or write to a Zustand store.
    • Server Caching Compatibility: Zustand's module state is compatible with Next.js App Router's aggressive server caching.
  4. Derive state using selectors

    main

    Instead of storing computed values in the state, you can derive them directly within a selector. This keeps the store minimal and avoids data duplication. For example, calculating totalFood from bears and foodPerBear inside the component's selector.

    import { create } from 'zustand'
    
    interface BearState {
      bears: number
      foodPerBear: number
    }
    
    const useBearStore = create<BearState>()(() => ({
      bears: 3,
      foodPerBear: 2,
    }))
    
    function TotalFood() {
      // Derived value: required amount food for all bears
      const totalFood = useBearStore((s) => s.bears * s.foodPerBear)
    
      return <div>We need ${totalFood} jars of honey</div>
    }
  5. Prevent infinite loops from unstable selector outputs in v5

    main

    Zustand v5 enforces stricter selector behavior to align with React. If a selector returns a new object or array reference on every call, it can trigger infinite re-render loops.

    To fix this, ensure your selectors return stable references using one of these methods:

    1. Use useShallow: Wrap selectors that return new arrays or objects in useShallow to ensure the reference only changes when the actual content changes.
    2. Use stable fallbacks: If a selector returns a function or an object that might be undefined, provide a constant fallback instead of an inline anonymous function.
    3. Use createWithEqualityFn: If you need to revert to the v4 behavior where unstable references were handled differently, use createWithEqualityFn from zustand/traditional.
    import { useShallow } from 'zustand/shallow'
    
    // Fix for array/object selectors
    const [searchValue, setSearchValue] = useStore(
      useShallow((state) => [state.searchValue, state.setSearchValue])
    )
    
    // Fix for function selectors
    const FALLBACK_ACTION = () => {}
    const action = useMainStore((state) => state.action ?? FALLBACK_ACTION)
  6. Optimize re-renders with `useShallow`

    main

    The useShallow hook is used to optimize React component re-renders by memoizing selector functions. When a selector returns a new object or array (a new reference) on every execution, Zustand's default equality check (Object.is) will trigger a re-render even if the contents of that object/array are identical to the previous state.

    useShallow performs a shallow comparison of the values returned by the selector. This ensures that the component only re-renders if the actual content of the returned object or array changes, rather than just the reference.

    const memoizedSelector = useShallow(selector)
  7. Extend store behavior with Middlewares

    main

    Zustand provides several composable middlewares to extend store functionality:

    • persist: Persist and rehydrate state using localStorage or a custom storage engine.
    • devtools: Connect a store to Redux DevTools for time-travel debugging.
    • redux: Use a reducer and dispatch pattern similar to Redux.
    • immer: Write state updates with mutable syntax using Immer.
    • combine: Combine separate state slices into a single store with inferred types.
    • subscribeWithSelector: Subscribe to a slice of state with selector and equality support.
  8. Avoid redundant state by using derived state

    main

    When building complex state logic, avoid storing values that can be calculated from existing state. This prevents bugs where different parts of the state get out of sync.

    In the Tic-Tac-Toe tutorial, xIsNext (which indicates if it is X's turn) is directly tied to whether the currentMove is even or odd. Instead of storing xIsNext in the Zustand store and manually updating it via setXIsNext, you should calculate it inside your component as a derived value.

    Benefits:

    • Reduces the number of state updates required.
    • Eliminates the possibility of xIsNext and currentMove becoming desynchronized.
    • Simplifies the store logic and setter functions.
    export default function Game() {
      const history = useGameStore((state) => state.history)
      const currentMove = useGameStore((state) => state.currentMove)
      
      // Derived state: calculated during render, not stored in Zustand
      const xIsNext = currentMove % 2 === 0
      const currentSquares = history[currentMove]
    
      // ...
    }
  9. Refactor components to be fully controlled by store state

    main

    Instead of having a component manage its own local state, you can make it 'controlled' by passing state and update callbacks as props. This pattern allows the parent component (or the Zustand store) to act as the single source of truth.

    1. The Child Component: Receives values (e.g., squares, xIsNext) and an event handler (e.g., onPlay) via props. It calls the handler instead of updating local state.
    2. The Parent Component: Selects the necessary state from the Zustand store using hooks and implements the logic to update the store when the child's handler is triggered.
    // Controlled Child
    function Board({ xIsNext, squares, onPlay }) {
      function handleClick(i) {
        const nextSquares = squares.slice()
        nextSquares[i] = xIsNext ? 'X' : 'O'
        onPlay(nextSquares)
      }
      // ... render logic
    }
    
    // Parent managing store updates
    function Game() {
      const history = useGameStore((state) => state.history)
      const setHistory = useGameStore((state) => state.setHistory)
      
      function handlePlay(nextSquares) {
        setHistory(history.concat([nextSquares]))
      }
    
      return <Board squares={history[history.length - 1]} onPlay={handlePlay} />
    }
    function Board({ xIsNext, squares, onPlay }) {
      const winner = calculateWinner(squares)
      const turns = calculateTurns(squares)
      const player = xIsNext ? 'X' : 'O'
      const status = calculateStatus(winner, turns, player)
    
      function handleClick(i) {
        if (squares[i] || winner) return
        const nextSquares = squares.slice()
        nextSquares[i] = player
        onPlay(nextSquares)
      }
    
      return (
        <>
          <div style={{ marginBottom: '0.5rem' }}>{status}</div>
          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)' }}>
            {squares.map((square, squareIndex) => (
              <Square
                key={squareIndex}
                value={square}
                onSquareClick={() => handleClick(squareIndex)}
              />
            ))}
          </div>
        </>
      )
    }
  10. How state merging works in the `set` function

    main

    Zustand follows immutable state patterns similar to React's useState. When using the set function to update the store, Zustand performs a shallow merge of the state at the top level by default. This means you do not need to manually spread the existing state (...state) if you are only updating top-level properties.

    Instead of: set((state) => ({ ...state, count: state.count + 1 }))

    You can simply write: set((state) => ({ count: state.count + 1 }))

    import { create } from 'zustand'
    
    const useCountStore = create((set) => ({
      count: 0,
      inc: () => set((state) => ({ count: state.count + 1 })),
    }))
  11. Compare Zustand with Redux

    main

    Zustand and Redux both use an immutable state model. A key difference is that Redux requires your application to be wrapped in context providers, whereas Zustand does not.

    In both libraries, render optimization is achieved by manually applying selectors to extract only the necessary parts of the state.

    import { create } from 'zustand'
    
    type State = {
      count: number
    }
    
    type Actions = {
      increment: (qty: number) => void
      decrement: (qty: number) => void
    }
    
    const useCountStore = create<State & Actions>((set) => ({
      count: 0,
      increment: (qty: number) => set((state) => ({ count: state.count + qty })),
      decrement: (qty: number) => set((state) => ({ count: state.count - qty })),
    }))
    
    const Component = () => {
      const count = useCountStore((state) => state.count)
      const increment = useCountStore((state) => state.increment)
      const decrement = useCountStore((state) => state.decrement)
      // ...
    }
  12. Compare Zustand with Recoil

    main

    Zustand and Recoil differ in how they manage atoms. Recoil depends on atom string keys, whereas Zustand uses a single store. Additionally, Recoil requires wrapping your application in a context provider.

    Like Jotai, Recoil optimizes renders through atom dependency, while Zustand uses manual selectors.

    import { create } from 'zustand'
    
    type State = {
      count: number
    }
    
    type Actions = {
      setCount: (countCallback: (count: State['count']) => State['count']) => void
    }
    
    const useCountStore = create<State & Actions>((set) => ({
      count: 0,
      setCount: (countCallback) =>
        set((state) => ({ count: countCallback(state.count) })),
    }))