SKIP TO CONTENT
HOME/LEARN/FRONTEND
REDUXCONTEXT API

STATE MANAGEMENT AT SCALE

Redux Toolkit patterns, Context API tradeoffs, and when to use which approach.

The State Management Spectrum

Not all state is equal. URL state (React Router), server state (React Query/SWR), form state (react-hook-form), UI state (local useState), and global app state (Redux/Zustand) each have optimal tools. The mistake is using one tool for everything.

Redux Toolkit — When & Why

Use RTK when you have complex state logic shared across many components, need time-travel debugging, or want predictable state updates in a team.

tsx
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

// Async thunk for API calls
export const fetchProjects = createAsyncThunk(
  'projects/fetch',
  async (orgId: string) => {
    const res = await api.get(`/orgs/${orgId}/projects`);
    return res.data;
  }
);

const projectsSlice = createSlice({
  name: 'projects',
  initialState: { items: [], status: 'idle' } as ProjectState,
  reducers: {
    projectUpdated(state, action) {
      const idx = state.items.findIndex(p => p.id === action.payload.id);
      if (idx !== -1) state.items[idx] = action.payload;
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchProjects.pending, (state) => { state.status = 'loading'; })
      .addCase(fetchProjects.fulfilled, (state, action) => {
        state.status = 'idle';
        state.items = action.payload;
      });
  },
});

Custom Hooks as State Machines

Sometimes the best state management is a well-designed custom hook that encapsulates complex logic and is testable in isolation.

tsx
// Track dirty state by diffing current vs saved config
function useFormDiff<T extends Record<string, unknown>>(saved: T) {
  const [current, setCurrent] = useState<T>(saved);

  const dirtyFields = useMemo(() => {
    const dirty = new Set<string>();
    for (const key of Object.keys(current)) {
      if (JSON.stringify(current[key]) !== JSON.stringify(saved[key])) {
        dirty.add(key);
      }
    }
    return dirty;
  }, [current, saved]);

  const isDirty = dirtyFields.size > 0;
  const reset = useCallback(() => setCurrent(saved), [saved]);

  return { current, setCurrent, dirtyFields, isDirty, reset };
}

// Usage
const { current, setCurrent, isDirty } = useFormDiff(savedConfig);
// Show blinking save indicator when isDirty === true