ESC
Type to search guides, tutorials, and reference documentation.
Verified by Garnet Grid

Frontend State Management Patterns

A practical guide to managing application state in modern frontend applications — comparing local state, context, Redux, Zustand, signals, and server-state solutions.

State management is the most debated topic in frontend engineering. Not because the problem is inherently complex, but because teams consistently over-engineer their approach. The right solution depends on the type of state, the size of the application, and the team’s preferences.

State Categories

Understanding what kind of state you’re managing is the first step to choosing the right tool:

CategoryExamplesCharacteristics
UI StateModal open/closed, active tab, form inputLocal, ephemeral, component-scoped
Client StateTheme preference, sidebar collapsed, feature flagsShared across components, persists within session
Server StateUser profile, product list, dashboard dataFetched from API, cached, stale, needs sync
URL StateCurrent route, query parameters, filtersShareable, bookmarkable, navigation-linked
Form StateField values, validation errors, dirty trackingComplex lifecycle, validation rules

The State Management Spectrum

Simplest                                                    Most Complex
   ├──────────┬───────────┬───────────┬──────────┬──────────┤
 useState   useReducer  Context    Zustand   Redux/
                                   Jotai    MobX/XState

Rule of Thumb

Use the simplest tool that solves your problem. If useState works, don’t reach for Redux. If Context works, don’t add Zustand. Complexity should be justified by requirements.

Pattern 1: Local Component State

For UI state that belongs to a single component:

function SearchBox() {
  const [query, setQuery] = useState('');
  const [isOpen, setIsOpen] = useState(false);
  
  return (/* ... */);
}

When to use: Toggle states, form inputs, component-specific UI. This covers 70% of state management needs.

Pattern 2: Lifted State / Prop Drilling

When siblings need to share state, lift it to the nearest common ancestor:

function FilterableProductList() {
  const [filters, setFilters] = useState({});
  
  return (
    <>
      <FilterBar filters={filters} onChange={setFilters} />
      <ProductList filters={filters} />
    </>
  );
}

When to use: 2-3 levels of depth, small number of shared values. Stop when prop drilling becomes painful (> 3 levels).

Pattern 3: React Context

For state that many components need but doesn’t change frequently:

const ThemeContext = createContext('light');

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

When to use: Theme, locale, auth status, feature flags — data that changes infrequently but is needed everywhere.

When NOT to use: Frequently changing data (causes re-renders for all consumers) or large state objects.

Pattern 4: External State Stores

For complex client state with frequent updates:

Zustand (lightweight, hooks-first):

const useStore = create((set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
  removeItem: (id) => set((state) => ({ 
    items: state.items.filter(i => i.id !== id) 
  })),
}));

function ItemList() {
  const items = useStore((state) => state.items);
  return items.map(item => <Item key={item.id} {...item} />);
}

When to use: Shopping carts, complex multi-step forms, collaborative editing state.

Pattern 5: Server State

API data has unique characteristics: it’s asynchronous, cacheable, shared, and becomes stale. Treat it differently from client state.

React Query / TanStack Query:

function UserProfile({ userId }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    staleTime: 5 * 60 * 1000, // 5 minutes
  });

  if (isLoading) return <Skeleton />;
  if (error) return <ErrorState error={error} />;
  return <Profile user={data} />;
}

What you get for free: Caching, deduplication, background refetching, optimistic updates, pagination, infinite scroll.

When to use: Always, for any data fetched from an API. This is the single biggest improvement most frontend teams can make.

Choosing the Right Pattern

Is it server data?
  → Yes → React Query / SWR / Apollo
  → No → continue

Is it URL state?
  → Yes → Router (useSearchParams, useRouter)
  → No → continue

Does only one component need it?
  → Yes → useState / useReducer
  → No → continue

Do 2-3 nearby components need it?
  → Yes → Lift state to parent
  → No → continue

Does it change infrequently?
  → Yes → React Context
  → No → External store (Zustand, Jotai)

Anti-Patterns

Global State by Default

Not everything belongs in global state. Most state is local. Start local and lift only when you have a real need.

Storing Server Data in Client State

Don’t useEffectfetchsetState. This ignores caching, deduplication, loading states, error handling, and staleness. Use a server-state library.

Context for Frequently Changing Data

React Context triggers re-renders in every consumer when the value changes. If your context value changes on every keystroke, you’ll have performance problems.

State Duplication

Don’t store the same data in multiple places. Derive computed values instead of storing them:

// Bad — storing derived state
const [items, setItems] = useState([]);
const [totalPrice, setTotalPrice] = useState(0);
// Must manually sync totalPrice whenever items changes

// Good — derive it
const [items, setItems] = useState([]);
const totalPrice = items.reduce((sum, item) => sum + item.price, 0);

Redux for Simple Apps

Redux adds boilerplate for actions, reducers, selectors, and middleware. If your app has a handful of screens & simple data flows, Redux is unnecessary overhead.

Performance Considerations

Selective Subscriptions

External stores like Zustand allow subscribing to specific slices of state, preventing unnecessary re-renders:

// Only re-renders when `count` changes, not when other state changes
const count = useStore((state) => state.count);

Memoization

Use useMemo and useCallback when passing derived values or callbacks through Context or as props to memoized components.

State Colocation

Keep state as close to where it’s used as possible. Moving state higher in the tree causes re-renders to cascade further.

The best state management solution is the one your team understands, maintains, and doesn’t fight against. Simplicity wins.

Jakub Dimitri Rezayev
Jakub Dimitri Rezayev
Founder & Chief Architect • Garnet Grid Consulting

Jakub holds an M.S. in Customer Intelligence & Analytics and a B.S. in Finance & Computer Science from Pace University. With deep expertise spanning D365 F&O, Azure, Power BI, and AI/ML systems, he architects enterprise solutions that bridge legacy systems and modern technology — and has led multi-million dollar ERP implementations for Fortune 500 supply chains.

View Full Profile →