Skip to main content

You Don't Need a State Management Library

·PkgPulse Team

TL;DR

Most apps don't need a state management library. React's built-in tools — useState, useReducer, useContext, and React Query for server state — handle 80% of real-world use cases. The "I need global state" problem is usually a "I need server state caching" problem (solved by TanStack Query) or a "my component tree is too deep" problem (solved by lifting state or restructuring). Add Zustand when you have genuinely global client state that multiple unrelated parts of the app need to share synchronously.

Key Takeaways

  • useState + useReducer covers most component-level state
  • TanStack Query handles server state (API responses, loading, caching) — this covers ~60% of "why I needed Redux"
  • useContext covers shared state between nearby components
  • Zustand is appropriate when you have truly global synchronous client state
  • Redux Toolkit makes sense for large teams who need predictable state + devtools

The State Management Trap

How developers fall into it:

1. Build a small React app
2. Need to share data between two components
3. Research "React state management"
4. Find Redux, Zustand, MobX, Recoil, Jotai, XState...
5. Add Redux Toolkit to a 3-component app
6. Write actions, reducers, selectors, thunks for a todo list
7. Hate the experience, blame Redux
8. Never question whether you needed Redux at all

The real question: What KIND of state are you managing?

Server state (API data, DB results, user data):
→ YOU DON'T NEED A STATE LIBRARY
→ You need TanStack Query (formerly React Query)

Local UI state (open/closed, active tab, form values):
→ YOU DON'T NEED A STATE LIBRARY
→ useState inside the component that owns it

Shared UI state (3-4 components need it):
→ YOU PROBABLY DON'T NEED A STATE LIBRARY
→ Lift state to common parent + prop drilling OR useContext

Global client state (auth token, theme, user preferences):
→ NOW you MIGHT need a state library
→ But even here: useContext + useReducer handles it for most apps

Global synchronized state (multiple tabs, real-time, undo/redo):
→ YES — this is the real use case for state libraries

What Built-in React Handles Well

// Case 1: Local component state
// useState for simple values, useReducer for complex state objects

// Simple: useState
function SearchBar() {
  const [query, setQuery] = useState('');
  const [isOpen, setIsOpen] = useState(false);

  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

// Complex: useReducer
type CartState = {
  items: CartItem[];
  total: number;
  coupon: string | null;
};

type CartAction =
  | { type: 'ADD_ITEM'; item: CartItem }
  | { type: 'REMOVE_ITEM'; id: string }
  | { type: 'APPLY_COUPON'; code: string };

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM':
      return {
        ...state,
        items: [...state.items, action.item],
        total: state.total + action.item.price,
      };
    // ...
  }
}

// No state library needed. This is React.

TanStack Query Solves "Server State" — The Real Redux Use Case

// The #1 reason developers reach for Redux: API data management
// "I need to store the user data globally"
// "I need to cache API responses"
// "I need loading states"
// "I need error handling"
// "I need to refetch when the user returns to the tab"

// Redux approach (complex):
// actions/user.ts
export const fetchUser = createAsyncThunk('user/fetch', async (id: string) => {
  const data = await api.getUser(id);
  return data;
});

// reducers/user.ts
const userSlice = createSlice({
  name: 'user',
  initialState: { data: null, loading: false, error: null },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => { state.loading = true; })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.data = action.payload;
        state.loading = false;
      });
  },
});

// TanStack Query approach (simple):
import { useQuery } from '@tanstack/react-query';

function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => api.getUser(userId),
    staleTime: 5 * 60 * 1000,  // Cache for 5 minutes
  });

  if (isLoading) return <Spinner />;
  if (error) return <ErrorMessage />;

  return <div>{user.name}</div>;
}

// TanStack Query also gives you for FREE:
// → Automatic refetch on window focus
// → Deduplication (10 components requesting same data = 1 request)
// → Background refresh
// → Optimistic updates
// → Pagination and infinite scroll
// → DevTools
// → No boilerplate

useContext: The Underused Native Solution

// For state shared between 3-10 components in the same subtree:
// useContext is sufficient and ships with React

// theme.tsx
const ThemeContext = createContext<{
  theme: 'light' | 'dark';
  toggle: () => void;
}>({ theme: 'light', toggle: () => {} });

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');

  return (
    <ThemeContext.Provider
      value={{ theme, toggle: () => setTheme(t => t === 'light' ? 'dark' : 'light') }}
    >
      {children}
    </ThemeContext.Provider>
  );
}

