Beyond Redux: Modern State Management

SS Saurav Sitaula

Compare modern React state management solutions: Zustand for simple global state, React Query/TanStack Query for server state, and Jotai for atomic state. Learn the server state vs client state paradigm, when to use each library, and build a modern state management stack without Redux.

The Redux Fatigue

After mastering Redux Toolkit, I noticed something: I was still reaching for Redux by default, even when it was overkill.

A simple app with a theme toggle and a shopping cart? Redux. A form with a few fields? Redux. A todo list? Redux.

Then I worked on a project with a developer who asked: “Why Redux?”

I didn’t have a good answer.

The State Management Explosion

While I was deep in Redux-land, the ecosystem had exploded:

  • Zustand - “A small, fast and scalable bearbones state-management solution”
  • Jotai - “Primitive and flexible state management for React”
  • Recoil - “A state management library for React” (by Facebook)
  • Valtio - “Proxy-based state management”
  • MobX - “Simple, scalable state management”
  • XState - “State machines and statecharts for the modern web”

And the big realization:

React Query and SWR weren’t just data fetching libraries. They were changing how we think about state entirely.

The Server State vs Client State Split

This was the paradigm shift I needed:

Server State:

  • Data that lives on the server
  • Cached on the client
  • Can become stale
  • Needs background refetching
  • Examples: user profiles, posts, products, orders

Client State:

  • Data that lives only in the browser
  • Truly owned by the client
  • Doesn’t need syncing
  • Examples: UI state, form inputs, local preferences

I had been treating both the same way. Fetch data → put in Redux → manage staleness manually → fetch again.

But server state isn’t really your state. You’re just caching someone else’s state.

React Query Changed Everything

Once I understood the split, React Query clicked:

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

function UserProfile({ userId }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    staleTime: 5 * 60 * 1000,  // Consider fresh for 5 minutes
  });
  
  if (isLoading) return <Spinner />;
  if (error) return <Error message={error.message} />;
  
  return <Profile user={user} />;
}

function UpdateProfileButton({ userId }) {
  const queryClient = useQueryClient();
  
  const mutation = useMutation({
    mutationFn: updateUser,
    onSuccess: () => {
      // Invalidate and refetch
      queryClient.invalidateQueries({ queryKey: ['user', userId] });
    },
  });
  
  return (
    <button onClick={() => mutation.mutate({ id: userId, name: 'New Name' })}>
      Update
    </button>
  );
}

What React Query handles automatically:

  • Caching
  • Background refetching
  • Stale-while-revalidate
  • Deduplication of requests
  • Garbage collection
  • Retry logic
  • Pagination and infinite scroll helpers
  • Optimistic updates
  • Prefetching

What I used to write manually:

  • All of the above, badly

My New Mental Model

Here’s how I think about state now:

TypeToolExamples
Server stateReact Query / SWRAPI data, user profiles, lists
Global client stateZustand / ContextTheme, auth status, cart
Local client stateuseStateForm inputs, toggles, modals
URL stateRouterCurrent page, filters, search params
Form stateReact Hook FormComplex forms with validation

Notice Redux isn’t on the list. It’s not that Redux is bad—it’s that specialized tools often do each job better.

Zustand: Redux’s Simpler Cousin

For the client state that React Query doesn’t cover, I found Zustand. It’s like Redux, but without the ceremony:

import { create } from 'zustand';

// Create a store - that's it!
const useCartStore = create((set) => ({
  items: [],
  addItem: (item) => set((state) => ({ 
    items: [...state.items, item] 
  })),
  removeItem: (id) => set((state) => ({
    items: state.items.filter(item => item.id !== id)
  })),
  clearCart: () => set({ items: [] }),
}));

// Use it like a hook
function CartButton() {
  const items = useCartStore((state) => state.items);
  const addItem = useCartStore((state) => state.addItem);
  
  return (
    <div>
      <span>{items.length} items</span>
      <button onClick={() => addItem({ id: 1, name: 'Widget' })}>
        Add Widget
      </button>
    </div>
  );
}

No Provider needed. No boilerplate. Just a hook.

Zustand with DevTools

You can still get Redux DevTools support:

import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

const useStore = create(devtools((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
})));

