跳转到主要内容

State Management

The goal is predictable change—not shoving everything into one global store.

The best state architecture is usually the smallest one that keeps ownership clear. Start local, lift when necessary, and only globalize when the product really demands it.

Decision criteria

Use these criteria as a checklist. If you cannot justify a global solution with them, keep the state closer to where it is used.

  • Lifecycle: only this component/page, or persisted across pages?
  • Update frequency: will frequent updates amplify render costs?
  • Consistency needs: transactions, replay, persistence, multi-tab sync?

State taxonomy (what kind of state is it?)

Many teams reach for a global store too early because they mix different kinds of state. Classify first—solutions become obvious.

When you name the type, you implicitly pick the right tools: UI state wants local updates, server state wants caching and invalidation, URL state wants stability and shareability.

  • UI state: ephemeral and view-specific (`isOpen`, `selectedTab`, input drafts).
  • Server state: remote data with caching, invalidation, retries (lists, user profile).
  • URL state: shareable/bookmarkable state (filters, pagination, sort).
  • Derived state: computable from other state/props—avoid storing it if you can derive it.

Context pitfalls and a safer baseline

Context is great for passing data through the tree, but it is not a free global store: updates can rerender many components.

A healthy default is: keep `Context` values stable, keep updates scoped, and split providers when update rates differ.

  • Avoid putting high-frequency values (mouse position, keystrokes) in Context.
  • Split Context by responsibility: state vs dispatch, or feature slices.
  • Keep state local by default; lift only when there is a real sharing need.
TypeScript
import { createContext, useContext, useReducer } from 'react';

type State = { count: number };
type Action = { type: 'inc' } | { type: 'dec' };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'inc':
      return { count: state.count + 1 };
    case 'dec':
      return { count: state.count - 1 };
  }
}

const StateContext = createContext<State | undefined>(undefined);
const DispatchContext = createContext<React.Dispatch<Action> | undefined>(undefined);

export function CounterProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(reducer, { count: 0 });
  return (
    <StateContext.Provider value={state}>
      <DispatchContext.Provider value={dispatch}>{children}</DispatchContext.Provider>
    </StateContext.Provider>
  );
}

export function useCounterState() {
  const s = useContext(StateContext);
  if (!s) throw new Error('Missing CounterProvider');
  return s;
}

export function useCounterDispatch() {
  const d = useContext(DispatchContext);
  if (!d) throw new Error('Missing CounterProvider');
  return d;
}

When to add an external store

An external store is about subscription and consistency, not “state management vibes”. If you need selective subscriptions or cross-tab persistence, it can be the simplest solution.

  • Cross-route or cross-tab persistence (e.g. drafts, editor state).
  • Many distant consumers with selective subscriptions (avoid “rerender the world”).
  • Complex consistency needs: transactions, undo/redo, replay, optimistic updates.

A practical pattern

For shared state within a subtree, Context + useReducer is often a good baseline.

TypeScript
import { createContext, useContext, useReducer } from 'react';

type State = { count: number };
type Action = { type: 'inc' } | { type: 'dec' } | { type: 'reset' };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'inc':
      return { count: state.count + 1 };
    case 'dec':
      return { count: state.count - 1 };
    case 'reset':
      return { count: 0 };
  }
}

const CounterContext = createContext<
  | { state: State; dispatch: React.Dispatch<Action> }
  | undefined
>(undefined);

export function CounterProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(reducer, { count: 0 });
  return (
    <CounterContext.Provider value={{ state, dispatch }}>
      {children}
    </CounterContext.Provider>
  );
}

export function useCounter() {
  const ctx = useContext(CounterContext);
  if (!ctx) throw new Error('Missing CounterProvider');
  return ctx;
}

Production checklist

  • State ownership is explicit: a single “source of truth” per domain.
  • No duplicated derived state (prefer derive-on-render with `memo` when needed).
  • Context values are stable and scoped; high-frequency updates are isolated.
  • Shared state has a persistence strategy (if required) and a reset/clear story.

Further reading

State Management - Guides - React Docs - React 文档