State Management in 2021: Redux Is Dead for 90% of New Projects
State Management in 2021: Redux Is Dead for 90% of New Projects#
I'm going to say it: Redux is dead for 90% of new projects in 2021.
Not all projects. Not your existing Redux codebase that works fine. But if you're starting a new React project today and your first instinct is "let me install Redux" -- we need to talk.
TL;DR
- Split your state into server state (API data) and client state (UI-only), and use different tools for each
- React Query handles server state better than any Redux setup I've ever seen
- For client state, start with
useState/useReducer+ Context. Reach for Zustand when that gets unwieldy- Redux is still right for large teams with complex synchronous client state, but it's no longer the default
The counterargument I hear most often: "What about time-travel debugging?" and "What about middleware?" These are real advantages of Redux, and I'll address them honestly below.
The Insight That Changed Everything#
Here's the framing that unlocked state management for me: most applications have two fundamentally different kinds of state.
Server state: Data that lives on the server, fetched asynchronously, needs to be kept in sync. User profiles, product listings, order history.
Client state: Data that exists only in the browser. Which tab is selected, whether a sidebar is open, input field text before submission.
These have completely different requirements. Server state needs caching, background refetching, loading/error states, and cache invalidation. Client state needs none of that -- it just needs to be accessible across components.
The mistake most Redux-heavy apps make is managing server state in Redux. You end up writing actions for FETCH_USER_REQUEST, FETCH_USER_SUCCESS, FETCH_USER_FAILURE. Reducers for loading/error/data. Selectors. Thunks or sagas for the actual fetching. That's a LOT of boilerplate for something React Query handles in 15 lines.
React Query: The Right Tool for Server State#
Here's what a user profile looks like with React Query vs. the Redux approach:
// Redux approach: ~80 lines of actions + reducers + selectors + thunks
// I'm not even going to write it out. You know what it looks like.
// React Query: this is it. This is the whole thing.
import { useQuery, useMutation, useQueryClient } from 'react-query';
interface UserProfile {
id: string;
name: string;
email: string;
plan: 'free' | 'pro' | 'enterprise';
}
function useUserProfile(userId: string) {
return useQuery<UserProfile, Error>(
['user', userId],
() => fetch(`/api/users/${userId}`).then((r) => r.json()),
{
staleTime: 5 * 60 * 1000, // fresh for 5 minutes
retry: 2,
}
);
}
function useUpdateProfile() {
const queryClient = useQueryClient();
return useMutation(
(updates: Partial<UserProfile>) =>
fetch(`/api/users/me`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
}).then((r) => r.json()),
{
onSuccess: (updatedUser: UserProfile) => {
queryClient.setQueryData(['user', updatedUser.id], updatedUser);
},
}
);
}
// In your component
function ProfileSettings() {
const { data: profile, isLoading, error } = useUserProfile('current');
const { mutate: updateProfile, isLoading: isSaving } = useUpdateProfile();
if (isLoading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
return (
<form onSubmit={(e) => {
e.preventDefault();
const name = new FormData(e.currentTarget).get('name') as string;
updateProfile({ name });
}}>
<input name="name" defaultValue={profile?.name} />
<button type="submit" disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save'}
</button>
</form>
);
}
And React Query handles the stuff that's genuinely painful in Redux: automatic background refetching when the window regains focus, deduplicating concurrent requests, cache-first loading. All for free.
Context + useReducer: Good Enough for Most Client State#
For client state shared across many components, the built-in Context API with useReducer is often all you need:
import { createContext, useContext, useReducer, ReactNode } from 'react';
interface CartItem {
productId: string;
name: string;
price: number;
quantity: number;
}
type CartAction =
| { type: 'ADD_ITEM'; payload: Omit<CartItem, 'quantity'> }
| { type: 'REMOVE_ITEM'; payload: { productId: string } }
| { type: 'UPDATE_QUANTITY'; payload: { productId: string; quantity: number } }
| { type: 'CLEAR_CART' };
function cartReducer(state: CartItem[], action: CartAction): CartItem[] {
switch (action.type) {
case 'ADD_ITEM': {
const existing = state.find((item) => item.productId === action.payload.productId);
if (existing) {
return state.map((item) =>
item.productId === action.payload.productId
? { ...item, quantity: item.quantity + 1 }
: item
);
}
return [...state, { ...action.payload, quantity: 1 }];
}
case 'REMOVE_ITEM':
return state.filter((item) => item.productId !== action.payload.productId);
case 'UPDATE_QUANTITY':
return state.map((item) =>
item.productId === action.payload.productId
? { ...item, quantity: action.payload.quantity }
: item
);
case 'CLEAR_CART':
return [];
default:
return state;
}
}
interface CartContextValue {
items: CartItem[];
dispatch: React.Dispatch<CartAction>;
total: number;
itemCount: number;
}
const CartContext = createContext<CartContextValue | null>(null);
export function CartProvider({ children }: { children: ReactNode }) {
const [items, dispatch] = useReducer(cartReducer, []);
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
return (
<CartContext.Provider value={{ items, dispatch, total, itemCount }}>
{children}
</CartContext.Provider>
);
}
export function useCart(): CartContextValue {
const context = useContext(CartContext);
if (!context) throw new Error('useCart must be used within CartProvider');
return context;
}
This works well until you have many independent slices of state that different parts of the app need independently. And at that point...
Zustand: When Context Gets Uncomfortable#
Zustand is what happens when someone builds a state library with zero tolerance for boilerplate:
import create from 'zustand';
interface Notification {
id: string;
message: string;
type: 'success' | 'error' | 'info';
}
interface UIStore {
sidebarOpen: boolean;
notifications: Notification[];
activeModal: string | null;
toggleSidebar: () => void;
addNotification: (notification: Omit<Notification, 'id'>) => void;
dismissNotification: (id: string) => void;
openModal: (modalId: string) => void;
closeModal: () => void;
}
export const useUIStore = create<UIStore>((set) => ({
sidebarOpen: false,
notifications: [],
activeModal: null,
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
addNotification: (notification) =>
set((state) => ({
notifications: [
...state.notifications,
{ ...notification, id: crypto.randomUUID() },
],
})),
dismissNotification: (id) =>
set((state) => ({
notifications: state.notifications.filter((n) => n.id !== id),
})),
openModal: (modalId) => set({ activeModal: modalId }),
closeModal: () => set({ activeModal: null }),
}));
// Subscribe to only the slice you need
function NotificationBell() {
const notifications = useUIStore((state) => state.notifications);
const dismiss = useUIStore((state) => state.dismissNotification);
// only re-renders when notifications changes, not when sidebar toggles
return <div>{notifications.length}</div>;
}
The killer advantage over Context: components subscribe to specific slices. With Context, any change to the context value re-renders ALL consumers. Zustand doesn't have that problem. And the API is just... clean. No providers, no boilerplate, no ceremony.
Look, Redux Isn't Actually Dead#
I know I said it was. I was being provocative. (Did it work?)
Redux has real advantages in specific situations:
- Large teams with many developers touching state at once. The strict action-reducer pattern prevents the "who changed this?" debugging nightmare
- Complex synchronous state with many interconnected pieces that change together
- Time-travel debugging. Redux DevTools' time travel is genuinely unmatched
- Middleware ecosystems. Analytics, logging, persistence intercepting every state change -- Redux is built for this
And honestly, Redux Toolkit has eliminated most of the boilerplate complaints. If your team is productive with Redux, there's no reason to migrate.
But for a new project at a small team? Starting with Redux in 2021 is like buying a semi-truck because you might need to move furniture someday.
My Take#
For a new project in 2021, here's what I'd do:
- React Query for all server state. Eliminates the biggest source of Redux boilerplate
- useState for UI state local to a component or its direct children
- Context + useReducer for shared client state with clear domain boundaries (cart, auth, theme)
- Zustand if Context re-render performance becomes an issue or you need state across many disconnected components
- Redux only if you have strong reasons: large team, complex synchronous state, or an existing Redux codebase that's working
Notice the pattern. Each tool handles a specific kind of state. The monolithic "put everything in Redux" approach is what I'm arguing against.
Don't store server data alongside client state in your global store. You end up with parallel representations of the same data, cache invalidation nightmares, and loading states in every reducer. On large projects this pattern produces Redux stores with 40+ reducers — half of which end up being simple CRUD wrappers for API data that a library like React Query would handle automatically.
Don't use Context for state that changes on every keystroke or scroll event. Context re-renders all consumers on every change. That's fine for theme/locale/auth. It's not fine for real-time input.
And for the love of everything, don't reach for global state before asking: can this just live in the component that needs it? Lift state only as far as needed.
The state management question in 2021 isn't "should I use Redux." It's "what kind of state is this, and what's the simplest tool that handles it correctly?"
The ecosystem has matured past the point where one tool handles everything. That's a good thing.