Shortcuts
BlogDecember 15, 2024

Mohamed Elbarry
State Management in React
You don’t need Redux (or Zustand, or Jotai) for every React app. I’ve shipped plenty of UIs with just useState, useReducer, and a bit of Context. The moment I reach for a store is when the same state is needed in many unrelated components and prop drilling or Context re-renders become a real pain. Here’s how I think about the options. When building Lumin AI's chat interface we had a lot of client state; the approach below is what I use. For component-local state and small, coherent state machines, the built-in hooks are enough. I start here and only add more when the app forces my hand. React’s useState and useReducer docs are the source of truth if you want the full API.
Tsx
function Counter() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('');

  const increment = () => setCount(prev => prev + 1);
  const decrement = () => setCount(prev => prev - 1);

  return (
    <div>
      <h2>Count: {count}</h2>
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder='Enter name'
      />
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </div>
  );
}
When one component has several pieces of state that change together, or the update logic gets noisy, I switch to useReducer. Weirdly it’s one of those things I didn’t use much at first and now I reach for it whenever the logic gets noisy—it keeps transitions explicit and testable.
Tsx
function reducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + 1 };
    case 'DECREMENT':
      return { ...state, count: state.count - 1 };
    case 'SET_NAME':
      return { ...state, name: action.payload };
    case 'RESET':
      return initialState;
    default:
      return state;
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);

  const increment = () => dispatch({ type: 'INCREMENT' });
  const decrement = () => dispatch({ type: 'DECREMENT' });
  const setName = (name) => dispatch({ type: 'SET_NAME', payload: name });

  return (
    <div>
      <h2>Count: {state.count}</h2>
      <input
        value={state.name}
        onChange={(e) => setName(e.target.value)}
        placeholder='Enter name'
      />
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </div>
  );
}
Context is fine for a single theme, locale, or auth user—stuff that’s read in many places and doesn’t change every keystroke. The catch: when the context value changes, every component that consumes that context re-renders, even if it only uses one field. So if you put user + notifications + theme in one context and only notifications update, every consumer re-renders. That’s why I put only what’s truly global in Context, keep the value stable (e.g. one object with refs or split into a few contexts), and move to a store when you have frequently changing global state or want fine-grained subscriptions (e.g. “only re-render when this slice changes”).
Tsx
const AppContext = createContext(null);

export function AppProvider({ children }) {
  const [state, dispatch] = useReducer(appReducer, initialState);

  return (
    <AppContext.Provider value={{ state, dispatch }}>{children}</AppContext.Provider>
  );
}

export function useApp() {
  const context = useContext(AppContext);
  if (!context) {
    throw new Error('useApp must be used within an AppProvider');
  }
  return context;
}
Forms, toggles, and multi-step flows often get their own hooks. That keeps components thin and the logic reusable and testable.
Tsx
export function useForm({ initialValues, validate, onSubmit }) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  const setValue = useCallback((name, value) => {
    setValues(prev => ({ ...prev, [name]: value }));
    if (errors[name]) {
      setErrors(prev => ({ ...prev, [name]: undefined }));
    }
  }, [errors]);

  const handleSubmit = useCallback(async (e) => {
    e.preventDefault();
    if (!validateForm()) return;
    setIsSubmitting(true);
    try {
      await onSubmit(values);
    } finally {
      setIsSubmitting(false);
    }
  }, [values, validateForm, onSubmit]);

  return {
    values,
    errors,
    touched,
    isSubmitting,
    setValue,
    handleSubmit
  };
}
For app-wide client state (user prefs, UI flags, cached client-side data) I use Zustand. It’s a small API, no providers, and components that don’t use a slice don’t re-render when that slice changes. Redux Toolkit is still a good fit if you want strict devtools and a single reducer tree; for most of my projects Zustand is enough.
Tsx
import { create } from 'zustand';

interface UserStore {
  user: User | null;
  setUser: (user: User | null) => void;
  logout: () => void;
}

export const useUserStore = create<UserStore>((set) => ({
  user: null,
  setUser: (user) => set({ user }),
  logout: () => set({ user: null }),
}));

function UserProfile() {
  const { user, logout } = useUserStore();

  if (!user) return <div>Please log in</div>;

  return (
    <div>
      <h2>Welcome, {user.name}!</h2>
      <button onClick={logout}>Logout</button>
    </div>
  );
}
If the team already uses Redux or you need time-travel and a single store, Redux Toolkit keeps the boilerplate down:
Tsx
import { createSlice, configureStore } from '@reduxjs/toolkit';

const usersSlice = createSlice({
  name: 'users',
  initialState: {
    users: [],
    loading: false,
    error: null
  },
  reducers: {
    addUser: (state, action) => {
      state.users.push(action.payload);
    },
    removeUser: (state, action) => {
      state.users = state.users.filter(user => user.id !== action.payload);
    }
  }
});

export const { addUser, removeUser } = usersSlice.actions;

export const store = configureStore({
  reducer: {
    users: usersSlice.reducer
  }
});
Server data is a different problem from client state. I use TanStack Query (React Query) for fetching, caching, and invalidation so the UI doesn’t re-fetch on every mount. That keeps client stores focused on real client state.
Tsx
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

function useUsers() {
  return useQuery({
    queryKey: ['users'],
    queryFn: () => fetch('/api/users').then(res => res.json()),
    staleTime: 5 * 60 * 1000, // 5 minutes
  });
}

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

  return useMutation({
    mutationFn: (userData) =>
      fetch('/api/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(userData),
      }).then(res => res.json()),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });
}
For larger client stores I keep data normalized (by id, list of ids) so updates don’t require deep clones and selectors stay simple. UI state (loading, error, current page) I keep separate from domain data.
Tsx
interface AppState {
  users: {
    byId: Record<string, User>;
    allIds: string[];
  };
  posts: {
    byId: Record<string, Post>;
    allIds: string[];
  };
  ui: {
    loading: boolean;
    error: string | null;
    currentPage: number;
  };
}
Start with useState and useReducer. Use Context only for a few global values and keep the provider value stable. Add Zustand (or similar) when many unrelated components need the same client state. Use TanStack Query for server data so you don’t overload your client store. One thing to try: if you have a “global” Context that updates often, see whether moving that slice into Zustand reduces unnecessary re-renders. Zustand and TanStack Query docs are good next reads. Hope that helps. I'm currently looking for new challenges in the AI and Full Stack space. If you're building something interesting, let's chat.
Share this post: