DEV Community

Rizwan Saleem
Rizwan Saleem

Posted on

How to manage state in modern frontend applications — a practical guide

How to manage state in modern frontend applications — a practical guide

Frontend State Management: A Practical Guide for 2026

React's state management landscape has fragmented into specialized solutions. The key to avoiding over-engineering is understanding that not all state is the same. This guide covers local state, server state, URL state, and app state-with real code examples showing when to use each pattern.

The State Management Spectrum

Before choosing a tool, categorize your state:

State Type Characteristics Best Tool
Local state Form inputs, UI toggles, ephemeral data useState/useReducer
Server state Async API data, needs caching/mutations TanStack Query (React Query)
Global UI state Themes, modals, sidebars, auth Zustand or Context API
URL state Filters, pagination, search params nuqs or TanStack Router
Complex global state Dozens of actions, time-travel debugging Redux Toolkit

Local State: useState and useReducer

Use local state for anything contained within a single component.

Form Inputs and UI Toggles

import { useState } from 'react';

function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState(null);
  const [status, setStatus] = useState('typing'); // typing | submitting | success

  if (status === 'success') {
    return <h1>Login successful!</h1>;
  }

  async function handleSubmit(e) {
    e.preventDefault();
    setStatus('submitting');
    try {
      await submitForm(email, password);
      setStatus('success');
    } catch (err) {
      setError(err);
      setStatus('typing');
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={email}
        onChange={e => setEmail(e.target.value)}
        disabled={status === 'submitting'}
      />
      <input
        type="password"
        value={password}
        onChange={e => setPassword(e.target.value)}
        disabled={status === 'submitting'}
      />
      {error && <p className="Error">{error.message}</p>}
      <button disabled={!email || !password || status === 'submitting'}>
        Submit
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Key principles:

  • Avoid redundant state: Don't store fullName if you have firstName and lastName-calculate it during render
  • Keep state local: Only lift state up when multiple components need it

Complex Local State with useReducer

When you have many related state updates, consolidate logic in a reducer:

import { useReducer } from 'react';

function TaskApp() {
  const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

  function handleAddTask(text) {
    dispatch({ type: 'added', id: nextId++, text });
  }

  function handleChangeTask(task) {
    dispatch({ type: 'changed', task });
  }

  function handleDeleteTask(taskId) {
    dispatch({ type: 'deleted', id: taskId });
  }

  return (
    <>
      <AddTask onAddTask={handleAddTask} />
      <TaskList
        tasks={tasks}
        onChangeTask={handleChangeTask}
        onDeleteTask={handleDeleteTask}
      />
    </>
  );
}

function tasksReducer(tasks, action) {
  switch (action.type) {
    case 'added':
      return [...tasks, { id: action.id, text: action.text, done: false }];
    case 'changed':
      return tasks.map(t =>
        t.id === action.task.id ? action.task : t
      );
    case 'deleted':
      return tasks.filter(t => t.id !== action.id);
    default:
      throw Error('Unknown action: ' + action.type);
  }
}
Enter fullscreen mode Exit fullscreen mode

This pattern makes state updates predictable and testable.

Server State: TanStack Query (React Query)

Stop putting API data in Redux. Server state is fundamentally different-it's async, needs caching, and requires mutation patterns. TanStack Query handles all of this automatically.

Basic Query with Caching

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

function UserProfile({ userId }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
    gcTime: 30 * 60 * 1000,   // Cache time (formerly cacheTime)
  });

  if (isLoading) return <Skeleton />;
  if (error) return <ErrorComponent error={error} />;
  return <Profile user={data} />;
}
Enter fullscreen mode Exit fullscreen mode

Mutations with Optimistic Updates

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

