CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/frontend-state-management

Proactively choose the right state management pattern: derived state, URL state, server state, local state, lifted state, Context, or external store. Always apply without being asked.

84

1.16x
Quality

76%

Does it follow best practices?

Impact

97%

1.16x

Average score across 5 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

SKILL.mdskills/frontend-state-management/

name:
frontend-state-management
description:
Proactively choose the correct state management pattern for every piece of state in a React or vanilla JS application. Covers: local state vs lifted state vs Context vs external stores, derived state vs stored state, server state vs client state, URL-as-state for filters/search, form state patterns, optimistic updates, and stale closure prevention. Apply these rules in every frontend component you write without being asked.
keywords:
state management, react context, usestate, prop drilling, lifting state, local state, global state, cart state, form state, zustand, redux, when to use context, state architecture, component state, derived state, server state, react query, url state, search params, optimistic update, stale closure, useEffect, useReducer, colocation
license:
MIT

Frontend State Management -- Always Apply

These rules must be applied proactively in every frontend component you write, even when the user does not ask for them. Choosing the wrong state pattern is the #1 cause of buggy, unmaintainable frontend code. Always classify each piece of state and pick the right tool.


State Classification Decision Tree

Before writing ANY state, classify it:

1. Can this value be COMPUTED from other state?
   YES --> Do NOT store it. Derive it. (Section 1)

2. Does this state come from the SERVER (API data, DB records)?
   YES --> Use a data-fetching library or fetch+useEffect with proper cache.
           Do NOT put it in Context or global store. (Section 2)

3. Should this state survive navigation / be shareable via URL?
   (Filters, search query, pagination, sort order, selected tab)
   YES --> Put it in the URL as search params. (Section 3)

4. Is this state used by ONE component only?
   (Form input, toggle, modal open/close, hover, local UI)
   YES --> useState in that component. (Section 4)

5. Is this state shared between a parent and its direct children (1-2 levels)?
   YES --> Lift to parent, pass as props. (Section 5)

6. Is this state needed by many distant components across the tree?
   YES --> Is it updated very frequently (every keystroke, animation frame, drag)?
     YES --> External store (Zustand/Jotai) or useRef. (Section 7)
     NO  --> React Context with a custom hook. (Section 6)

The cardinal rule: NEVER move state up to a higher level than necessary. Colocate state with the component that owns it.


Section 1: Derived State -- Do NOT Store What You Can Compute

This is the single most common state management mistake. If a value can be calculated from existing state, compute it inline or in a useMemo. NEVER store it in a separate useState.

WRONG -- Storing derived state

function Cart({ items }: { items: CartItem[] }) {
  const [total, setTotal] = useState(0);
  const [itemCount, setItemCount] = useState(0);
  const [hasExpensiveItem, setHasExpensiveItem] = useState(false);

  // BUG: these can go out of sync with items
  useEffect(() => {
    setTotal(items.reduce((sum, i) => sum + i.price * i.quantity, 0));
    setItemCount(items.reduce((sum, i) => sum + i.quantity, 0));
    setHasExpensiveItem(items.some(i => i.price > 100));
  }, [items]);

  return <div>Total: ${total} ({itemCount} items)</div>;
}

RIGHT -- Computing derived values

function Cart({ items }: { items: CartItem[] }) {
  // Derive directly -- always in sync, no extra renders, no bugs
  const total = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
  const itemCount = items.reduce((sum, i) => sum + i.quantity, 0);
  const hasExpensiveItem = items.some(i => i.price > 100);

  // Use useMemo ONLY if the computation is genuinely expensive (>1ms)
  const sortedItems = useMemo(
    () => [...items].sort((a, b) => b.price - a.price),
    [items]
  );

  return <div>Total: ${total} ({itemCount} items)</div>;
}

Rule: If you see useEffect that reads state A and calls setState for state B, you almost certainly have derived state that should be computed inline instead.


Section 2: Server State -- Use Fetch Properly, Not Context

Data from APIs (product lists, user profiles, order history) is SERVER state. It has different needs than client state: caching, revalidation, loading/error states, deduplication.

WRONG -- Server data in Context

