Skip to content
Hiren Limbasiya
Email me
Writing

Frontend

Five Kinds of React State (and the Right Tool for Each)

URL, server, form, UI, and global state are five different problems. Which tool each one wants, when Redux Toolkit earns its place, and when a custom hook beats both.

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 and 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

Written by Hiren Limbasiya, tech lead and full-stack engineer. Questions or corrections are welcome.