Web State Management
State management is how you control data flow in your application - where state lives, how it changes, and how components access it. Good state management makes applications predictable, testable, and maintainable.
Understanding Application State
State is data that changes over time and affects what users see. Different types of state have different characteristics and require different management approaches.
State Categories
Server State (Remote Data):
- Origin: External sources (APIs, databases, third-party services)
- Characteristics: Asynchronous, potentially stale, shared across users
- Challenges: Caching, synchronization, loading states, error handling, background refetching
- Source of truth: Backend server
- Examples: User accounts, transactions, payment history, product catalog
Client State (Local Data):
- Origin: Browser/application
- Characteristics: Synchronous, always fresh (is the source of truth), user-specific
- Challenges: Persistence, cross-component access, derived values
- Source of truth: Client-side store
- Examples: User preferences, theme, selected filters, UI settings
UI State (Ephemeral):
- Origin: User interactions
- Characteristics: Temporary, component-specific, not persisted
- Challenges: Component coupling, prop drilling
- Source of truth: Component or parent component
- Examples: Modal open/closed, dropdown expanded, loading indicators, form validation errors
URL State (Shareable):
- Origin: Browser location
- Characteristics: Bookmarkable, shareable, survives page refresh
- Challenges: Serialization, type safety, synchronization with UI
- Source of truth: Browser URL
- Examples: Current route, search query, pagination page, filter selections
Form State (Input Data):
- Origin: User input
- Characteristics: Temporary until submission, requires validation, may have autosave
- Challenges: Validation, dirty tracking, submission status
- Source of truth: Form component or form library
- Examples: Text inputs, checkbox selections, file uploads, multi-step form progress
Why State Categories Matter
Different state types have fundamentally different characteristics. Treating all state the same leads to over-engineered solutions for simple problems or under-engineered solutions for complex ones.
Common mistake: Using a global state solution (like Redux or Zustand) for server state leads to manually implementing caching, loading states, refetching, and error handling. Specialized server state libraries (React Query, SWR, Apollo Client) handle these concerns automatically.
Common mistake: Storing UI state globally when it's only used by one component adds unnecessary complexity. Local state keeps components self-contained and easier to test.
Common mistake: Not using URL state for shareable application views prevents users from bookmarking or sharing links to specific filtered/paginated views.
Core Principles
1. Single Source of Truth
Each piece of state should have exactly one authoritative source. Don't duplicate data across multiple stores or components - derive it from the single source when needed.
Bad: Storing the same user data in both a global store and a component's local state. When the user updates their profile, you must remember to update both locations.
Good: Store user data in one location (global store or server state cache) and have components read from that single source. Updates automatically propagate to all consumers.
Derived data: Calculate values from the source of truth rather than storing them separately. For example, if you store a list of items and their prices, calculate the total price when needed rather than maintaining a separate totalPrice state variable.
2. Colocation (Keep State Local)
State should live as close as possible to where it's used. Don't lift state to a global store unless multiple distant components genuinely need access.
Decision process:
- Does only one component use this state? → Keep it in that component
- Do only sibling components need this state? → Lift to common parent
- Do components across different routes need this state? → Consider global state
- Is this data from an API? → Use server state management
Benefits of colocation:
- Easier to understand: State logic lives next to the code that uses it
- Easier to test: Component tests are self-contained
- Easier to refactor: Moving or deleting a component moves/deletes its state
- Better performance: Fewer components re-render when state changes
When to lift state: When you're passing the same callback functions down through multiple component layers just to update parent state, or when you need to synchronize state between distant components.
3. Immutability
Never modify state directly. Always create new objects/arrays when updating state. This enables:
- Change detection: Frameworks can detect changes by reference equality (
oldState === newState) - Time-travel debugging: State history can be maintained by keeping old state snapshots
- Predictability: State changes are explicit and traceable
- Performance optimizations: Components can skip re-renders if state reference hasn't changed
Example of immutable updates:
// BAD: Mutation
users.push(newUser); // Modifies existing array
user.name = 'Jane'; // Modifies existing object
// GOOD: Immutable
const updatedUsers = [...users, newUser]; // New array
const updatedUser = { ...user, name: 'Jane' }; // New object
Nested updates:
// BAD: Nested mutation
state.user.profile.email = '[email protected]';
// GOOD: Immutable nested update
const updatedState = {
...state,
user: {
...state.user,
profile: {
...state.user.profile,
email: '[email protected]'
}
}
};
For deeply nested structures, consider normalization (flatten the structure) or use libraries that simplify immutable updates (Immer, Immutable.js).
4. Unidirectional Data Flow
Data should flow in one direction: from state stores → to components → to UI. User interactions create actions/events that update stores, which then update components.
Pattern:
User Action → Update State → Re-render UI
Why it matters:
- Predictable: You always know how data flows through your application
- Debuggable: You can trace state changes back to specific actions
- Testable: Pure functions transform state based on actions
Anti-pattern (bidirectional binding): When components can directly modify state that other components depend on without going through a centralized update mechanism, it becomes difficult to track why state changed.
5. Minimize Derived State
Don't store values that can be calculated from existing state. Compute them on-demand or use memoization for expensive calculations.
Bad:
// Storing redundant derived state
{
items: [...],
itemCount: 5, // Redundant: items.length
totalPrice: 99.99, // Redundant: can be calculated from items
isEmpty: false // Redundant: items.length === 0
}
Good:
// Store minimal state, derive everything else
{
items: [...]
}
// Compute when needed
const itemCount = items.length;
const totalPrice = items.reduce((sum, item) => sum + item.price, 0);
const isEmpty = items.length === 0;
Memoization: For expensive calculations that run frequently, memoize (cache) the results so they only recalculate when dependencies change. Most state management solutions provide computed/derived state features that automatically handle this.
6. Separate Server State from Client State
Server state and client state have fundamentally different characteristics and should be managed by different tools.
Server state characteristics:
- Asynchronous (requires loading states)
- Shared across users (potential for stale data)
- Requires caching for performance
- Needs background refetching to stay fresh
- Subject to network errors
- Source of truth is remote
Client state characteristics:
- Synchronous (always immediately available)
- User-specific (no staleness concerns)
- Source of truth is local
- Persists only as long as needed (session, localStorage, or ephemeral)
Use specialized libraries for server state: Libraries like React Query (React), Apollo Client (GraphQL), or SWR (React) automatically handle caching, refetching, deduplication, loading states, and error handling. Don't replicate this functionality manually in global state stores.
Use lightweight solutions for client state: For client state (theme, preferences, UI state), simple stores or built-in framework state management suffice.
State Scope Patterns
Local State
State used by a single component only.
Characteristics:
- Lives in the component
- Disposed when component unmounts
- Not accessible to other components
- Simplest form of state management
When to use:
- Form input values (before submission)
- Toggle states (dropdown open/closed, accordion expanded)
- Temporary UI state (hover, focus)
- Component-specific loading indicators
Example use cases:
- A search input component that filters a list as you type (state:
searchTerm) - A modal component tracking whether it's open (state:
isOpen) - A dropdown tracking which option is currently highlighted (state:
selectedIndex)
Lifted State
State lifted to a common parent component and passed down via props.
Characteristics:
- Lives in a parent component
- Shared between sibling or descendant components
- Passed down via props
- Updated via callback props
When to use:
- State shared between siblings (e.g., form inputs in different sections)
- Parent needs to coordinate child components
- State used by a component and its immediate children
Considerations:
- Prop drilling: If props pass through many intermediate components that don't use them, consider global state or context injection instead
- Component coupling: Children become dependent on parent's state shape
Example use cases:
- A form with multiple input components that need to share validation state
- A parent component rendering a list and a detail view, both need to know which item is selected
- A multi-step wizard where the parent tracks the current step and each step component needs that information
Global State
State accessible throughout the application.
Characteristics:
- Lives in a centralized store outside components
- Accessible from any component
- Survives component unmount/remount
- Requires state management library or framework mechanism
When to use:
- Authentication state (user, permissions, session)
- User preferences (theme, language, display settings)
- Application-wide UI state (sidebar open/closed, notifications)
- State shared across routes
- State that multiple distant components need
Trade-offs:
- More powerful: Any component can access and update
- More complex: Requires setup and understanding of state management patterns
- Less obvious data flow: Changes to global state can affect many components
Example use cases:
- Current logged-in user (used by header, profile page, permission checks throughout app)
- Theme preference (affects styling across all components)
- Shopping cart (accessed from product pages, cart page, checkout)
- Global notifications/toasts
Context/Dependency Injection State
State injected into a component subtree, accessible to descendants without prop drilling.
Characteristics:
- Lives at a specific level in the component tree
- Accessible to all descendants
- Does not pollute global scope
- Typically provided by framework mechanisms
When to use:
- Localized shared state (multi-step form state, wizard flow)
- Dependency injection (theme provider, i18n, feature flags)
- Avoiding prop drilling for deeply nested components
- Scoping state to specific feature modules
Trade-offs:
- Avoids prop drilling: Deeply nested children can access state directly
- Scoped: State doesn't leak to unrelated parts of the app
- Performance: In some frameworks, all consumers re-render when context value changes (check your framework's behavior)
Example use cases:
- A multi-step form where all steps need access to shared form data
- A theme provider that wraps a specific section of the app
- A feature-specific state that only components within that feature need
State Management Decision Tree
Follow this decision tree to choose the right state management approach:
Decision criteria:
- Server state? → Use specialized server state library (handles caching, refetching, loading states automatically)
- Single component? → Local state (simplest, most isolated)
- Parent-child relationship? → Lift to parent (direct prop passing)
- Nearby components in tree? → Context/injection (avoids prop drilling)
- Globally needed across routes? → Global state management
Common State Patterns
Loading States
When fetching data, track loading, success, and error states. This is sometimes called the "remote data pattern" or "async state pattern."
States:
- Idle: No request made yet
- Loading: Request in progress
- Success: Data received successfully
- Error: Request failed
State shape:
{
data: null | T, // The actual data (null until loaded)
loading: boolean, // Is request in progress?
error: string | null // Error message if failed
}
Why this pattern: It provides enough information to render appropriate UI for each state (spinner during loading, error message on failure, data when successful). Without tracking these states explicitly, you can't provide good user experience.
Extended pattern: Add refetching: boolean to distinguish between initial load and background refresh, so you can show data with a subtle indicator during refetch rather than replacing content with a spinner.
Optimistic Updates
Update the UI immediately with expected result, then reconcile with server response. If server request fails, roll back the optimistic update.
Flow:
- User performs action
- Immediately update UI with expected result (optimistic)
- Send request to server
- On success: Do nothing (UI already updated) or replace with server response
- On error: Revert UI to previous state and show error
Use cases:
- Creating/deleting list items
- Toggling flags (like/favorite/archive)
- Simple updates where you know the server's response shape
Don't use for:
- Operations where server calculates values (totals, balances)
- Operations that may fail validation
- Critical operations where showing incorrect data temporarily is unacceptable
Why it works: Provides instant feedback, making the application feel faster. Users expect their actions to take effect immediately, and waiting for server confirmation feels sluggish.
Normalization
Store data in a flat structure with IDs as keys, rather than nested arrays/objects. This simplifies updates and avoids duplication.
Nested structure (problematic):
{
users: [
{
id: 1,
name: 'Alice',
orders: [
{ id: 101, total: 50, items: [...] }
]
}
]
}
Problems:
- Updating an order requires finding the user, then finding the order in their orders array
- If an order appears in multiple places, you must update all occurrences
- Deep nesting makes immutable updates verbose
Normalized structure (better):
{
users: {
1: { id: 1, name: 'Alice', orderIds: [101] }
},
orders: {
101: { id: 101, userId: 1, total: 50, itemIds: [201, 202] }
},
items: {
201: { id: 201, name: 'Widget', price: 25 },
202: { id: 202, name: 'Gadget', price: 25 }
}
}
Benefits:
- Direct access to any entity by ID:
state.orders[101] - Update once, affects all references
- Shallow immutable updates:
{ ...state.orders, [101]: updatedOrder }
When to normalize: When you have relational data (entities reference each other), data appears in multiple views, or frequent updates to nested data.
Derived/Computed State
Calculate values from existing state rather than storing them separately. Many state management libraries provide memoization to cache computed results efficiently.
Pattern:
// Source state (stored)
const items = [...];
// Derived state (computed)
const itemCount = items.length;
const totalPrice = items.reduce((sum, item) => sum + item.price, 0);
const expensiveItems = items.filter(item => item.price > 100);
With memoization (pseudocode):
const totalPrice = computed(() =>
items.reduce((sum, item) => sum + item.price, 0)
);
// Only recalculates when `items` changes
Why it matters: Storing derived state creates opportunities for inconsistency (forgetting to update the derived value when source changes). Computing on-demand ensures consistency, and memoization ensures it's performant.
Pagination State
Track current page, items per page, total count, and whether there are more pages.
State shape:
{
currentPage: 1,
pageSize: 20,
totalItems: 150,
totalPages: 8,
items: [...], // Current page's items
hasNextPage: true,
hasPrevPage: false
}
Infinite scroll variation:
{
pages: [
{ items: [...], nextCursor: 'abc123' },
{ items: [...], nextCursor: 'def456' }
],
allItems: [...], // Flattened from all pages
hasMore: true
}
Considerations:
- Server-side pagination: Server returns page data and metadata (total count, cursors)
- Client-side pagination: Load all data once, slice on client (only for small datasets)
- Cursor-based vs. offset-based: Cursor-based (using opaque tokens) handles real-time data better, offset-based (page numbers) is simpler but can miss/duplicate items if data changes
Filter and Search State
State shape:
{
filters: {
status: 'active',
category: 'electronics',
priceRange: { min: 0, max: 1000 }
},
searchTerm: 'laptop',
sortBy: 'price',
sortOrder: 'asc'
}
Should it be in URL?: Yes, if you want users to bookmark or share filtered views. URL state persists across page refreshes and is shareable.
Local vs. URL state:
- URL state: User can bookmark, share, browser back/forward works
- Local state: Filters don't clutter URL, may be temporary explorations
Many applications use both: temporary filter adjustments in local state, with a "Apply Filters" button that commits them to the URL.
State Persistence Patterns
Session Storage
Data persists for the browser tab's lifetime. When the tab closes, data is cleared.
Use cases:
- Form autosave (recover if page refreshes)
- Multi-step process progress
- Temporary user selections within a session
Characteristics:
- Tab-specific (not shared across tabs)
- Cleared when tab closes
- Survives page refresh
- Storage limit: ~5-10MB
Local Storage
Data persists indefinitely until explicitly cleared.
Use cases:
- User preferences (theme, language)
- Saved filters or view settings
- "Remember me" state
- Draft content (until user explicitly deletes)
Characteristics:
- Shared across all tabs for the same origin
- Persists browser restarts
- Storage limit: ~5-10MB
- Synchronous API (can block main thread for large data)
Caution: Don't store sensitive data (tokens, passwords) in localStorage - it's accessible to any JavaScript running on the page, including third-party scripts and XSS attacks.
IndexedDB
Transactional database in the browser for larger datasets.
Use cases:
- Offline-first applications
- Caching large amounts of data
- Storing binary data (images, files)
Characteristics:
- Asynchronous API
- Much larger storage limits (~50MB+ depending on browser)
- Supports transactions, indexes, queries
- More complex API (consider using libraries like localforage or Dexie.js)
Cookies
Small data stored and sent with HTTP requests.
Use cases:
- Session identifiers
- Authentication tokens (with secure, httpOnly flags)
- Tracking preferences that server needs to know about
Characteristics:
- Sent with every HTTP request to the same domain (increases request size)
- Storage limit: ~4KB per cookie
- Can have expiration dates
- Can be marked secure (HTTPS only) and httpOnly (not accessible to JavaScript)
Use cookies for server state, localStorage for client state.
Anti-Patterns to Avoid
1. Storing Server Data in Global State
Don't manually fetch data and store it in your global state store. Use server state libraries that handle caching, refetching, and loading states automatically.
Why it's bad: You end up reimplementing caching logic, refetch strategies, cache invalidation, and error handling - all of which server state libraries provide for free.
2. Over-Globalizing State
Not all state needs to be global. Keeping state local makes components easier to understand, test, and reuse.
Why it's bad: Global state creates coupling between distant parts of your application and makes it harder to understand where state is used.
3. Prop Drilling (Without Good Reason)
Passing props through many layers of components that don't use them, just to get them to a deeply nested child.
Why it's bad: Intermediate components become coupled to data they don't use. Changes to prop shape require updates to all intermediate components.
Solution: Use context/injection or lift state management to a store accessible by the deeply nested component.
4. Mutating State Directly
Directly modifying state objects/arrays breaks change detection and leads to bugs.
Why it's bad: Frameworks rely on reference equality to detect changes. If you mutate the existing object, the reference doesn't change, so the framework doesn't know to re-render.
5. Not Handling Loading and Error States
Fetching data without tracking loading/error states leads to poor user experience (no feedback while loading, crashes on errors).
Why it's bad: Users don't know if their request is processing, completed, or failed. Unhandled errors crash components or display incorrect UI.
6. Storing Derived State
Storing values that can be calculated from other state creates synchronization problems.
Why it's bad: You must remember to update the derived value every time the source changes. Forgetting creates inconsistencies.
7. Mixing Concerns in State
Don't mix unrelated state domains in a single store unless they have genuine interdependencies.
Why it's bad: Makes the store harder to understand and increases the risk of unintended side effects when updating state.
Example: Mixing user authentication state with payment processing state in the same store when they don't interact. Split them into separate stores.
Framework-Specific Implementations
The concepts above apply to all web frameworks, but implementation details vary. See framework-specific guides for concrete examples:
React
- React State Management - Zustand, React Query, Context API patterns
- Local state:
useState,useReducer - Global client state: Zustand (recommended), Redux Toolkit
- Server state: React Query (TanStack Query)
- Context injection: React Context API
Angular
- Angular State Management - Services, Signals, NgRx patterns
- Local state: Component properties, Signals
- Global client state: Services with RxJS, Services with Signals
- Server state: Services with HttpClient, RxJS observables
- Context injection: Dependency injection, providedIn
Vue (If Applicable)
- Local state:
ref,reactive - Global client state: Pinia (recommended), Vuex
- Server state: TanStack Query for Vue, custom composables
- Context injection:
provide/inject
Further Reading
Related Guidelines
- React State Management - React-specific state patterns with Zustand and React Query
- Angular State Management - Angular-specific patterns with Services, Signals, and NgRx
- Frontend Overview - High-level frontend architecture principles
- Web Components - Component design patterns that affect state structure
- API Design - Understanding API contracts for server state
- TypeScript Types - Type-safe state management
External Resources
- State Management in Modern Applications
- Server State vs Client State
- Thinking in React (State Principles)
- Redux Style Guide (Applicable Beyond Redux)
Summary
Key Takeaways:
- Classify your state: Server, client, UI, URL, or form - each needs a different approach
- Server state ≠ client state: Use specialized libraries for server state (React Query, Apollo), simple stores for client state
- Colocation first: Keep state as local as possible, only globalize when necessary
- Single source of truth: Don't duplicate data; derive it from one authoritative source
- Immutability always: Never mutate state directly; create new objects/arrays
- Unidirectional flow: State updates flow one direction (action → state → UI)
- Minimize derived state: Calculate from source state rather than storing separately
- Handle loading/error states: Always track async operation status
- Normalize complex state: Flatten nested structures for easier updates
- Choose the right tool: Follow the decision tree to select the appropriate state management approach
Next Steps: Review framework-specific state management guides for concrete implementations and best practices tailored to your framework.