Zustand with Persistence

Save to localStorage automatically:

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

const useCartStore = create(
  persist(
    (set) => ({
      items: [],
      addItem: (item) => set((state) => ({ items: [...state.items, item] })),
    }),
    {
      name: 'cart-storage',  // localStorage key
    }
  )
);

Refresh the page? Cart items are still there.

Zustand with Immer

Miss the Immer “mutations” from Redux Toolkit?

import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

const useStore = create(immer((set) => ({
  items: [],
  addItem: (item) => set((state) => {
    state.items.push(item);  // "Mutation" works!
  }),
})));

Jotai: Atomic State

Jotai takes a different approach—atomic state. Think of it like useState, but shareable:

import { atom, useAtom } from 'jotai';

// Create atoms (like individual pieces of state)
const countAtom = atom(0);
const doubledAtom = atom((get) => get(countAtom) * 2);  // Derived atom

function Counter() {
  const [count, setCount] = useAtom(countAtom);
  const [doubled] = useAtom(doubledAtom);
  
  return (
    <div>
      <p>Count: {count}</p>
      <p>Doubled: {doubled}</p>
      <button onClick={() => setCount(c => c + 1)}>+</button>
    </div>
  );
}

No store configuration. Atoms are created on the fly. Components subscribe to exactly what they need.

Jotai shines for:

  • Fine-grained reactivity (only re-render what changes)
  • Derived state
  • Async atoms (built-in suspense support)
  • Code splitting (atoms can be defined anywhere)

When I Reach for What

After experimenting with everything, here’s my decision process:

Just need local state?

const [isOpen, setIsOpen] = useState(false);

useState is still king for component-local state.

Need to share state between a few nearby components?

// Context + useReducer
const CartContext = createContext();

function CartProvider({ children }) {
  const [state, dispatch] = useReducer(cartReducer, initialState);
  return (
    <CartContext.Provider value={{ state, dispatch }}>
      {children}
    </CartContext.Provider>
  );
}

Good old Context works fine for limited scope.

Need global client state with good DX?

// Zustand
const useStore = create((set) => ({
  theme: 'light',
  toggleTheme: () => set((s) => ({ 
    theme: s.theme === 'light' ? 'dark' : 'light' 
  })),
}));

Zustand is my default for global client state now.

Need server state?

// React Query
const { data, isLoading } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
});

React Query, always. Don’t put server data in Redux.

Need complex async flows with specific requirements?

// Redux Toolkit with RTK Query
const api = createApi({
  endpoints: (builder) => ({
    // ...
  }),
});

When you need the Redux ecosystem, RTK is still solid.

The Combination That Works

My current stack:

Server State     → React Query
Global UI State  → Zustand
Local UI State   → useState
URL State        → Router (React Router / Next.js)
Form State       → React Hook Form
Complex Forms    → React Hook Form + Zod

No Redux in sight. Not because Redux is bad, but because these specialized tools each do their job better.

What I Wish I’d Known Earlier

  1. Server state is not client state. Stop putting fetched data in Redux. Use React Query or SWR.

  2. You probably don’t need global state. Most “global” state can be URL state, server state, or lifted local state.

  3. Zustand is ridiculously simple. If you’re intimidated by Redux, try Zustand. You’ll be productive in 10 minutes.

  4. React Query’s mental model is more important than its API. Understanding cache invalidation and stale-while-revalidate changes how you build apps.

  5. The best state management is no state management. Derive what you can. Fetch what you need. Store only what’s left.

  6. Tools evolve, principles don’t. Unidirectional data flow, immutable updates, separation of concerns—these matter more than any library.

The Journey Continues

The state management landscape has matured. We’re no longer fighting about Redux vs MobX. We’re choosing the right tool for each type of state.

But there’s still more to learn. Components can fail. Users can lose connection. APIs can return errors.

Time to talk about error boundaries and building resilient applications.


P.S. — I showed my Redux → Zustand + React Query migration to a junior developer. They said “wait, that’s it? Where’s the rest?” There is no rest. Sometimes less really is more.

SS

Saurav Sitaula

Software Architect • Nepal

Back to all posts
Saurav.dev

© 2026 Saurav Sitaula.AstroNeoBrutalism