Skip to content
Sudip KC writing notes in a journal at a desk
SK.
← All articles

Opinion · · 6 min read

You Probably Don't Need Redux

  • React
  • Architecture
  • TypeScript
  • Opinion
  • Next.js

Most React apps I review that use Redux aren't managing complex client state. They're caching server data by hand, with a lot of ceremony. Take that job away and the store that's left is usually small enough to delete.

I want to be careful with the title. Redux isn't bad. Redux Toolkit is a well-designed library, and there are apps where it's the right call. My claim is narrower: for the kind of apps most of us build, dashboards, admin panels, booking flows, content sites, a global store is often solving a problem you don't have anymore.

What Redux was solving

When Redux took off, React didn't have a good built-in answer for sharing state across a tree. Context was awkward, hooks didn't exist, and fetching data meant writing your own loading flags, caching and error handling. Redux gave us one predictable place for all of it, with great devtools.

That was a real improvement at the time. But look at what most stores I've seen actually contain:

  • orders, isLoadingOrders, ordersError
  • menuItems, isLoadingMenu, menuError
  • user, isLoadingUser
  • isSidebarOpen
  • selectedTableId

Almost all of that is server state: data that lives in a database, that we've copied into the browser and now have to keep fresh. Only the last two are truly client state.

Server state is a different problem

Server state has needs that a generic store doesn't handle for you. It goes stale. Other users change it. It needs deduplication when three components ask for the same thing, background refetching, retries, pagination, and cache invalidation after a mutation.

You can build all of that in Redux. People do, with thunks, sagas, normalised entities and hand-written invalidation. Or you can use a tool built for exactly that job. I use TanStack Query on nearly every React project:

export function useOrders(tableId: string) {
  return useQuery({
    queryKey: ["orders", tableId],
    queryFn: () => api.get<Order[]>(`/tables/${tableId}/orders/`),
    staleTime: 30_000,
  });
}

export function useAddItem(tableId: string) {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (item: NewOrderItem) => api.post(`/tables/${tableId}/orders/`, item),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["orders", tableId] }),
  });
}

That's the whole data layer for this feature. Loading, error, caching, deduplication and refetching are handled. No action types, no reducer, no selector, no slice. If you've ever written FETCH_ORDERS_REQUEST, FETCH_ORDERS_SUCCESS and FETCH_ORDERS_FAILURE, you know how much code that replaces.

To be fair, Redux Toolkit has RTK Query, which solves the same problem well. If you're already deep in Redux, that's a sensible path. But it reinforces my point: the hard part was never the global store. It was server caching.

Once server data has a proper home, most "global state" turns out to be a couple of booleans.

Next.js shrank the problem further

With the App Router, a lot of data doesn't touch client state at all. A Server Component fetches on the server and renders HTML. There's nothing to store, sync or invalidate on the client for that first render. I went into that in Why I Use Next.js for Most of My Modern Web Projects.

So on a typical project now, data flows like this: Server Components for the initial page, TanStack Query for anything live or user-driven, and local state for the rest.

What's left is usually small

After moving server data out, here's what most apps actually need on the client:

  • UI state for one component: use useState. A dropdown being open isn't global.
  • State shared by a few nearby components: lift it to their common parent.
  • State that belongs in the URL: filters, tabs, pagination, the selected item. Put it in search params. It survives refresh, works with the back button, and makes links shareable.
  • Form state: a form library or plain controlled inputs. Forms don't belong in a global store.
  • A few truly app-wide values: theme, current user, a toast queue. Context handles these fine because they change rarely.

The URL point is the one I wish I'd learned earlier. A dashboard with filters in Redux loses them on refresh. The same filters in the URL just work, and the support conversation "can you send me the link to what you're seeing" becomes possible.

When something in between helps

Sometimes there is genuine shared client state that changes often, and Context causes too many re-renders. Say a POS screen where the cart, the selected table and the active discount are used by many components across the page.

For that, I reach for a small store before Redux. Zustand is a good fit here: a hook, no providers, and components subscribe only to what they use.

import { create } from "zustand";

type CartState = {
  items: CartItem[];
  add: (item: CartItem) => void;
  clear: () => void;
};

export const useCart = create<CartState>((set) => ({
  items: [],
  add: (item) => set((s) => ({ items: [...s.items, item] })),
  clear: () => set({ items: [] }),
}));

It's small enough that a junior developer can read the whole store in a minute, which matters to me when I'm reviewing code and onboarding interns.

When Redux is the right choice

I'd still pick Redux Toolkit in some situations:

  • Heavy client-side state with complex transitions, like an editor, a canvas tool, or an offline-first app where the client is the source of truth for a while.
  • You need time-travel debugging or a strict audit of every state change.
  • A large team already knows it well, and the codebase is organised around it. Rewriting working code to follow fashion is a bad trade.
  • Many independent features need to react to the same events, and the action-based model genuinely fits.

If that describes your app, use it and don't feel bad. My problem isn't Redux. It's reaching for it by default on day one, before anyone knows what state the app will have.

How I decide on a new project

I start with nothing global and add only when there's pain:

  1. Is this data from the server? Use Server Components or TanStack Query.
  2. Should it survive a refresh or be shareable? Put it in the URL.
  3. Is it used by one component, or a few close together? useState and lifting.
  4. Is it app-wide and rarely changing? Context.
  5. Is it shared, frequently changing and causing re-render problems? A small store.
  6. Is it complex, event-driven client state at scale? Now consider Redux Toolkit.

Most projects stop at step four. That's not because they're simple. It's because the data mostly lives on the server, where it belongs. On my React and Django projects, the backend owns the rules and the data. I cover that split in How I Structure React + Django Projects.

Where I've landed

Redux taught a generation of React developers to think about state carefully, and that lesson is still valuable. But the tools changed. Server Components, TanStack Query, URL state and tiny stores now cover what most apps used Redux for, with less code and fewer concepts.

So before you add a global store, ask what's actually going in it. If the answer is mostly API responses and loading flags, you don't need Redux. You need a server cache, and the rest will fit in useState.