Skip to main content

State Management Patterns

This guide explains how to manage different types of state in frontend applications. Understanding when to use each tool prevents common problems like stale data, unnecessary re-renders, and complex debugging.

When to Use This Guide

Use this guide when you need to:

  • Cache API responses and keep them synchronized with the server
  • Share UI state (sidebar, theme, modals) across components
  • Handle loading and error states for data fetching
  • Implement optimistic updates for better user experience
  • Manage multi-tenant context in a SaaS application

Choosing the Right Tool

The most common mistake is using one tool for all state. Different types of state have different requirements:

CategoryToolExamplesWhy This Tool
Server StateReact QueryAPI data, cached responsesHandles caching, background refetch, stale data automatically
Client StateZustandUI state, user preferencesSimple API, no boilerplate, performant selectors
Form StateReact Hook FormForm inputs, validationOptimized for form performance, built-in validation
URL StateNext.js RouterQuery params, path paramsShareable URLs, browser history integration

Common Problems and Solutions

ProblemWrong ApproachRight Approach
Data becomes stale after mutationManually update local stateUse React Query cache invalidation
Component re-renders on unrelated state changesSubscribe to entire Zustand storeUse selectors to subscribe to specific values
Loading state shown on every page visitAlways fetch fresh dataConfigure staleTime to serve cached data
User sees old data after editWait for refetchUse optimistic updates

React Query for Server State

Use Case: Data List with Filtering

Scenario: Display a paginated list of products that users can filter by status or search.

Problem: Without caching, every filter change triggers a network request, even for previously loaded data.

Solution: React Query caches responses by query key, so returning to a previous filter serves cached data instantly.

// src/hooks/useProducts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { productApi } from '@/services/api/products';

// Query keys factory - ensures consistent cache keys
export const productKeys = {
all: ['products'] as const,
lists: () => [...productKeys.all, 'list'] as const,
list: (filters: ProductFilters) => [...productKeys.lists(), filters] as const,
details: () => [...productKeys.all, 'detail'] as const,
detail: (id: string) => [...productKeys.details(), id] as const,
};

// List query hook
export function useProducts(filters: ProductFilters = {}) {
return useQuery({
queryKey: productKeys.list(filters),
queryFn: () => productApi.list(filters),
});
}

// Detail query hook
export function useProduct(id: string) {
return useQuery({
queryKey: productKeys.detail(id),
queryFn: () => productApi.get(id),
enabled: !!id,
});
}

Use Case: Create, Update, Delete with Cache Sync

Scenario: User creates a new product and expects to see it in the list immediately.

Problem: After creating a product, the list still shows old data because it's cached.

Solution: Invalidate related queries after mutations to trigger automatic refetch.

// src/hooks/useProducts.ts
export function useCreateProduct() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: productApi.create,
onSuccess: () => {
// Invalidate list queries to refetch
queryClient.invalidateQueries({
queryKey: productKeys.lists(),
});
},
});
}

export function useUpdateProduct() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateProductDto }) =>
productApi.update(id, data),
onSuccess: (_, { id }) => {
// Invalidate both list and detail queries
queryClient.invalidateQueries({
queryKey: productKeys.lists(),
});
queryClient.invalidateQueries({
queryKey: productKeys.detail(id),
});
},
});
}

export function useDeleteProduct() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: productApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: productKeys.lists(),
});
},
});
}

Use Case: Optimistic Updates for Better UX

Scenario: User updates a product name and expects instant feedback.

Problem: Waiting for server response before showing the update feels slow.

Solution: Update the UI immediately, then sync with server. Roll back if the request fails.

export function useUpdateProduct() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateProductDto }) =>
productApi.update(id, data),
onMutate: async ({ id, data }) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({
queryKey: productKeys.detail(id),
});

// Snapshot the previous value
const previousProduct = queryClient.getQueryData(
productKeys.detail(id)
);

// Optimistically update to the new value
queryClient.setQueryData(productKeys.detail(id), (old: Product) => ({
...old,
...data,
}));

// Return context with the snapshotted value
return { previousProduct };
},
onError: (err, { id }, context) => {
// Roll back to the previous value on error
queryClient.setQueryData(
productKeys.detail(id),
context?.previousProduct
);
},
onSettled: (_, __, { id }) => {
// Always refetch after error or success
queryClient.invalidateQueries({
queryKey: productKeys.detail(id),
});
},
});
}

Use Case: Infinite Scroll List

Scenario: Display a long list of items that loads more as the user scrolls.

Problem: Traditional pagination requires clicking "Next" buttons and loses scroll position.

Solution: Use infinite query to append pages as user scrolls down.

