Skip to content
Home / Skills / React / State Management
RE

State Management

React core v1.0.0

React State Management

Overview

State management in React ranges from local component state to global application state. This skill covers useState, useReducer, Context API, and external state libraries like Zustand and Redux Toolkit.


Key Concepts

State Management Hierarchy

┌─────────────────────────────────────────────────────────────┐
│                  State Management Spectrum                   │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  Scope               │ Solution          │ Use Case         │
│  ────────────────────┼───────────────────┼─────────────────│
│  Component           │ useState          │ UI state, forms  │
│  Component (complex) │ useReducer        │ Multi-field forms│
│  Parent-Child        │ Props             │ Direct passing   │
│  Subtree             │ Context           │ Theme, auth      │
│  Feature             │ Zustand slice     │ Feature state    │
│  Global              │ Redux/Zustand     │ App-wide state   │
│  Server              │ React Query       │ API data         │
│  URL                 │ Router state      │ Navigation       │
│                                                              │
│  State Categories:                                          │
│  ┌─────────────────────────────────────────────────────┐   │
│  │                                                      │   │
│  │  Server State ────────▶ React Query / SWR           │   │
│  │  • Cached, async                                     │   │
│  │  • Has loading/error states                          │   │
│  │  • Owned by server                                   │   │
│  │                                                      │   │
│  │  Client State ────────▶ Zustand / Redux / Context   │   │
│  │  • Synchronous                                       │   │
│  │  • Owned by client                                   │   │
│  │  • UI state, preferences                             │   │
│  │                                                      │   │
│  │  URL State ───────────▶ React Router                │   │
│  │  • Shareable, bookmarkable                          │   │
│  │  • Filters, pagination                              │   │
│  │                                                      │   │
│  │  Form State ──────────▶ React Hook Form             │   │
│  │  • Validation, submission                           │   │
│  │  • Performance optimized                            │   │
│  │                                                      │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Best Practices

1. Colocate State

Keep state as close to where it’s used as possible.

2. Separate Server and Client State

Use React Query for server state, local solutions for UI state.

3. Derive State When Possible

Compute values from existing state instead of storing redundantly.

4. Use Immutable Updates

Never mutate state directly; create new references.

5. Normalize Complex Data

Use normalized structures for entity-heavy data.


Code Examples

Example 1: useReducer for Complex State

import { useReducer, useCallback } from 'react';

// State type
interface FormState {
  values: Record<string, string>;
  errors: Record<string, string>;
  touched: Record<string, boolean>;
  isSubmitting: boolean;
  isValid: boolean;
}

// Action types
type FormAction =
  | { type: 'SET_VALUE'; field: string; value: string }
  | { type: 'SET_ERROR'; field: string; error: string }
  | { type: 'SET_TOUCHED'; field: string }
  | { type: 'SUBMIT_START' }
  | { type: 'SUBMIT_SUCCESS' }
  | { type: 'SUBMIT_ERROR'; errors: Record<string, string> }
  | { type: 'RESET' };

// Reducer
function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case 'SET_VALUE':
      return {
        ...state,
        values: { ...state.values, [action.field]: action.value },
        errors: { ...state.errors, [action.field]: '' },
      };

    case 'SET_ERROR':
      return {
        ...state,
        errors: { ...state.errors, [action.field]: action.error },
        isValid: false,
      };

    case 'SET_TOUCHED':
      return {
        ...state,
        touched: { ...state.touched, [action.field]: true },
      };

    case 'SUBMIT_START':
      return { ...state, isSubmitting: true };

    case 'SUBMIT_SUCCESS':
      return { ...state, isSubmitting: false };

    case 'SUBMIT_ERROR':
      return {
        ...state,
        isSubmitting: false,
        errors: action.errors,
        isValid: false,
      };

    case 'RESET':
      return initialState;

    default:
      return state;
  }
}

const initialState: FormState = {
  values: {},
  errors: {},
  touched: {},
  isSubmitting: false,
  isValid: true,
};