export const useTheme = () => useContext(ThemeContext);

// Usage — any component can access this:
function Button() {
  const { theme, toggle } = useTheme();
  return <button onClick={toggle}>{theme === 'light' ? '🌙' : '☀️'}</button>;
}

// When useContext IS enough:
// → Auth state (currentUser, login, logout)
// → Theme (light/dark mode)
// → User preferences
// → Feature flags
// → Small apps with a single global state object

// When useContext ISN'T enough:
// → Frequent updates (re-renders all consumers on EVERY change)
// → Complex state with many independent slices
// → State that's unrelated to component tree structure

When to Actually Add Zustand

// Zustand is the right answer when:
// 1. You have global client state NOT from the server
// 2. Multiple UNRELATED parts of the app need the same state
// 3. You're having performance issues with useContext re-renders
// 4. You need state that persists across route changes

// Real examples where Zustand makes sense:
// - Shopping cart (not server data, persists across routes, everywhere in app)
// - Multi-step wizard state (shared between unrelated form steps)
// - WebSocket connection state (realtime data throughout app)
// - User-built workspace/canvas state (complex, frequent updates)

// What Zustand looks like:
import { create } from 'zustand';

interface CartStore {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
  clearCart: () => void;
}

const useCartStore = create<CartStore>((set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
  removeItem: (id) => set((state) => ({
    items: state.items.filter(i => i.id !== id),
  })),
  clearCart: () => set({ items: [] }),
}));

// Any component, anywhere:
function CartIcon() {
  const items = useCartStore((state) => state.items);
  return <span>{items.length}</span>;  // Only re-renders when items changes
}

function ProductPage({ productId }: { productId: string }) {
  const addItem = useCartStore((state) => state.addItem);
  return <button onClick={() => addItem({ id: productId, ... })}>Add to Cart</button>;
}

// Note: ProductPage doesn't re-render when items changes — only subscribed to addItem

The Redux Case: When It Actually Makes Sense

Redux Toolkit is appropriate when:

1. Large team with many developers touching shared state
   → Enforced patterns prevent "who broke global state" mysteries
   → Predictable state makes debugging easier across teams

2. Complex state with derived data and selectors
   → Redux Toolkit + createEntityAdapter for normalized CRUD state
   → Reselect for memoized derived state

3. Time-travel debugging is genuinely useful
   → Redux DevTools time-travel: replay actions to reproduce bugs
   → Valuable for complex user interaction debugging

4. You have the boilerplate budget
   → Redux has more setup than Zustand
   → Worth it when team size justifies the conventions

Real-world Redux use cases:
→ Figma-like collaborative editors (complex state, undo/redo)
→ Financial trading platforms (predictable state transitions matter)
→ Large enterprise apps with 10+ developers on the same state

What Redux is NOT appropriate for:
→ Todo apps, portfolios, blogs, marketing sites, landing pages
→ Apps with <5 developers
→ Apps where most data comes from APIs (TanStack Query handles this)

The Decision Tree

What state problem do you have?

"I need to fetch and cache API data globally"
→ TanStack Query (not a state library — it's data fetching)

"Two sibling components need shared state"
→ Lift to parent, prop drilling is fine for 2-3 levels

"5+ components across different levels need the same auth state"
→ useContext + useReducer

"Cart state needs to persist across routes and be in header AND checkout"
→ Zustand (genuinely global, not server state)

"My app has 50K lines, 10 developers, complex UX workflows"
→ Redux Toolkit

"I'm building a collaborative real-time tool"
→ Zustand + Yjs, or XState for complex state machines

If you're unsure which bucket you're in:
→ You're in the TanStack Query bucket.
→ 60% of "I need global state" problems are server state.
→ Install TanStack Query first. Solve 80% of the problem.
→ Add Zustand if you still have global client state needs.
→ Add Redux Toolkit if you're on a large team with complex patterns.

The progression:
1. useState / useReducer (always start here)
2. TanStack Query (if it's server state)
3. useContext (if it's shared between nearby components)
4. Zustand (if it's truly global client state)
5. Redux Toolkit (if you're on a large team)

Most apps stop at step 2 or 3.

Compare Zustand vs Redux download trends and health scores at PkgPulse.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.