export function useInfiniteProducts(filters: ProductFilters = {}) {
return useInfiniteQuery({
queryKey: productKeys.list({ ...filters, infinite: true }),
queryFn: ({ pageParam = 1 }) =>
productApi.list({ ...filters, page: pageParam }),
getNextPageParam: (lastPage, pages) => {
if (lastPage.hasMore) {
return pages.length + 1;
}
return undefined;
},
initialPageParam: 1,
});
}

// Usage in component
function ProductInfiniteList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteProducts();

const products = data?.pages.flatMap(page => page.items) ?? [];

return (
<div>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
{hasNextPage && (
<Button
onClick={() => fetchNextPage()}
loading={isFetchingNextPage}
>
Load More
</Button>
)}
</div>
);
}

Zustand for Client State

Use Case: UI State (Sidebar, Theme)

Scenario: User toggles sidebar, and it should stay open/closed as they navigate.

Problem: Local component state resets on navigation.

Solution: Use Zustand store to persist UI state across page changes.

// src/stores/useUIStore.ts
import { create } from 'zustand';

interface UIState {
sidebarOpen: boolean;
theme: 'light' | 'dark';
toggleSidebar: () => void;
setTheme: (theme: 'light' | 'dark') => void;
}

export const useUIStore = create<UIState>((set) => ({
sidebarOpen: true,
theme: 'light',
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setTheme: (theme) => set({ theme }),
}));

Use Case: Persisted User Preferences

Scenario: User selects language and page size preferences that should persist across sessions.

Problem: Settings are lost when user closes the browser.

Solution: Use Zustand persist middleware to save state to localStorage.

// src/stores/useSettingsStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';

interface SettingsState {
language: string;
pageSize: number;
setLanguage: (language: string) => void;
setPageSize: (size: number) => void;
}

export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
language: 'en',
pageSize: 20,
setLanguage: (language) => set({ language }),
setPageSize: (pageSize) => set({ pageSize }),
}),
{
name: 'app-settings',
storage: createJSONStorage(() => localStorage),
}
)
);

Use Case: Large Store with Multiple Concerns

Scenario: Application has authentication, UI, and notification state that need to be shared globally.

Problem: One large store becomes hard to maintain and test.

Solution: Use slices pattern to organize related state together while sharing a single store.

// src/stores/slices/authSlice.ts
import { StateCreator } from 'zustand';

export interface AuthSlice {
user: User | null;
isAuthenticated: boolean;
setUser: (user: User | null) => void;
logout: () => void;
}

export const createAuthSlice: StateCreator<AuthSlice> = (set) => ({
user: null,
isAuthenticated: false,
setUser: (user) => set({ user, isAuthenticated: !!user }),
logout: () => set({ user: null, isAuthenticated: false }),
});

// src/stores/slices/uiSlice.ts
export interface UISlice {
sidebarOpen: boolean;
toggleSidebar: () => void;
}

export const createUISlice: StateCreator<UISlice> = (set) => ({
sidebarOpen: true,
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
});

// src/stores/useAppStore.ts
import { create } from 'zustand';
import { createAuthSlice, AuthSlice } from './slices/authSlice';
import { createUISlice, UISlice } from './slices/uiSlice';

type AppStore = AuthSlice & UISlice;

export const useAppStore = create<AppStore>()((...args) => ({
...createAuthSlice(...args),
...createUISlice(...args),
}));

Use Case: Shopping Cart with Computed Values

Scenario: Display cart total and item count in the header, updated in real-time as items are added.

Problem: Computing totals on every render is wasteful; subscribing to entire cart causes unnecessary re-renders.

Solution: Use selectors to compute derived values and subscribe only to what's needed.

// src/stores/useCartStore.ts
import { create } from 'zustand';

interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}

interface CartState {
items: CartItem[];
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
}

export const useCartStore = create<CartState>((set) => ({
items: [],
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
return {
items: state.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
),
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
}),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((i) => i.id !== id),
})),
updateQuantity: (id, quantity) =>
set((state) => ({
items: state.items.map((i) =>
i.id === id ? { ...i, quantity } : i
),
})),
clearCart: () => set({ items: [] }),
}));

// Selectors - compute derived values
export const selectCartTotal = (state: CartState) =>
state.items.reduce((sum, item) => sum + item.price * item.quantity, 0);

export const selectCartItemCount = (state: CartState) =>
state.items.reduce((sum, item) => sum + item.quantity, 0);

// Usage - component only re-renders when selected value changes
function CartSummary() {
const total = useCartStore(selectCartTotal);
const itemCount = useCartStore(selectCartItemCount);

return (
<div>
<span>{itemCount} items</span>
<span>${total.toFixed(2)}</span>
</div>
);
}

Combining React Query and Zustand

Use Case: Authentication State

Scenario: App needs to know if user is authenticated and fetch user profile data.

Problem: Authentication state is needed immediately (client state), but profile data comes from API (server state).