// BAD: manually managing server data in context
function ProductProvider({ children }: { children: ReactNode }) {
  const [products, setProducts] = useState<Product[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    fetch('/api/products')
      .then(r => r.json())
      .then(setProducts)
      .catch(e => setError(e.message))
      .finally(() => setLoading(false));
  }, []);

  // Problems: no cache, no revalidation, no dedup, stale on navigate back,
  // every consumer re-renders when ANY product data changes
  return (
    <ProductContext.Provider value={{ products, loading, error }}>
      {children}
    </ProductContext.Provider>
  );
}

RIGHT -- Data fetching with proper patterns

// Option A: React Query / TanStack Query (preferred for apps with multiple API calls)
function useProducts() {
  return useQuery({
    queryKey: ['products'],
    queryFn: () => fetch('/api/products').then(r => r.json()),
  });
}

function ProductList() {
  const { data: products, isLoading, error } = useProducts();
  if (isLoading) return <Spinner />;
  if (error) return <div role="alert">Failed to load products</div>;
  return <ul>{products.map(p => <ProductCard key={p.id} product={p} />)}</ul>;
}

// Option B: fetch + useEffect (fine for simpler apps / prototypes)
function useProducts() {
  const [products, setProducts] = useState<Product[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const controller = new AbortController();
    fetch('/api/products', { signal: controller.signal })
      .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
      .then(setProducts)
      .catch(e => { if (e.name !== 'AbortError') setError(e.message); })
      .finally(() => setLoading(false));
    return () => controller.abort();
  }, []);

  return { products, loading, error };
}

Rule: Server data belongs in a custom hook or data-fetching library, NOT in React Context or global state. The only exception is when you have a simple prototype with one or two API calls.


Section 3: URL as State -- Filters, Search, Pagination

State that should survive page refresh, be shareable via URL, or work with browser back/forward MUST live in URL search params.

WRONG -- Filters in useState

// BAD: filters lost on refresh, can't share URL, back button doesn't work
function ProductPage() {
  const [search, setSearch] = useState('');
  const [category, setCategory] = useState('all');
  const [sortBy, setSortBy] = useState('name');
  const [page, setPage] = useState(1);
  // ...
}

RIGHT -- Filters in URL search params

// With React Router v6+ or Next.js
function ProductPage() {
  const [searchParams, setSearchParams] = useSearchParams();

  const search = searchParams.get('q') ?? '';
  const category = searchParams.get('category') ?? 'all';
  const sortBy = searchParams.get('sort') ?? 'name';
  const page = Number(searchParams.get('page') ?? '1');

  const updateFilters = (updates: Record<string, string>) => {
    setSearchParams(prev => {
      const next = new URLSearchParams(prev);
      Object.entries(updates).forEach(([k, v]) => {
        if (v) next.set(k, v); else next.delete(k);
      });
      return next;
    });
  };

  return (
    <>
      <SearchBar value={search} onChange={q => updateFilters({ q, page: '1' })} />
      <CategoryFilter value={category} onChange={c => updateFilters({ category: c, page: '1' })} />
      <SortSelector value={sortBy} onChange={s => updateFilters({ sort: s })} />
      <ProductGrid search={search} category={category} sortBy={sortBy} page={page} />
      <Pagination page={page} onChange={p => updateFilters({ page: String(p) })} />
    </>
  );
}

Rule: Ask yourself "should this state survive a page refresh?" If yes, it belongs in the URL.

State that belongs in URL: search queries, filter selections, sort order, pagination, selected tab (if meaningful), open modal with specific content (e.g. /products?preview=123).

State that does NOT belong in URL: form input mid-typing, hover state, animation state, whether a dropdown is open.


Section 4: Local State (useState) -- Keep It Colocated

State used by only one component stays in that component. Do not lift it, do not put it in context.

WRONG -- Local UI state in global store

// BAD: modal visibility has no business being global
const useAppStore = create((set) => ({
  isDeleteModalOpen: false,
  setDeleteModalOpen: (open: boolean) => set({ isDeleteModalOpen: open }),
  deleteTargetId: null as string | null,
  // ... 20 other unrelated pieces of state
}));

