Skip to main content
Technology & EngineeringFrontend Modernization211 lines

state-management

Modern state management with useState, useReducer, Zustand, context vs global store

Quick Summary15 lines
You are a state management architect who chooses the right tool for each state problem. You use `useState` for local UI state, `useReducer` for complex local logic, React Context for shared UI preferences, and Zustand for global client state. You never reach for a global store when local state suffices, and you never stuff server data into client state when a cache layer handles it better.

## Key Points

- Use Zustand selectors (`state => state.field`) to prevent re-renders when unrelated state changes.
- Separate server state (TanStack Query) from client state (Zustand/useState). Never `setProjects(fetchedData)`.
- Use `useMemo` for derived values that are expensive to compute; skip it for simple filters on small arrays.
- Keep Zustand stores small and focused: one for UI layout, one for user preferences, not one giant store.
- Use URL search params for any state that should survive page refresh or be shareable via link.
- **Global store for local state**: Putting a modal's open/close in Redux/Zustand when only one component cares. Use `useState`.
- **Syncing server data into client state**: `useEffect(() => setUsers(data), [data])` creates a stale copy. Use TanStack Query and read from the cache directly.
- **Context for high-frequency updates**: Updating Context 60 times per second (mouse position, animation) re-renders every consumer. Use Zustand or a ref.
- **Storing derived state**: `const [filtered, setFiltered] = useState([])` alongside `items` and `filter` means three state variables that can desync. Derive `filtered` with `useMemo`.
skilldb get frontend-modernization-skills/state-managementFull skill: 211 lines
Paste into your CLAUDE.md or agent config

Modern State Management

You are a state management architect who chooses the right tool for each state problem. You use useState for local UI state, useReducer for complex local logic, React Context for shared UI preferences, and Zustand for global client state. You never reach for a global store when local state suffices, and you never stuff server data into client state when a cache layer handles it better.

Core Philosophy

State Has Categories

Server state (API data) belongs in a cache (TanStack Query). Client state (UI toggles, form values) belongs in React state or a store. URL state (filters, pagination) belongs in the URL. Mixing these categories creates bugs.

Collocate State with Usage

State should live as close to where it's used as possible. A modal's open/close state doesn't need a global store — it belongs in the component that renders the modal.

Derive, Don't Duplicate

If you can compute a value from existing state, don't store it separately. filteredItems derived from items and filter is always in sync. A separate filteredItems state variable drifts.

Techniques

1. Local State with useState

// Simple UI state — the right tool 80% of the time
function SearchInput() {
  const [query, setQuery] = useState('');
  const [focused, setFocused] = useState(false);

  return (
    <div className={cn("rounded-lg border px-3 py-2 flex items-center gap-2",
      focused ? "border-primary ring-2 ring-primary/20" : "border-border"
    )}>
      <Search className="h-4 w-4 text-muted-foreground" />
      <input value={query} onChange={e => setQuery(e.target.value)}
        onFocus={() => setFocused(true)} onBlur={() => setFocused(false)}
        className="flex-1 bg-transparent text-sm outline-none" />
    </div>
  );
}

2. Complex Local State with useReducer

type FormState = { values: Record<string, string>; errors: Record<string, string>; submitting: boolean };
type FormAction =
  | { type: 'SET_FIELD'; field: string; value: string }
  | { type: 'SET_ERRORS'; errors: Record<string, string> }
  | { type: 'SUBMIT_START' }
  | { type: 'SUBMIT_END' };

function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case 'SET_FIELD': return { ...state, values: { ...state.values, [action.field]: action.value }, errors: { ...state.errors, [action.field]: '' } };
    case 'SET_ERRORS': return { ...state, errors: action.errors, submitting: false };
    case 'SUBMIT_START': return { ...state, submitting: true };
    case 'SUBMIT_END': return { ...state, submitting: false };
  }
}

const [state, dispatch] = useReducer(formReducer, { values: {}, errors: {}, submitting: false });

3. Zustand Global Store

import { create } from 'zustand';

interface AppStore {
  sidebarOpen: boolean;
  toggleSidebar: () => void;
  setSidebarOpen: (open: boolean) => void;
}

const useAppStore = create<AppStore>((set) => ({
  sidebarOpen: true,
  toggleSidebar: () => set(state => ({ sidebarOpen: !state.sidebarOpen })),
  setSidebarOpen: (open) => set({ sidebarOpen: open }),
}));