// Custom hook
function useForm<T extends Record<string, string>>(
  initialValues: T,
  validate: (values: T) => Record<string, string>
) {
  const [state, dispatch] = useReducer(formReducer, {
    ...initialState,
    values: initialValues,
  });

  const setValue = useCallback((field: keyof T, value: string) => {
    dispatch({ type: 'SET_VALUE', field: String(field), value });
  }, []);

  const setTouched = useCallback((field: keyof T) => {
    dispatch({ type: 'SET_TOUCHED', field: String(field) });
  }, []);

  const handleSubmit = useCallback(
    async (onSubmit: (values: T) => Promise<void>) => {
      dispatch({ type: 'SUBMIT_START' });

      const errors = validate(state.values as T);
      if (Object.keys(errors).length > 0) {
        dispatch({ type: 'SUBMIT_ERROR', errors });
        return;
      }

      try {
        await onSubmit(state.values as T);
        dispatch({ type: 'SUBMIT_SUCCESS' });
      } catch (error) {
        dispatch({
          type: 'SUBMIT_ERROR',
          errors: { form: 'Submission failed' },
        });
      }
    },
    [state.values, validate]
  );

  const reset = useCallback(() => {
    dispatch({ type: 'RESET' });
  }, []);

  return {
    values: state.values as T,
    errors: state.errors,
    touched: state.touched,
    isSubmitting: state.isSubmitting,
    isValid: state.isValid,
    setValue,
    setTouched,
    handleSubmit,
    reset,
  };
}

// Usage
function SignupForm() {
  const { values, errors, isSubmitting, setValue, handleSubmit } = useForm(
    { email: '', password: '', name: '' },
    (values) => {
      const errors: Record<string, string> = {};
      if (!values.email.includes('@')) errors.email = 'Invalid email';
      if (values.password.length < 8) errors.password = 'Too short';
      return errors;
    }
  );

  return (
    <form onSubmit={(e) => { e.preventDefault(); handleSubmit(submitFn); }}>
      <input
        value={values.email}
        onChange={(e) => setValue('email', e.target.value)}
      />
      {errors.email && <span className="error">{errors.email}</span>}
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Submitting...' : 'Sign Up'}
      </button>
    </form>
  );
}

Example 2: Context with Optimized Rendering

import React, { createContext, useContext, useReducer, useMemo, ReactNode } from 'react';

// Split context to prevent unnecessary re-renders
interface AuthState {
  user: User | null;
  isLoading: boolean;
  error: string | null;
}

interface AuthActions {
  login: (credentials: Credentials) => Promise<void>;
  logout: () => void;
  refreshToken: () => Promise<void>;
}

const AuthStateContext = createContext<AuthState | null>(null);
const AuthActionsContext = createContext<AuthActions | null>(null);

// Provider
function AuthProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(authReducer, initialAuthState);

  // Memoize actions to prevent re-creation
  const actions = useMemo<AuthActions>(
    () => ({
      login: async (credentials) => {
        dispatch({ type: 'LOGIN_START' });
        try {
          const user = await authAPI.login(credentials);
          dispatch({ type: 'LOGIN_SUCCESS', user });
        } catch (error) {
          dispatch({ type: 'LOGIN_ERROR', error: error.message });
        }
      },
      logout: () => {
        authAPI.logout();
        dispatch({ type: 'LOGOUT' });
      },
      refreshToken: async () => {
        try {
          const user = await authAPI.refreshToken();
          dispatch({ type: 'REFRESH_SUCCESS', user });
        } catch (error) {
          dispatch({ type: 'LOGOUT' });
        }
      },
    }),
    []
  );

  return (
    <AuthStateContext.Provider value={state}>
      <AuthActionsContext.Provider value={actions}>
        {children}
      </AuthActionsContext.Provider>
    </AuthStateContext.Provider>
  );
}

// Hooks
function useAuthState() {
  const context = useContext(AuthStateContext);
  if (!context) throw new Error('useAuthState must be within AuthProvider');
  return context;
}

function useAuthActions() {
  const context = useContext(AuthActionsContext);
  if (!context) throw new Error('useAuthActions must be within AuthProvider');
  return context;
}

// Selector hook for specific state
function useUser() {
  const { user } = useAuthState();
  return user;
}

function useIsAuthenticated() {
  const { user, isLoading } = useAuthState();
  return { isAuthenticated: !!user, isLoading };
}

// Usage - only re-renders when relevant state changes
function UserAvatar() {
  const user = useUser(); // Only re-renders when user changes
  if (!user) return null;
  return <img src={user.avatar} alt={user.name} />;
}

function LoginButton() {
  const { login } = useAuthActions(); // Never re-renders from state changes
  const { isAuthenticated } = useIsAuthenticated();

  if (isAuthenticated) return null;
  return <button onClick={() => login({ email: '', password: '' })}>Login</button>;
}

