Every React project I built between 2019 and 2023 started the same way: install Redux Toolkit, create a store, write slices for auth, write slices for UI state, write slices for API data, write thunks to fetch that API data, write selectors to read it back out. Hundreds of lines of boilerplate before a single feature was done.
Then I looked at what those slices actually contained. Most of them were just caching server responses. A usersSlice that fetched users from the API and stored them. An ordersSlice that did the same. Loading states, error states, stale data checks -- all reinvented per slice.
That's when it clicked: 70-80% of what I called "state" was actually server data. And Redux is the wrong tool for server data.
The Two Kinds of State
Client state and server state have fundamentally different problems.
Server state is data that lives on your backend. It's async, it can become stale, other users can change it, and you need strategies for caching, refetching, and invalidation. Think: user profiles, dashboard metrics, order lists, notifications.
Client state is data that exists only in the browser. It's synchronous, only you can change it, and it disappears on refresh (unless you persist it). Think: is the sidebar open, which theme is selected, is the user authenticated.
Redux treats both the same way. That's why you end up writing so much code -- you're manually solving caching, staleness, and synchronization problems that a purpose-built tool handles automatically.
TanStack Query for Server State
TanStack Query (formerly React Query) manages everything about server data: fetching, caching, background refetching, invalidation, and optimistic updates. Here's a dashboard that fetches metrics:
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
interface DashboardMetrics {
revenue: number;
activeUsers: number;
conversionRate: number;
recentOrders: Order[];
}
function useDashboardMetrics() {
return useQuery({
queryKey: ["dashboard", "metrics"],
queryFn: async (): Promise<DashboardMetrics> => {
const res = await fetch("/api/dashboard/metrics");
if (!res.ok) throw new Error("Failed to fetch metrics");
return res.json();
},
staleTime: 30_000, // consider fresh for 30 seconds
refetchInterval: 60_000, // poll every minute
});
}
function DashboardPage() {
const { data, isLoading, error } = useDashboardMetrics();
if (isLoading) return <LoadingSkeleton />;
if (error) return <ErrorMessage error={error} />;
return (
<div>
<MetricCard label="Revenue" value={data.revenue} />
<MetricCard label="Active Users" value={data.activeUsers} />
<MetricCard label="Conversion" value={data.conversionRate} />
<OrderTable orders={data.recentOrders} />
</div>
);
}
No slice, no reducer, no action creator, no thunk. The data is fetched, cached, and automatically refetched when it goes stale. If the user switches tabs and comes back, TanStack Query refetches in the background. If two components use the same queryKey, only one request fires.
Mutations with optimistic updates are just as clean:
function useUpdateOrderStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ orderId, status }: { orderId: string; status: string }) => {
const res = await fetch(`/api/orders/${orderId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
});
return res.json();
},
onMutate: async ({ orderId, status }) => {
await queryClient.cancelQueries({ queryKey: ["dashboard", "metrics"] });
const previous = queryClient.getQueryData(["dashboard", "metrics"]);
queryClient.setQueryData(["dashboard", "metrics"], (old: DashboardMetrics) => ({
...old,
recentOrders: old.recentOrders.map((o) =>
o.id === orderId ? { ...o, status } : o
),
}));
return { previous };
},
onError: (_err, _vars, context) => {
queryClient.setQueryData(["dashboard", "metrics"], context?.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["dashboard", "metrics"] });
},
});
}
The UI updates instantly, rolls back on error, and refetches the real data afterward. Try doing that in Redux without 50 lines of action/reducer ceremony.
Zustand for Client State
After TanStack Query handles server data, what's left? Auth tokens, UI preferences, feature flags, maybe a sidebar toggle. That's Zustand territory.
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface AuthState {
token: string | null;
user: { id: string; email: string; role: string } | null;
login: (token: string, user: AuthState["user"]) => void;
logout: () => void;
}
const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
user: null,
login: (token, user) => set({ token, user }),
logout: () => set({ token: null, user: null }),
}),
{ name: "auth-storage" }
)
);
interface UIState {
sidebarOpen: boolean;
theme: "light" | "dark";
toggleSidebar: () => void;
setTheme: (theme: "light" | "dark") => void;
}
const useUIStore = create<UIState>()(
persist(
(set) => ({
sidebarOpen: true,
theme: "light",
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
setTheme: (theme) => set({ theme }),
}),
{ name: "ui-preferences" }
)
);
That's it. Two stores, fully typed, with localStorage persistence. Use them in any component:
function Sidebar() {
const { sidebarOpen, toggleSidebar } = useUIStore();
const user = useAuthStore((s) => s.user);
if (!sidebarOpen) return null;
return (
<nav>
<span>{user?.email}</span>
<button onClick={toggleSidebar}>Close</button>
</nav>
);
}
No <Provider> wrapping your app. No connect(). No useDispatch + useSelector dance. Zustand is ~3KB gzipped and has zero setup cost. You call create, you get a hook.
The selector pattern (useAuthStore((s) => s.user)) means the component only re-renders when user changes -- not when token changes. Fine-grained reactivity out of the box.
The Before/After
Here's what a typical "fetch users and store them" pattern looks like in Redux Toolkit versus the Zustand + TanStack Query approach.
Redux Toolkit:
// userSlice.ts
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
export const fetchUsers = createAsyncThunk("users/fetch", async () => {
const res = await fetch("/api/users");
return res.json();
});
const userSlice = createSlice({
name: "users",
initialState: {
data: [] as User[],
loading: false,
error: null as string | null,
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.loading = false;
state.data = action.payload;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message ?? "Failed";
});
},
});
// In component:
const dispatch = useDispatch();
const { data, loading, error } = useSelector((s: RootState) => s.users);
useEffect(() => { dispatch(fetchUsers()); }, [dispatch]);
TanStack Query:
// In component:
const { data, isLoading, error } = useQuery({
queryKey: ["users"],
queryFn: () => fetch("/api/users").then((r) => r.json()),
});
The Redux version requires a slice file, three reducer cases, a thunk, a useEffect to trigger the fetch, and wiring into the root store. The TanStack Query version is four lines. And the TanStack version gives you caching, deduplication, background refetching, and stale-while-revalidate for free.
When Redux Still Makes Sense
Redux isn't dead. It's just overused. There are cases where centralized, predictable state transitions matter:
- Complex interdependent state -- if changing one value requires cascading updates to five others in a specific order, Redux's single-store model with middleware makes that traceable.
- Collaborative/real-time apps -- when you need to replay, undo, or sync state across clients (think: collaborative editors, multiplayer), Redux's action log is genuinely useful.
- Very large teams -- Redux's strict structure (actions, reducers, selectors) creates guardrails that prevent 20 developers from managing state in 20 different ways.
If none of those apply to your project, Redux is overhead you don't need.
The Hierarchy
When I need state in a React app, I run through this list in order:
-
useState/useReducer-- local component state. If it doesn't need to be shared, don't share it. - TanStack Query -- anything from an API. Covers 70-80% of what people put in Redux.
- Zustand -- global client state that multiple components need. Auth, theme, UI flags.
- Redux -- only if you have complex interdependent state transitions or need the action log.
Most apps never get past step 3. The combination of TanStack Query + Zustand handles everything a typical dashboard, SaaS app, or admin panel needs -- with a fraction of the code and none of the boilerplate.
I stopped installing Redux a year ago. Haven't missed it once.
Top comments (0)