function UpdateUser({ userId }) {
  const queryClient = useQueryClient();

  const updateMutation = useMutation({
    mutationFn: updateUser,
    onMutate: async (newData) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: ['user', userId] });
      // Snapshot previous value
      const previous = queryClient.getQueryData(['user', userId]);
      // Optimistically update UI
      queryClient.setQueryData(['user', userId], newData);
      return { previous };
    },
    onError: (err, newData, context) => {
      // Rollback on error
      queryClient.setQueryData(['user', userId], context.previous);
    },
    onSettled: () => {
      // Always refetch after error or success
      queryClient.invalidateQueries({ queryKey: ['user', userId] });
    },
  });

  return (
    <button onClick={() => updateMutation.mutate({ name: 'New Name' })}>
      Update Name
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

TanStack Query provides caching, deduplication, background refetches, and optimistic updates out of the box.

Global UI State: Zustand vs Context API

When to Use Context API

Context is built into React and works well for:

  • Theme (dark/light mode)
  • User authentication status
  • Language settings (i18n)
  • Settings read often but written rarely

Bad use cases for Context:

  • Frequently updated data (real-time feeds, chat)
  • Complex state with many actions
  • Performance-critical updates that change on every render
import { createContext, useContext, useState } from 'react';

const ThemeContext = createContext(null);

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  const toggleTheme = () => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light');
  };

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider');
  }
  return context;
}

