React Query changed how I think about state — and I have not used Redux since
← Back
April 2, 2026React7 min read

React Query changed how I think about state — and I have not used Redux since

Published April 2, 20267 min read

I used Redux for three years. Not because I loved it — nobody loves the boilerplate — but because I did not have a better mental model. Then I read the React Query docs and found a sentence that changed how I think about frontend state: "Most of the state that applications need to manage is actually server state." That reframing made Redux feel like the wrong tool for the job I was using it for.

The distinction that changes everything

React Query draws a hard line between two kinds of state that most apps blur together:

Server state — data that lives on the server. Your app fetches a copy, but the server owns it. It can change at any time (another user edits it, a background job updates it). Examples: user profile, product list, order history, notifications.

Client state — data that only exists in the browser. No server equivalent. Examples: sidebar open/closed, selected tab, modal visibility, form draft, theme preference.

Redux treats both the same way: put everything in a global store, write reducers, dispatch actions. That works, but it means you spend a lot of effort managing things that Redux was not designed for — cache invalidation, background refetching, loading/error states, deduplication of concurrent requests.

What the Redux version looked like

typescript
// Redux slice for user data (before)
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

export const fetchUser = createAsyncThunk('user/fetch', async (userId: string) => {
  const res = await fetch(`/api/users/${userId}`);
  return res.json();
});

const userSlice = createSlice({
  name: 'user',
  initialState: { data: null, loading: false, error: null } as UserState,
  reducers: {
    clearUser: (state) => { state.data = null; },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => { state.loading = true; state.error = null; })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.loading = false;
        state.data = action.payload;
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message ?? 'Unknown error';
      });
  },
});

// Component
function UserProfile({ userId }: { userId: string }) {
  const dispatch = useDispatch();
  const { data: user, loading, error } = useSelector((s: RootState) => s.user);

  useEffect(() => {
    dispatch(fetchUser(userId));
  }, [userId, dispatch]);

  if (loading) return <Spinner />;
  if (error) return <Error message={error} />;
  if (!user) return null;
  return <div>{user.name}</div>;
}

That is a lot of code for "fetch a user and show it". And it does not handle: cache invalidation when the user updates their profile, background refetch when the window regains focus, deduplication if two components request the same user at the same time, or stale data showing briefly on navigation.

The React Query version

typescript
// React Query (after)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

function useUser(userId: string) {
  return useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
    staleTime: 60_000, // consider fresh for 1 minute
  });
}

function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading, error } = useUser(userId);

  if (isLoading) return <Spinner />;
  if (error) return <Error message={error.message} />;
  if (!user) return null;
  return <div>{user.name}</div>;
}

Thirty lines down to ten. And React Query gives you cache invalidation, background refetch, window focus refetch, and request deduplication by default.

Mutations and cache invalidation

The pattern that sold me completely: updating data and invalidating the cache.

typescript
function useUpdateUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (data: UpdateUserInput) =>
      fetch('/api/users/me', {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
      }).then(r => r.json()),

    onSuccess: (updatedUser) => {
      // Update the cache immediately — no refetch needed
      queryClient.setQueryData(['user', updatedUser.id], updatedUser);

      // Or just invalidate and let it refetch
      // queryClient.invalidateQueries({ queryKey: ['user'] });
    },
  });
}

function EditProfileForm() {
  const { mutate, isPending } = useUpdateUser();

  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      mutate({ name: e.currentTarget.name.value });
    }}>
      <input name="name" />
      <button disabled={isPending}>Save</button>
    </form>
  );
}

What I still use for client state

React Query only handles server state. For genuine client state — modal open/closed, selected tab, theme — I now use useState for component-local state, and Zustand for global client state that multiple components share.

typescript
// Zustand for client-only global state
import { create } from 'zustand';

interface UIStore {
  sidebarOpen: boolean;
  activeTab: string;
  toggleSidebar: () => void;
  setActiveTab: (tab: string) => void;
}

const useUIStore = create<UIStore>((set) => ({
  sidebarOpen: true,
  activeTab: 'overview',
  toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
  setActiveTab: (tab) => set({ activeTab: tab }),
}));

Zustand has no boilerplate (no reducers, no actions, no dispatch), handles React Strict Mode correctly, and works with React DevTools. It is everything Redux was trying to be for client state, without the ceremony.

The mental model shift

The real value of React Query is not the API — it is the forced clarity. When you reach for useQuery, you ask: "Is this data from the server?" When you reach for useState or Zustand, you ask: "Is this only in the browser?" The answer to those two questions guides every state decision in the app.

Before this mental model, everything went into Redux because Redux was "the state solution". The store became a mixed bag of cached API responses and UI flags, with reducers fighting over the same shape. Separating the two concerns by tool — React Query for server state, Zustand for client state — makes both simpler.

I have not opened a Redux file in eight months. I do not miss the boilerplate.

Share this
← All Posts7 min read