Example 3: Zustand Store

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

// Define store types
interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

interface CartState {
  items: CartItem[];
  isOpen: boolean;
}

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

type CartStore = CartState & CartActions;

// Create store with middleware
const useCartStore = create<CartStore>()(
  devtools(
    persist(
      immer((set, get) => ({
        // State
        items: [],
        isOpen: false,

        // Actions
        addItem: (item) =>
          set((state) => {
            const existing = state.items.find((i) => i.id === item.id);
            if (existing) {
              existing.quantity += 1;
            } else {
              state.items.push({ ...item, quantity: 1 });
            }
          }),

        removeItem: (id) =>
          set((state) => {
            state.items = state.items.filter((i) => i.id !== id);
          }),

        updateQuantity: (id, quantity) =>
          set((state) => {
            const item = state.items.find((i) => i.id === id);
            if (item) {
              if (quantity <= 0) {
                state.items = state.items.filter((i) => i.id !== id);
              } else {
                item.quantity = quantity;
              }
            }
          }),

        clearCart: () =>
          set((state) => {
            state.items = [];
          }),

        toggleCart: () =>
          set((state) => {
            state.isOpen = !state.isOpen;
          }),
      })),
      { name: 'cart-storage' }
    ),
    { name: 'CartStore' }
  )
);

// Derived state selectors
const selectItemCount = (state: CartStore) =>
  state.items.reduce((sum, item) => sum + item.quantity, 0);

const selectTotal = (state: CartStore) =>
  state.items.reduce((sum, item) => sum + item.price * item.quantity, 0);

// Usage with selectors
function CartIcon() {
  const count = useCartStore(selectItemCount);
  const toggle = useCartStore((state) => state.toggleCart);

  return (
    <button onClick={toggle}>
      Cart ({count})
    </button>
  );
}

function CartTotal() {
  const total = useCartStore(selectTotal);
  return <div>Total: ${total.toFixed(2)}</div>;
}

function CartItemList() {
  const items = useCartStore((state) => state.items);
  const updateQuantity = useCartStore((state) => state.updateQuantity);
  const removeItem = useCartStore((state) => state.removeItem);

  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>
          {item.name} - ${item.price} x {item.quantity}
          <button onClick={() => updateQuantity(item.id, item.quantity - 1)}>
            -
          </button>
          <button onClick={() => updateQuantity(item.id, item.quantity + 1)}>
            +
          </button>
          <button onClick={() => removeItem(item.id)}>Remove</button>
        </li>
      ))}
    </ul>
  );
}

Example 4: React Query for Server State

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

// Query keys factory
const userKeys = {
  all: ['users'] as const,
  lists: () => [...userKeys.all, 'list'] as const,
  list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
  details: () => [...userKeys.all, 'detail'] as const,
  detail: (id: string) => [...userKeys.details(), id] as const,
};

// API functions
async function fetchUsers(filters: UserFilters): Promise<User[]> {
  const params = new URLSearchParams(filters as any);
  const response = await fetch(`/api/users?${params}`);
  if (!response.ok) throw new Error('Failed to fetch users');
  return response.json();
}

async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) throw new Error('Failed to fetch user');
  return response.json();
}

