Sifen.
all posts
Apr 22, 20267 min readreactstatedata-fetching

Redux Toolkit and TanStack Query are not the same job

Most React apps don't need Redux. The ones that do, also need TanStack Query, and the line between them is where bugs live.

Two problems hiding under one name

Most apps that ship Redux do it because they confused two different jobs. There is client state, the things your user pokes at: a modal that is open, a draft they are typing, a multi-step form, a theme. Then there is server state, the things you fetched: the list of posts, a user profile, the cart. The first is yours to invent. The second is a cache of something a server owns.

If you treat them the same, you spend the rest of the project paying for it. You will write thunks that fetch, normalize, store, invalidate, and refetch, and you will write reducers that mutate the same store with optimistic updates from twelve different places. Every bug will be a race between those two.

What each tool is actually good at

Redux Toolkit is small, predictable, and unbeatable for client state. It gives you createSlice, createListenerMiddleware, and a single source of truth your components can subscribe to. It has none of the machinery you need for caches: no staleTime, no request deduping, no background refetch, no enabled gates. Building those yourself is the path that ends with a 4,000-line apiSlice.ts.

TanStack Query is the opposite. It has every cache primitive you wanted and zero opinions about your client state. It does not care if your modal is open. It cares whether a query key is fresh, who is subscribed to it, and what should happen when the window refocuses.

You want both. The trick is keeping them on their own side of the line.

A line you can actually draw

Here is the rule I use, and it has held up across half a dozen apps:

If you can answer the question by re-running the network request, it is server state. If the answer is something only this browser tab knows, it is client state.

Test it on real cases:

  • The list of posts? Server.
  • The "is the comment box expanded" flag? Client.
  • The searchTerm typed into a filter input? Client until you submit, server when the URL changes.
  • The cart? Mostly server, but with optimistic mutations.
  • A "you have unsaved changes" warning? Pure client.

Once you have this line, the architecture follows. Server state goes into TanStack Query under stable, hierarchical keys (['posts', { authorId, status }]). Client state goes into Redux slices, and slices have no idea fetching exists.

What you stop writing

The clearest sign you have it right is the things you stop needing to write.

  • No more useEffect(() => { fetch() }, []). TanStack Query owns that pattern.
  • No more "is loading", "has loaded", "had an error" booleans hand-rolled per slice. You read isPending, isError, data off the query result.
  • No more thunks that exist only to write the response into a slice and read it back two components away.
  • No more requestId tokens to ignore stale responses, because the cache key already does that.

The Redux slices that survive get small and boring, which is what you wanted. They look like uiSlice, draftSlice, composerSlice. Each is a few dozen lines. The data layer is in queries and mutations, where it belongs.

When you need them to talk

Sometimes a client action needs to invalidate server state, or a server response needs to seed client state. Both happen, and both have one-line answers:

const queryClient = useQueryClient();

const submit = useMutation({
  mutationFn: (input: ComposerDraft) => api.posts.create(input),
  onSuccess: () => {
    dispatch(composerActions.reset());
    queryClient.invalidateQueries({ queryKey: ["posts"] });
  },
});

The mutation is server, the reset is client, and they meet in onSuccess. That's the only place they need to know about each other.

The data fetching playbook

Once the line is drawn, the playbook is short:

  1. Every server resource gets a query hook in a queries/ folder. It owns its key shape and its select transforms.
  2. Every mutation gets a sibling hook that calls invalidateQueries for the keys it touches, plus optional optimistic updates with onMutate/onError rollback.
  3. Slices in Redux only own things you cannot ask the network. They never store a copy of a query result.
  4. Page components consume both. They do not coordinate cache invalidation themselves; mutations do that.

Follow that and the data layer mostly disappears. It becomes the kind of thing reviewers stop commenting on, because there is nothing to flag. That, more than any benchmark, is the win.