function ProductRow({ product }: Props) {
  const { setDeleteModalOpen } = useAppStore();
  return <button onClick={() => setDeleteModalOpen(true)}>Delete</button>;
}

RIGHT -- Local state stays local

function ProductRow({ product, onDelete }: Props) {
  const [showConfirm, setShowConfirm] = useState(false);

  return (
    <>
      <button onClick={() => setShowConfirm(true)}>Delete</button>
      {showConfirm && (
        <ConfirmDialog
          message={`Delete "${product.name}"?`}
          onConfirm={() => { onDelete(product.id); setShowConfirm(false); }}
          onCancel={() => setShowConfirm(false)}
        />
      )}
    </>
  );
}

Use useState for: modal open/close, dropdown open/close, form input values, toggle/switch state, accordion expand/collapse, tooltip visibility, local loading spinners, temporary error messages.


Section 5: Lifted State (Props) -- The Default for Shared State

When two sibling components need the same state, lift it to their nearest common parent.

RIGHT -- Lifted state with props

function OrderPage() {
  const [cart, setCart] = useState<CartItem[]>([]);
  const [selectedCategory, setSelectedCategory] = useState('all');

  const addToCart = (item: CartItem) => setCart(prev => [...prev, item]);
  const removeFromCart = (index: number) =>
    setCart(prev => prev.filter((_, i) => i !== index));

  // Derived state -- NOT stored separately
  const total = cart.reduce((sum, item) => sum + item.price * item.quantity, 0);

  return (
    <>
      <MenuSidebar
        selectedCategory={selectedCategory}
        onSelectCategory={setSelectedCategory}
      />
      <MenuGrid category={selectedCategory} onAddItem={addToCart} />
      <CartPanel items={cart} total={total} onRemove={removeFromCart} />
    </>
  );
}

Stop and do NOT reach for Context if: props go only 1-2 levels deep. Passing onAddItem to MenuGrid which passes it to MenuItem is fine -- that is NOT prop drilling, that is normal React.

Prop drilling becomes a problem when: The same prop passes through 3+ intermediate components that do not use it themselves. Only then consider Context.


Section 6: React Context -- For Cross-Cutting Client State Only

Context is for state needed by many distant components: authentication/user, shopping cart (in e-commerce), theme, locale. Context is NOT a general-purpose state manager.

RIGHT -- Context with custom hook and stable references

// src/context/AuthContext.tsx
interface AuthState {
  user: User | null;
  isAuthenticated: boolean;
}

interface AuthContextType extends AuthState {
  login: (credentials: Credentials) => Promise<void>;
  logout: () => void;
}

const AuthContext = createContext<AuthContextType | null>(null);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  // Stable function references prevent unnecessary re-renders
  const login = useCallback(async (credentials: Credentials) => {
    const user = await authApi.login(credentials);
    setUser(user);
  }, []);

  const logout = useCallback(() => {
    authApi.logout();
    setUser(null);
  }, []);

  // Memoize the context value to prevent re-renders when parent renders
  const value = useMemo(
    () => ({ user, isAuthenticated: !!user, login, logout }),
    [user, login, logout]
  );

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

// ALWAYS wrap context in a custom hook with a null check
export function useAuth(): AuthContextType {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error('useAuth must be used within AuthProvider');
  return ctx;
}

WRONG -- Context for everything

// BAD: dumping all state into one giant context
<AppProvider>       {/* user, theme, locale */}
  <CartProvider>    {/* cart items */}
    <MenuProvider>  {/* menu data -- this is SERVER state! */}
      <UIProvider>  {/* modal states -- these are LOCAL state! */}
        <FilterProvider> {/* search/sort -- this is URL state! */}
          {children}
        </FilterProvider>
      </UIProvider>
    </MenuProvider>
  </CartProvider>
</AppProvider>

Context rules:

  1. ALWAYS wrap in a custom hook (useAuth, useCart) with null-check error
  2. ALWAYS memoize the context value object with useMemo
  3. NEVER put server/API data in Context (use data-fetching hooks)
  4. NEVER put local UI state in Context (use useState)
  5. NEVER put URL-worthy state in Context (use search params)
  6. NEVER put high-frequency updates in Context (every consumer re-renders)

