How to manage state in modern frontend applications: a practical guide
Frontend state management tutorial: a practical guide across local, server, URL, and app state with concrete patterns and code.
What to manage where
- Local UI state: useState, useReducer, or component-level hooks for transient UI like form inputs, toggles, modals.
- Server data state: fetchable data from APIs that needs caching, invalidation, and background refresh.
- URL state: values encoded in the URL (query params) to enable shareable/bookmarkable state.
- App/global state: cross-cutting state shared across many components, like authentication, theme, or user preferences.
- Persisted client state: state that should survive page reloads, e.g., user preferences saved to localStorage.
Patterns and when to use them
- Context API
- When to use: simple global state that doesn’t need complex logic or heavy updates; you want to avoid prop drilling without adding a full library.
- Pros: built-in to React, minimal setup, good for lightweight sharing.
- Cons: can become hard to manage for large state; re-renders can cascade if not careful.
- How to implement: create a context, provide a value object, and consume with useContext or a custom hook.
- Redux
- When to use: large apps with complex data flows, strict immutability, time-travel debugging, or when multiple teams own different domains of state.
- Pros: predictable state container, robust ecosystem, great devtools.
- Cons: boilerplate, learning curve, can be overkill for small apps.
- Variants: classic Redux with reducers and actions; Redux Toolkit for ergonomic patterns.
- Zustand
- When to use: lightweight, fast setup, desire for simple store access outside React components; good for small-to-medium apps.
- Pros: minimal boilerplate, fine-grained reactivity, easy to use outside React.
- Cons: smaller ecosystem than Redux; middleware patterns exist but are different.
- React Query / TanStack Query
- When to use: server state management-data fetched from APIs that benefits from caching, background refreshing, refetching, and synchronization.
- Pros: reduces boilerplate for fetching and caching; handles stale data and consistency.
- Cons: not a general state store; focus is on server state, not client UI state.
- URL parameters (search params)
- When to use: shareable or bookmarkable app state; filter/sort/pagination states that users might want to persist via URL.
- Pros: enables deep linking; easy to copy/share state.
- Cons: URL length limits; encoding/decoding boilerplate; can expose sensitive data if not careful.
- URL state patterns to avoid over-engineering
- Keep only essential pieces in the URL (e.g., current page, active filters, search query) while keeping heavy UI state in memory.
- Synchronize selectively: update URL on user-initiated changes, debounce or throttle if updates are frequent.
Choosing the right combination
- Start with the simplest tool that fits the need.
- Separate concerns: treat server/state data with a dedicated tool (React Query), UI/local state with React hooks, and cross-cutting, non-UI state with Context or a small store.
- For large apps, consider a layered approach: local components + Context for light global share + a dedicated store for domain-wide state + React Query for server data + URL params for shareable filters.
Architectural patterns
- Global store with domain slices
- Structure: split the store into domain-specific slices (e.g., user, projects, UI). This makes maintenance easier and reduces unnecessary re-renders.
- Example approach: with a library like Zustand or Redux Toolkit, create separate slices and compose them.
- Server-state-first pattern
- Use a dedicated server-state manager (React Query) for all API data. Keep component-level caches in sync; derive UI state (like loading status) from React Query’s hooks.
- URL-driven UI
- Map key UI controls to URL search params: page, sort, filter, query. Read from URL on initial load and write back on changes. Use a router utility to keep the URL in sync.
Real-world example: a product list page
- Local state: search input, current modal visibility
- URL state: page number, page size, sort field, price range filters
- Server state: product data, category data fetched via API
- App state: user authentication status, cart contents
Minimal working code snippets
- Local UI state with useState
- const [query, setQuery] = useState('');
- const [modalOpen, setModalOpen] = useState(false);
- Context API for simple global UI state
- Create a ThemeContext to toggle light/dark mode and provide a useTheme hook.
- Zustand store for cross-cutting app state
- Create a small store with user, theme, and a simple toggle; export hooks for usage in components.
- React Query for server data
- useQuery(['products', filters], fetchProducts)
- useMutation for actions that modify server data (e.g., add to cart)
- URL params with TanStack Router or direct URL API
- Read: new URLSearchParams(window.location.search).get('page')
- Write: update the URL via history.pushState or a router’s navigation method
- Testing state logic
- Unit tests: test reducers or pure state functions with deterministic inputs.
- Integration tests: render components with mocked contexts and server responses; verify state transitions and UI changes.
- For server state: test caching and invalidation by simulating API responses and refetch logic.
Anti-patterns to avoid
- Storing everything in a single global store without structure
- Overusing Context for large state updates that cause frequent re-renders
- Mixing server and client state in the same store without clear boundaries
- Relying on URL state for non-shareable ephemeral UI state (noisy URLs)
Testing tips
- Isolate state logic: test pure functions and reducers separately.
- Mock network calls when testing server-state hooks.
- Test URL synchronization: simulate URL changes and verify UI reflects the state correctly; also verify that state changes update the URL predictably.
Tooling quick-start checklist
- Start with React hooks for local UI state
- Add Context for lightweight global state needs
- Introduce a small store (Zustand or Redux Toolkit) for domain-level state
- Introduce React Query for server data
- Implement URL-driven state for shareable filters and navigation
- Add tests early for state logic and URL synchronization
Illustration example: state flow
- UI action (type a query) -> local state updates -> URL params update -> React Query fetchs new server data -> UI renders with new data
- This keeps the user experience responsive while ensuring shareable, consistent state across sessions.
Would you like a compact, copy-paste starter project that wires up a sample page (local form state, a global theme via Context, server data with React Query, and URL-driven filters) with a small test suite?
If so, I can tailor it to your stack (Create React App, Next.js, or Vite) and include ready-to-run snippets for each pattern.
Possible clarifying directions
- Do you prefer a Next.js or a plain React setup for this tutorial
- Are you targeting a particular app domain (e-commerce, dashboard, social app)
- Do you want TypeScript included by default
-
Rizwan Saleem | https://rizwansaleem.co
Top comments (0)