Solution: Use Zustand for auth session state, React Query for profile data that depends on auth.

// src/stores/useAuthStore.ts
import { create } from 'zustand';
import { fetchAuthSession, signOut } from 'aws-amplify/auth';

interface AuthState {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
initialize: () => Promise<void>;
logout: () => Promise<void>;
}

export const useAuthStore = create<AuthState>((set) => ({
user: null,
isLoading: true,
isAuthenticated: false,

initialize: async () => {
try {
const session = await fetchAuthSession();
if (session.tokens) {
const user = await fetchUserInfo();
set({ user, isAuthenticated: true, isLoading: false });
} else {
set({ user: null, isAuthenticated: false, isLoading: false });
}
} catch {
set({ user: null, isAuthenticated: false, isLoading: false });
}
},

logout: async () => {
await signOut();
set({ user: null, isAuthenticated: false });
},
}));

// src/hooks/useCurrentUser.ts
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/stores/useAuthStore';

export function useCurrentUser() {
const { user, isAuthenticated } = useAuthStore();

return useQuery({
queryKey: ['currentUser'],
queryFn: fetchCurrentUserProfile,
enabled: isAuthenticated,
initialData: user,
});
}

Use Case: Multi-Tenant Context

Scenario: SaaS application where user can switch between tenants, and all data queries should filter by current tenant.

Problem: Every query needs to know the current tenant, and switching tenants should refresh all data.

Solution: Store current tenant in Zustand, include tenant in React Query keys to auto-invalidate on switch.

// src/stores/useTenantStore.ts
import { create } from 'zustand';

interface TenantState {
currentTenant: Tenant | null;
tenants: Tenant[];
setCurrentTenant: (tenant: Tenant) => void;
setTenants: (tenants: Tenant[]) => void;
}

export const useTenantStore = create<TenantState>((set) => ({
currentTenant: null,
tenants: [],
setCurrentTenant: (currentTenant) => set({ currentTenant }),
setTenants: (tenants) => set({ tenants }),
}));

// src/hooks/useTenantData.ts
import { useQuery } from '@tanstack/react-query';
import { useTenantStore } from '@/stores/useTenantStore';

export function useTenantProducts() {
const currentTenant = useTenantStore((state) => state.currentTenant);

return useQuery({
queryKey: ['products', currentTenant?.code],
queryFn: () => fetchProducts(currentTenant!.code),
enabled: !!currentTenant,
});
}

Best Practices

1. Query Key Conventions

Use Case: Ensure consistent cache keys across the application.

Why: Inconsistent keys cause cache misses and duplicate requests.

// Use factory pattern for consistent query keys
export const queryKeys = {
products: {
all: ['products'] as const,
lists: () => [...queryKeys.products.all, 'list'] as const,
list: (filters: Filters) => [...queryKeys.products.lists(), filters] as const,
details: () => [...queryKeys.products.all, 'detail'] as const,
detail: (id: string) => [...queryKeys.products.details(), id] as const,
},
users: {
all: ['users'] as const,
// ... similar pattern
},
};

2. Avoid State Duplication

Problem: Storing API data in both React Query cache and Zustand causes sync issues.

Solution: Use React Query as the single source of truth for server data.

// ❌ Bad: Duplicating server state in Zustand
const useProductStore = create((set) => ({
products: [], // Don't store API data here
setProducts: (products) => set({ products }),
}));

// ✅ Good: Use React Query for server state
function useProducts() {
return useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
});
}

3. Selective Subscriptions

Problem: Component re-renders whenever any store value changes.

Solution: Use selectors to subscribe only to values the component needs.

// ❌ Bad: Subscribe to entire store
function Component() {
const store = useUIStore(); // Re-renders on any state change
}

// ✅ Good: Subscribe to specific values
function Component() {
const sidebarOpen = useUIStore((state) => state.sidebarOpen);
}

4. Memoize Selectors

Use Case: Select multiple values from store without causing extra re-renders.

import { shallow } from 'zustand/shallow';

// For multiple values, use shallow comparison
function Component() {
const { theme, language } = useSettingsStore(
(state) => ({ theme: state.theme, language: state.language }),
shallow
);
}

5. DevTools Integration

Use Case: Debug state changes during development.

import { devtools } from 'zustand/middleware';

const useStore = create<State>()(
devtools(
(set) => ({
// ... state
}),
{ name: 'AppStore' }
)
);

6. Error Boundaries for Queries

Use Case: Gracefully handle API errors without crashing the entire page.

// src/components/QueryErrorBoundary.tsx
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';

export function QueryErrorBoundary({ children }: { children: React.ReactNode }) {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ error, resetErrorBoundary }) => (
<div>
<p>Error: {error.message}</p>
<Button onClick={resetErrorBoundary}>Retry</Button>
</div>
)}
>
{children}
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}