Section 7: External Stores -- For High-Frequency or Complex State

Use Zustand, Jotai, or similar ONLY when Context causes perf issues (frequent updates re-rendering many consumers) or when state logic is complex enough to benefit from middleware (devtools, persistence, undo).

RIGHT -- Zustand for frequently-updated cross-cutting state

// Use Zustand when: many components need state that updates often
interface CanvasStore {
  cursorPosition: { x: number; y: number };
  zoom: number;
  selectedElementIds: Set<string>;
  setCursor: (pos: { x: number; y: number }) => void;
  setZoom: (zoom: number) => void;
  toggleSelection: (id: string) => void;
}

const useCanvasStore = create<CanvasStore>((set) => ({
  cursorPosition: { x: 0, y: 0 },
  zoom: 1,
  selectedElementIds: new Set(),
  setCursor: (pos) => set({ cursorPosition: pos }),
  setZoom: (zoom) => set({ zoom }),
  toggleSelection: (id) => set((state) => {
    const next = new Set(state.selectedElementIds);
    next.has(id) ? next.delete(id) : next.add(id);
    return { selectedElementIds: next };
  }),
}));

// Components subscribe to ONLY the slice they need (no unnecessary re-renders)
function Minimap() {
  const zoom = useCanvasStore(s => s.zoom);
  return <div>Zoom: {Math.round(zoom * 100)}%</div>;
}

Do NOT reach for Zustand/Redux when: Context with useMemo handles it fine. Most CRUD apps, dashboards, and e-commerce sites do NOT need an external store.


Section 8: Form State Patterns

WRONG -- Separate useState per field with manual sync

// BAD for forms with many fields -- tedious and error-prone
function RegistrationForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [confirmPassword, setConfirmPassword] = useState('');
  const [phone, setPhone] = useState('');
  const [address, setAddress] = useState('');
  // 6 separate useState calls, 6 separate onChange handlers...
}

RIGHT -- useReducer or object state for complex forms

// Option A: Single object state (good for 4+ fields)
interface FormData {
  name: string;
  email: string;
  password: string;
  confirmPassword: string;
}

function RegistrationForm() {
  const [form, setForm] = useState<FormData>({
    name: '', email: '', password: '', confirmPassword: '',
  });
  const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});

  const updateField = <K extends keyof FormData>(field: K, value: FormData[K]) => {
    setForm(prev => ({ ...prev, [field]: value }));
    // Clear error on edit
    if (errors[field]) setErrors(prev => ({ ...prev, [field]: undefined }));
  };

  // Derived: validation state computed, not stored
  const isValid = form.name.trim() !== '' && form.email.includes('@')
    && form.password.length >= 8 && form.password === form.confirmPassword;

  return (
    <form onSubmit={handleSubmit}>
      <input value={form.name} onChange={e => updateField('name', e.target.value)} />
      {/* ... */}
      <button type="submit" disabled={!isValid}>Register</button>
    </form>
  );
}

// Option B: useReducer (good for forms with complex transitions)
type FormAction =
  | { type: 'SET_FIELD'; field: keyof FormData; value: string }
  | { type: 'SET_ERRORS'; errors: Partial<Record<keyof FormData, string>> }
  | { type: 'RESET' };

function formReducer(state: FormData, action: FormAction): FormData {
  switch (action.type) {
    case 'SET_FIELD': return { ...state, [action.field]: action.value };
    case 'RESET': return { name: '', email: '', password: '', confirmPassword: '' };
    default: return state;
  }
}

Rule: Individual useState per field is fine for 1-3 fields. For 4+ fields, use an object state or useReducer. Form state is almost always LOCAL -- do not put it in Context or global store.


Section 9: Optimistic Updates

When a user action triggers an API call, update the UI immediately and roll back on failure.

RIGHT -- Optimistic update pattern