async function updateUser(id: string, data: Partial<User>): Promise<User> {
  const response = await fetch(`/api/users/${id}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });
  if (!response.ok) throw new Error('Failed to update user');
  return response.json();
}

// Custom hooks
function useUsers(filters: UserFilters) {
  return useQuery({
    queryKey: userKeys.list(filters),
    queryFn: () => fetchUsers(filters),
    staleTime: 5 * 60 * 1000, // 5 minutes
  });
}

function useUser(id: string) {
  return useQuery({
    queryKey: userKeys.detail(id),
    queryFn: () => fetchUser(id),
    staleTime: 5 * 60 * 1000,
    enabled: !!id,
  });
}

function useUpdateUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ id, data }: { id: string; data: Partial<User> }) =>
      updateUser(id, data),
      
    // Optimistic update
    onMutate: async ({ id, data }) => {
      // Cancel outgoing queries
      await queryClient.cancelQueries({ queryKey: userKeys.detail(id) });

      // Snapshot previous value
      const previousUser = queryClient.getQueryData<User>(userKeys.detail(id));

      // Optimistically update
      if (previousUser) {
        queryClient.setQueryData(userKeys.detail(id), {
          ...previousUser,
          ...data,
        });
      }

      return { previousUser };
    },

    // Rollback on error
    onError: (err, { id }, context) => {
      if (context?.previousUser) {
        queryClient.setQueryData(userKeys.detail(id), context.previousUser);
      }
    },

    // Invalidate on success
    onSettled: (_, __, { id }) => {
      queryClient.invalidateQueries({ queryKey: userKeys.detail(id) });
      queryClient.invalidateQueries({ queryKey: userKeys.lists() });
    },
  });
}

// Usage
function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading, error } = useUser(userId);
  const updateUser = useUpdateUser();

  if (isLoading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  if (!user) return null;

  return (
    <div>
      <h1>{user.name}</h1>
      <button
        onClick={() =>
          updateUser.mutate({
            id: userId,
            data: { name: 'Updated Name' },
          })
        }
        disabled={updateUser.isPending}
      >
        {updateUser.isPending ? 'Updating...' : 'Update Name'}
      </button>
    </div>
  );
}

Example 5: URL State Management

import { useSearchParams, useNavigate } from 'react-router-dom';
import { useMemo, useCallback } from 'react';

// Custom hook for URL-based state
interface FilterState {
  search: string;
  category: string;
  sortBy: 'date' | 'price' | 'name';
  sortOrder: 'asc' | 'desc';
  page: number;
  limit: number;
}

const defaultFilters: FilterState = {
  search: '',
  category: '',
  sortBy: 'date',
  sortOrder: 'desc',
  page: 1,
  limit: 20,
};

function useUrlFilters() {
  const [searchParams, setSearchParams] = useSearchParams();

  const filters = useMemo<FilterState>(() => ({
    search: searchParams.get('search') || defaultFilters.search,
    category: searchParams.get('category') || defaultFilters.category,
    sortBy: (searchParams.get('sortBy') as FilterState['sortBy']) || defaultFilters.sortBy,
    sortOrder: (searchParams.get('sortOrder') as FilterState['sortOrder']) || defaultFilters.sortOrder,
    page: parseInt(searchParams.get('page') || String(defaultFilters.page), 10),
    limit: parseInt(searchParams.get('limit') || String(defaultFilters.limit), 10),
  }), [searchParams]);

  const setFilters = useCallback(
    (updates: Partial<FilterState>) => {
      setSearchParams((prev) => {
        const newParams = new URLSearchParams(prev);

        Object.entries(updates).forEach(([key, value]) => {
          if (value === defaultFilters[key as keyof FilterState] || value === '') {
            newParams.delete(key);
          } else {
            newParams.set(key, String(value));
          }
        });

        // Reset page when other filters change
        if (!('page' in updates)) {
          newParams.delete('page');
        }

        return newParams;
      });
    },
    [setSearchParams]
  );

  const resetFilters = useCallback(() => {
    setSearchParams(new URLSearchParams());
  }, [setSearchParams]);

  return { filters, setFilters, resetFilters };
}

// Usage
function ProductList() {
  const { filters, setFilters, resetFilters } = useUrlFilters();
  const { data: products, isLoading } = useProducts(filters);

  return (
    <div>
      <input
        value={filters.search}
        onChange={(e) => setFilters({ search: e.target.value })}
        placeholder="Search..."
      />

      <select
        value={filters.sortBy}
        onChange={(e) => setFilters({ sortBy: e.target.value as FilterState['sortBy'] })}
      >
        <option value="date">Date</option>
        <option value="price">Price</option>
        <option value="name">Name</option>
      </select>

      <button onClick={resetFilters}>Reset Filters</button>

      {isLoading ? (
        <Spinner />
      ) : (
        <ProductGrid products={products} />
      )}

      <Pagination
        page={filters.page}
        onPageChange={(page) => setFilters({ page })}
      />
    </div>
  );
}

Anti-Patterns

❌ Storing Derived State

// WRONG - storing computed values
const [items, setItems] = useState<Item[]>([]);
const [total, setTotal] = useState(0);
// Must manually keep total in sync

// ✅ CORRECT - derive from source state
const [items, setItems] = useState<Item[]>([]);
const total = useMemo(() => items.reduce((sum, i) => sum + i.price, 0), [items]);

❌ Mutating State Directly

// WRONG - mutation
state.items.push(newItem);
setState(state);

// ✅ CORRECT - immutable update
setState({ ...state, items: [...state.items, newItem] });

References