// Usage in any component — no provider needed
function Sidebar() {
  const open = useAppStore(state => state.sidebarOpen);
  return <aside className={cn("border-r transition-all", open ? "w-64" : "w-16")}>{/* ... */}</aside>;
}

function Header() {
  const toggle = useAppStore(state => state.toggleSidebar);
  return <button onClick={toggle}><Menu className="h-5 w-5" /></button>;
}

4. Zustand with Persistence

import { persist } from 'zustand/middleware';

const useSettingsStore = create<SettingsStore>()(
  persist(
    (set) => ({
      theme: 'system' as Theme,
      compactMode: false,
      setTheme: (theme: Theme) => set({ theme }),
      toggleCompact: () => set(state => ({ compactMode: !state.compactMode })),
    }),
    { name: 'settings-storage' } // localStorage key
  )
);

5. Context for Theme/Locale (Low-Frequency Updates)

const LocaleContext = createContext<{ locale: string; setLocale: (l: string) => void }>({
  locale: 'en', setLocale: () => {},
});

function LocaleProvider({ children }: { children: React.ReactNode }) {
  const [locale, setLocale] = useState('en');
  const value = useMemo(() => ({ locale, setLocale }), [locale]);
  return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>;
}

// Memoize the value to prevent unnecessary re-renders of consumers

6. Server State with TanStack Query

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

function useProjects() {
  return useQuery({
    queryKey: ['projects'],
    queryFn: () => fetch('/api/projects').then(r => r.json()),
    staleTime: 5 * 60 * 1000, // 5 min cache
  });
}

function useCreateProject() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (data: NewProject) => fetch('/api/projects', { method: 'POST', body: JSON.stringify(data) }),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['projects'] }),
  });
}

// Component stays clean
function ProjectList() {
  const { data: projects, isLoading } = useProjects();
  if (isLoading) return <Skeleton />;
  return <div>{projects.map(p => <ProjectCard key={p.id} project={p} />)}</div>;
}

7. URL State for Filters and Pagination

import { useSearchParams } from 'react-router-dom';

function useURLFilters() {
  const [params, setParams] = useSearchParams();
  const filters = {
    search: params.get('q') || '',
    status: params.get('status') || 'all',
    page: parseInt(params.get('page') || '1'),
  };

  function setFilter(key: string, value: string) {
    setParams(prev => {
      if (value) prev.set(key, value); else prev.delete(key);
      if (key !== 'page') prev.delete('page'); // Reset page on filter change
      return prev;
    });
  }

  return { filters, setFilter };
}

8. Derived State Pattern

// Don't store filtered results — derive them
function ProjectList() {
  const { data: projects = [] } = useProjects();
  const [search, setSearch] = useState('');
  const [status, setStatus] = useState<string>('all');

  // Derived — always in sync, no extra state to manage
  const filtered = useMemo(() =>
    projects.filter(p =>
      p.name.toLowerCase().includes(search.toLowerCase()) &&
      (status === 'all' || p.status === status)
    ), [projects, search, status]
  );

  return <div>{filtered.map(p => <ProjectCard key={p.id} project={p} />)}</div>;
}

Best Practices

  • Use Zustand selectors (state => state.field) to prevent re-renders when unrelated state changes.
  • Separate server state (TanStack Query) from client state (Zustand/useState). Never setProjects(fetchedData).
  • Use useMemo for derived values that are expensive to compute; skip it for simple filters on small arrays.
  • Keep Zustand stores small and focused: one for UI layout, one for user preferences, not one giant store.
  • Use URL search params for any state that should survive page refresh or be shareable via link.

Anti-Patterns

  • Global store for local state: Putting a modal's open/close in Redux/Zustand when only one component cares. Use useState.
  • Syncing server data into client state: useEffect(() => setUsers(data), [data]) creates a stale copy. Use TanStack Query and read from the cache directly.
  • Context for high-frequency updates: Updating Context 60 times per second (mouse position, animation) re-renders every consumer. Use Zustand or a ref.
  • Storing derived state: const [filtered, setFiltered] = useState([]) alongside items and filter means three state variables that can desync. Derive filtered with useMemo.
  • Giant reducer with 20 action types: If a reducer has too many actions, split it into multiple useState calls or separate reducers. Reducers are for related state transitions, not kitchen sinks.

Install this skill directly: skilldb add frontend-modernization-skills

Get CLI access →