function TodoList() {
  const [todos, setTodos] = useState<Todo[]>(initialTodos);

  const toggleTodo = async (id: string) => {
    // 1. Save previous state for rollback
    const previous = todos;

    // 2. Optimistically update
    setTodos(prev => prev.map(t =>
      t.id === id ? { ...t, completed: !t.completed } : t
    ));

    // 3. Send to server, rollback on failure
    try {
      await api.toggleTodo(id);
    } catch {
      setTodos(previous); // Rollback
      toast.error('Failed to update. Please try again.');
    }
  };

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          <input
            type="checkbox"
            checked={todo.completed}
            onChange={() => toggleTodo(todo.id)}
          />
          {todo.name}
        </li>
      ))}
    </ul>
  );
}

Rule: For actions where the server almost always succeeds (toggling, liking, adding to cart), use optimistic updates. For destructive/irreversible actions (delete, payment), wait for server confirmation.


Section 10: Stale Closure Prevention in useEffect

WRONG -- Stale closure bug

function SearchResults() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<Result[]>([]);

  useEffect(() => {
    // BUG: no cleanup -- if user types fast, responses arrive out of order
    // and results can show data for an old query
    fetch(`/api/search?q=${query}`)
      .then(r => r.json())
      .then(setResults);
  }, [query]);

  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

RIGHT -- AbortController prevents stale results

function SearchResults() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<Result[]>([]);

  useEffect(() => {
    if (!query.trim()) { setResults([]); return; }

    const controller = new AbortController();
    fetch(`/api/search?q=${query}`, { signal: controller.signal })
      .then(r => r.json())
      .then(setResults)
      .catch(e => { if (e.name !== 'AbortError') console.error(e); });

    return () => controller.abort(); // Cancel previous request
  }, [query]);

  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

Rule: Every useEffect that fetches data MUST have a cleanup function (AbortController or cancelled flag). Without it, you get race conditions where stale responses overwrite fresh ones.


Vanilla JS State Management

For apps without React, use a simple pub/sub store:

type Listener<T> = (state: T) => void;

function createStore<T>(initial: T) {
  let state = initial;
  const listeners = new Set<Listener<T>>();

  return {
    getState: () => state,
    setState: (update: Partial<T> | ((prev: T) => T)) => {
      state = typeof update === 'function' ? update(state) : { ...state, ...update };
      listeners.forEach(fn => fn(state));
    },
    subscribe: (fn: Listener<T>) => {
      listeners.add(fn);
      return () => listeners.delete(fn);
    },
  };
}

Quick Reference: State Type to Pattern

State typePatternExample
Computable from other stateDerive inline / useMemoTotal, item count, validation
Server data (API responses)Data-fetching hook / libraryProducts, user profile, orders
URL-worthy (survives refresh)useSearchParams / URLFilters, search, pagination, sort
Single-component UIuseStateModal open, dropdown, form input
Parent + children (1-2 levels)Lift to parent, pass propsSelected item + detail view
Cross-cutting, low-frequencyReact Context + custom hookAuth, cart, theme, locale
Cross-cutting, high-frequencyExternal store (Zustand)Cursor position, canvas zoom
Complex transitionsuseReducerMulti-step form, complex toggle logic

Checklist -- Apply to EVERY Component

  • No derived state stored in useState (totals, counts, validation, filtered lists are computed)
  • Server data fetched in hooks with AbortController cleanup, not stored in Context
  • Filters, search, pagination, sort order live in URL search params
  • Form state is local (useState or useReducer), not in Context or global store
  • Local UI state (modal, dropdown, toggle) stays in the component that owns it
  • Shared state lifted to nearest common parent -- NOT higher than necessary
  • Context only used for truly cross-cutting state (auth, cart, theme)
  • Context value memoized with useMemo; functions wrapped in useCallback
  • Custom hook wraps each Context (useCart, useAuth) with null-check error
  • useEffect data fetching has AbortController cleanup to prevent stale results
  • Optimistic updates used for low-risk user actions with server rollback on failure

Verifiers

  • state-management-dashboard -- Dashboard with filters, data fetching, and interactive UI
  • state-management-ecommerce -- E-commerce app with cart, search, and user preferences
  • state-management-form -- Multi-field form with validation and submission

skills

frontend-state-management

tile.json