// Usage
function ThemedButton() {
  const { theme, toggleTheme } = useTheme();
  return (
    <button onClick={toggleTheme} style={{ background: theme === 'light' ? '#fff' : '#333' }}>
      Toggle to {theme === 'light' ? 'dark' : 'light'} mode
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Zustand: Simpler, Faster, More Flexible

Zustand is the modern choice for global UI state. It has minimal boilerplate, no providers needed, and built-in middleware for persistence and devtools.

import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';

const useStore = create(
  devtools(
    persist(
      (set, get) => ({
        user: null,
        cart: [],
        setUser: (user) => set({ user }),
        addToCart: (item) => set((state) => ({
          cart: [...state.cart, item]
        })),
        total: () => get().cart.reduce((sum, item) => sum + item.price, 0),
        clearCart: () => set({ cart: [] })
      }),
      {
        name: 'app-storage',
        part: ['user', 'cart'] // Only persist these fields
      }
    )
  )
);

// Usage - no providers needed!
function CartButton() {
  const { cart, total } = useStore();
  return <button>{cart.length} items (${total()})</button>;
}

function UserProfile() {
  const { user, setUser } = useStore();

  const handleLogin = (userData) => {
    setUser(userData);
  };

  return user ? <div>Welcome, {user.name}</div> : <button onClick={() => handleLogin({ name: 'John' })}>Login</button>;
}
Enter fullscreen mode Exit fullscreen mode

Zustand vs Redux comparison:

  • Redux: Structured and powerful-best for very large, complex applications with dozens of actions
  • Zustand: Minimal and fast-best for small/medium projects or when you want to move quickly without boilerplate

URL State: Shareable, Bookmarkable State

URL params are global, serializable, and shareable. Use them for filters, pagination, and search-anything users should be able to bookmark or share.

Using nuqs for URL State

import { useQueryState } from 'nuqs';

function ProductList() {
  const [category, setCategory] = useQueryState('category');
  const [sort, setSort] = useQueryState('sort', { defaultValue: 'price-asc' });
  const [page, setPage] = useQueryState('page', { 
    defaultValue: '1',
    parse: Number,
    serialize: String
  });
  const [search, setSearch] = useQueryState('search');

  // URL is now: /products?category=electronics&sort=price-asc&page=2&search=laptop
  // Shareable, bookmarkable, back-button aware

  const filteredProducts = products.filter(product => {
    if (category && product.category !== category) return false;
    if (search && !product.name.toLowerCase().includes(search.toLowerCase())) return false;
    return true;
  });

  const sortedProducts = filteredProducts.sort((a, b) => {
    if (sort === 'price-asc') return a.price - b.price;
    if (sort === 'price-desc') return b.price - a.price;
    return 0;
  });

  return (
    <>
      <select value={category || ''} onChange={e => setCategory(e.target.value || null)}>
        <option value="">All Categories</option>
        <option value="electronics">Electronics</option>
        <option value="clothing">Clothing</option>
      </select>
      <select value={sort} onChange={e => setSort(e.target.value)}>
        <option value="price-asc">Price: Low to High</option>
        <option value="price-desc">Price: High to Low</option>
      </select>
      <input 
        placeholder="Search..." 
        value={search || ''} 
        onChange={e => setSearch(e.target.value || null)} 
      />
      <div>
        {sortedProducts.map(product => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
      <Pagination currentPage={page} onPageChange={setPage} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

The URL becomes the source of truth for filters-users can copy the URL, share it, or bookmark it, and they'll get the exact same view.

Choosing the Right Approach

Decision Matrix

Situation Use This Approach
Form inputs, UI toggles Local (useState)
Theme, auth, simple settings Context API
Multiple components write frequently Zustand/Jotai
Complex state with many actions Redux Toolkit
Need undo/redo/time-travel Redux Toolkit
Performance critical with many updates Zustand or Jotai
API data, caching TanStack Query
Theme, modals Zustand or Context
Forms React Hook Form
URL params nuqs
Animation state useState + refs
Complex global state Zustand or Redux Toolkit

When to Avoid Redux

Stop using Redux for everything. Only reach for Redux Toolkit when you have genuinely complex client-side logic that Zustand can't handle:

  • Very complex state (dozens of actions, reducers)
  • Need time-travel debugging
  • Team already knows Redux
  • Need middleware (thunk, saga)
  • Want large ecosystem support

For most applications in 2026:

  • TanStack Query handles server state
  • Zustand handles global UI state
  • useState handles local state

Avoiding Over-Engineering

Common Mistakes

  1. Using Redux for everything: TanStack Query should handle API data, not Redux
  2. Storing derived data in state: Calculate fullName from firstName + lastName during render instead of storing it
  3. Putting server state in global stores: API data belongs in TanStack Query's cache, not Zustand/Redux
  4. Not using URL state for filters: Filters and pagination should be in the URL for shareability
  5. Creating global state for local things: Keep state as local as possible-only lift when needed

Keep It Simple Principles

  1. Start with built-in tools: Use useState and useContext before introducing external libraries
  2. Separate concerns: Distinguish between local and global states-not all state needs to be shared
  3. Predictable updates: Use immutable updates to prevent unexpected side effects
  4. Leverage DevTools: Use Redux DevTools, Zustand DevTools, or React DevTools to debug

Testing State Logic

Testing Reducers

Reducers are pure functions-easy to test:

// tasksReducer.test.js
import { describe, it, expect } from 'vitest';
import { tasksReducer } from './tasksReducer';

const initialTasks = [
  { id: 0, text: 'Task 1', done: false },
  { id: 1, text: 'Task 2', done: true }
];

describe('tasksReducer', () => {
  it('adds a new task', () => {
    const newState = tasksReducer(initialTasks, {
      type: 'added',
      id: 2,
      text: 'New Task'
    });
    expect(newState).toHaveLength(3);
    expect(newState).toEqual({ id: 2, text: 'New Task', done: false });
  });

  it('deletes a task', () => {
    const newState = tasksReducer(initialTasks, {
      type: 'deleted',
      id: 0
    });
    expect(newState).toHaveLength(1);
    expect(newState.id).toBe(1);
  });

  it('changes a task', () => {
    const newState = tasksReducer(initialTasks, {
      type: 'changed',
      task: { id: 0, text: 'Updated Task 1', done: true }
    });
    expect(newState.text).toBe('Updated Task 1');
    expect(newState.done).toBe(true);
  });
});
Enter fullscreen mode Exit fullscreen mode

Testing Zustand Stores

// useStore.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { useStore } from './useStore';

describe('Cart Store', () => {
  beforeEach(() => {
    // Reset store before each test
    useStore.setState({ cart: [], user: null });
  });

  it('adds items to cart', () => {
    const item = { id: 1, name: 'Book', price: 10 };
    useStore.getState().addToCart(item);
    expect(useStore.getState().cart).toHaveLength(1);
    expect(useStore.getState().cart).toEqual(item);
  });

  it('calculates total correctly', () => {
    useStore.setState({ 
      cart: [
        { id: 1, price: 10 },
        { id: 2, price: 20 }
      ] 
    });
    expect(useStore.getState().total()).toBe(30);
  });

  it('clears cart', () => {
    useStore.setState({ cart: [{ id: 1, price: 10 }] });
    useStore.getState().clearCart();
    expect(useStore.getState().cart).toHaveLength(0);
  });
});
Enter fullscreen mode Exit fullscreen mode

Testing TanStack Query

// UserProfile.test.js
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import UserProfile from './UserProfile';

const createQueryClient = () => 
  new QueryClient({ defaultOptions: { queries: { retry: false } } });

function renderWithQueryClient(ui, { queryClient = createQueryClient() } = {}) {
  return render(
    <QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
  );
}

describe('UserProfile', () => {
  it('shows loading state', () => {
    const queryClient = createQueryClient();
    renderWithQueryClient(<UserProfile userId={1} />, { queryClient });
    expect(screen.getByText(/loading/i)).toBeInTheDocument();
  });

  it('fetches and displays user data', async () => {
    const mockUser = { id: 1, name: 'John Doe', email: 'john@example.com' };
    vi.mocked(fetchUser).mockResolvedValue(mockUser);
    renderWithQueryClient(<UserProfile userId={1} />);
    await waitFor(() => {
      expect(screen.getByText('John Doe')).toBeInTheDocument();
    });
  });

  it('shows error state', async () => {
    vi.mocked(fetchUser).mockRejectedValue(new Error('Failed to fetch'));
    renderWithQueryClient(<UserProfile userId={1} />);
    await waitFor(() => {
      expect(screen.getByText(/error/i)).toBeInTheDocument();
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Complete Real-World Example: E-Commerce Dashboard

Here's how all these patterns work together in a real application:

// App.jsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider, useTheme } from './ThemeContext';
import ProductList from './ProductList';
import CartButton from './CartButton';

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <ThemeProvider>
        <Dashboard />
      </ThemeProvider>
    </QueryClientProvider>
  );
}

function Dashboard() {
  const { theme } = useTheme();

  return (
    <div style={{ background: theme === 'dark' ? '#1a1a1a' : '#fff' }}>
      <header>
        <h1>Shop</h1>
        <CartButton />
      </header>
      <ProductList />
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode
// ProductList.jsx - URL state + Server state
import { useQueryState } from 'nuqs';
import { useQuery } from '@tanstack/react-query';

function ProductList() {
  const [category, setCategory] = useQueryState('category');
  const [sort, setSort] = useQueryState('sort', { defaultValue: 'price-asc' });
  const [page, setPage] = useQueryState('page', { 
    defaultValue: '1', 
    parse: Number, 
    serialize: String 
  });

  // Server state: API data with caching
  const { data: products, isLoading } = useQuery({
    queryKey: ['products', category],
    queryFn: () => fetchProducts(category),
    staleTime: 5 * 60 * 1000,
  });

  // Local state: ephemeral UI state
  const [searchTerm, setSearchTerm] = useState('');

  if (isLoading) return <LoadingSpinner />;

  const filtered = products
    .filter(p => !searchTerm || p.name.toLowerCase().includes(searchTerm.toLowerCase()))
    .sort((a, b) => {
      if (sort === 'price-asc') return a.price - b.price;
      if (sort === 'price-desc') return b.price - a.price;
      return 0;
    });

  return (
    <>
      <input 
        placeholder="Search..." 
        value={searchTerm} 
        onChange={e => setSearchTerm(e.target.value)}  // Local state
      />
      <select value={category || ''} onChange={e => setCategory(e.target.value || null)}>
        <option value="">All Categories</option>
        <option value="electronics">Electronics</option>
      </select>
      <div>
        {filtered.map(product => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode
// CartButton.jsx - Zustand global state
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

const useCartStore = create(
  persist(
    (set, get) => ({
      cart: [],
      addToCart: (item) => set((state) => ({ 
        cart: [...state.cart, item] 
      })),
      removeFromCart: (itemId) => set((state) => ({
        cart: state.cart.filter(item => item.id !== itemId)
      })),
      total: () => get().cart.reduce((sum, item) => sum + item.price, 0)
    }),
    { name: 'cart-storage' }
  )
);

function CartButton() {
  const { cart, total } = useCartStore();

  return (
    <button>
      Cart ({cart.length}) - ${total()}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Summary

The right state management approach depends on the type of state:

  • Local state (useState): Form inputs, UI toggles, component-specific data
  • Server state (TanStack Query): API data, caching, mutations
  • Global UI state (Zustand): Themes, modals, cart, auth
  • URL state (nuqs): Filters, pagination, search-anything shareable
  • Complex state (Redux Toolkit): Only when Zustand can't handle it

Key takeaways:

  1. Stop using Redux for everything-TanStack Query handles server state, Zustand handles global UI state
  2. Keep state as local as possible-only lift when multiple components need it
  3. Use URL state for filters and pagination-makes your app shareable and bookmarkable
  4. Avoid redundant state-calculate derived values during render
  5. Test your state logic-reducers and stores are pure functions that are easy to unit test

Build simpler, faster apps by choosing the right tool for each type of state.


Rizwan Saleem — https://rizwansaleem.co

Top comments (0)