Back to Blog

React State Management in 2026: Zustand vs Redux vs Jotai vs React Query

Compare React state management libraries in 2026 — Zustand, Redux Toolkit, Jotai, and React Query. Includes real code patterns, when to use each, and the common

Viprasol Tech Team
April 16, 2026
12 min read

React State Management in 2026: Zustand vs Redux vs Jotai vs React Query

The React ecosystem's approach to state management has shifted dramatically over the past three years. Redux, once the default for any React application beyond a toy project, is now often overkill. React Query has absorbed much of what people used Redux for (server state). Zustand handles client state in a fraction of the boilerplate.

This guide maps the current landscape — what each library does well, what it doesn't, and how to decide which combination is right for your application.


The State Categories That Actually Matter

Before comparing libraries, get clear on what type of state you're managing:

State TypeDefinitionRight Tool
Server stateData fetched from an API — asynchronous, cached, possibly staleReact Query / SWR
Client stateUI state that lives only in the browser — modals, selected items, form draftsZustand / Jotai / useState
URL stateState that belongs in the URL — filters, pagination, search termsNext.js router / nuqs
Form stateValidation, dirty fields, submission stateReact Hook Form
Component stateLocal to a single component, not shareduseState / useReducer

The most common mistake: using Redux (or any global store) for server state. Developers store API responses in Redux, then write selectors, reducers, and thunks to manage loading states — all of which React Query handles automatically with caching, refetching, and stale-time management.


React Query: For Server State

React Query (now TanStack Query) is the right choice for any state that comes from an API. It manages caching, background refetching, loading/error states, pagination, infinite scroll, and optimistic updates.

// lib/api.ts — typed fetcher
async function fetchUser(userId: string): Promise<User> {
  const res = await fetch(`/api/users/${userId}`, {
    headers: { Authorization: `Bearer ${getToken()}` },
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

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

export function useUser(userId: string) {
  return useQuery({
    queryKey: ['users', userId],
    queryFn: () => fetchUser(userId),
    staleTime: 5 * 60 * 1000,   // Data fresh for 5 minutes
    gcTime: 10 * 60 * 1000,     // Keep in cache 10 minutes after unmount
    retry: 2,
    enabled: !!userId,           // Only fetch if userId is defined
  });
}

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

  return useMutation({
    mutationFn: (data: { userId: string; updates: Partial<User> }) =>
      fetch(`/api/users/${data.userId}`, {
        method: 'PATCH',
        body: JSON.stringify(data.updates),
        headers: { 'Content-Type': 'application/json' },
      }).then(r => r.json()),

    // Optimistic update — update cache before server responds
    onMutate: async ({ userId, updates }) => {
      await queryClient.cancelQueries({ queryKey: ['users', userId] });
      const previous = queryClient.getQueryData<User>(['users', userId]);

      queryClient.setQueryData<User>(['users', userId], old =>
        old ? { ...old, ...updates } : old
      );

      return { previous };  // Snapshot for rollback
    },

    onError: (err, { userId }, context) => {
      // Roll back optimistic update on error
      if (context?.previous) {
        queryClient.setQueryData(['users', userId], context.previous);
      }
    },

    onSettled: (_, __, { userId }) => {
      // Always refetch after mutation settles
      queryClient.invalidateQueries({ queryKey: ['users', userId] });
    },
  });
}

// Component usage — clean, no boilerplate
function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading, error } = useUser(userId);
  const updateUser = useUpdateUser();

  if (isLoading) return <Skeleton />;
  if (error) return <ErrorMessage error={error} />;

  return (
    <div>
      <h1>{user.name}</h1>
      <button
        onClick={() => updateUser.mutate({ userId, updates: { role: 'admin' } })}
        disabled={updateUser.isPending}
      >
        {updateUser.isPending ? 'Saving…' : 'Make Admin'}
      </button>
    </div>
  );
}

Infinite scroll with React Query:

export function useProductsFeed(categoryId: string) {
  return useInfiniteQuery({
    queryKey: ['products', 'feed', categoryId],
    queryFn: ({ pageParam }) =>
      fetch(`/api/products?category=${categoryId}&cursor=${pageParam}&limit=20`)
        .then(r => r.json()),
    initialPageParam: null as string | null,
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? null,
  });
}

function ProductFeed({ categoryId }: { categoryId: string }) {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useProductsFeed(categoryId);

  return (
    <>
      {data?.pages.flatMap(page => page.items).map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? 'Loading…' : 'Load more'}
        </button>
      )}
    </>
  );
}

🌐 Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript — production-grade stack
  • Fixed-price contracts — no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Zustand: For Client State

Zustand is the minimal client-state library. No context, no reducers, no boilerplate — just a store with actions.

// stores/useCartStore.ts
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

interface CartStore {
  items: CartItem[];
  addItem: (item: Omit<CartItem, 'quantity'>) => void;
  removeItem: (id: string) => void;
  updateQuantity: (id: string, quantity: number) => void;
  clearCart: () => void;
  total: () => number;
  itemCount: () => number;
}

export const useCartStore = create<CartStore>()(
  devtools(
    persist(
      (set, get) => ({
        items: [],

        addItem: (item) =>
          set((state) => {
            const existing = state.items.find(i => i.id === item.id);
            if (existing) {
              return {
                items: state.items.map(i =>
                  i.id === item.id
                    ? { ...i, quantity: i.quantity + 1 }
                    : i
                ),
              };
            }
            return { items: [...state.items, { ...item, quantity: 1 }] };
          }),

        removeItem: (id) =>
          set((state) => ({ items: state.items.filter(i => i.id !== id) })),

        updateQuantity: (id, quantity) =>
          set((state) => ({
            items: quantity === 0
              ? state.items.filter(i => i.id !== id)
              : state.items.map(i => i.id === id ? { ...i, quantity } : i),
          })),

        clearCart: () => set({ items: [] }),

        // Derived state as functions (not stored — computed on access)
        total: () =>
          get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),

        itemCount: () =>
          get().items.reduce((sum, item) => sum + item.quantity, 0),
      }),
      {
        name: 'cart-storage',  // localStorage key
        partialize: (state) => ({ items: state.items }),  // Only persist items
      }
    ),
    { name: 'CartStore' }  // DevTools name
  )
);

// Component usage — subscribe to only what you need
function CartIcon() {
  const itemCount = useCartStore(state => state.itemCount());
  return <Badge count={itemCount} />;
}

function CartItem({ id }: { id: string }) {
  const item = useCartStore(state => state.items.find(i => i.id === id));
  const updateQuantity = useCartStore(state => state.updateQuantity);
  const removeItem = useCartStore(state => state.removeItem);
  // Only re-renders when THIS item changes
}

UI state store (modal, drawer, notifications):

// stores/useUIStore.ts
import { create } from 'zustand';

interface Notification {
  id: string;
  type: 'success' | 'error' | 'info';
  message: string;
}

interface UIStore {
  // Modal state
  activeModal: string | null;
  modalProps: Record<string, unknown>;
  openModal: (modal: string, props?: Record<string, unknown>) => void;
  closeModal: () => void;

  // Sidebar
  sidebarOpen: boolean;
  toggleSidebar: () => void;

  // Notifications (toast)
  notifications: Notification[];
  addNotification: (notification: Omit<Notification, 'id'>) => void;
  removeNotification: (id: string) => void;
}

export const useUIStore = create<UIStore>((set) => ({
  activeModal: null,
  modalProps: {},
  openModal: (modal, props = {}) => set({ activeModal: modal, modalProps: props }),
  closeModal: () => set({ activeModal: null, modalProps: {} }),

  sidebarOpen: false,
  toggleSidebar: () => set(state => ({ sidebarOpen: !state.sidebarOpen })),

  notifications: [],
  addNotification: (notification) =>
    set(state => ({
      notifications: [
        ...state.notifications,
        { ...notification, id: crypto.randomUUID() },
      ],
    })),
  removeNotification: (id) =>
    set(state => ({
      notifications: state.notifications.filter(n => n.id !== id),
    })),
}));

Jotai: For Atomic State

Jotai uses atomic state — each piece of state is an independent atom. Components subscribe to exactly what they need. Good for fine-grained reactivity where many small pieces of state are independently updated.

// atoms/themeAtoms.ts
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';

// Persisted atom — syncs with localStorage
export const themeAtom = atomWithStorage<'light' | 'dark' | 'system'>(
  'theme',
  'system'
);

// Derived atom — computed from other atoms
export const resolvedThemeAtom = atom((get) => {
  const theme = get(themeAtom);
  if (theme !== 'system') return theme;
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
});

// Write-only atom with side effects
export const toggleThemeAtom = atom(null, (get, set) => {
  const current = get(themeAtom);
  set(themeAtom, current === 'dark' ? 'light' : 'dark');
});

// Usage
function ThemeToggle() {
  const theme = useAtomValue(themeAtom);
  const toggle = useSetAtom(toggleThemeAtom);
  return <button onClick={toggle}>{theme === 'dark' ? '☀️' : '🌙'}</button>;
}

// Async atoms — for async derived state
const userIdAtom = atom<string | null>(null);

const userDataAtom = atom(async (get) => {
  const userId = get(userIdAtom);
  if (!userId) return null;
  const res = await fetch(`/api/users/${userId}`);
  return res.json();
});
// Note: prefer React Query for server state over async atoms

🚀 Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4–8 weeks, full platforms in 3–5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Redux Toolkit: When It's Still the Right Choice

Redux Toolkit (RTK) is the modern Redux — it eliminates boilerplate with createSlice and createAsyncThunk. But when should you still choose it?

Use Redux Toolkit when:

  • Large team with strict patterns (Redux enforces consistent architecture)
  • Complex state interactions across many slices (Redux DevTools time-travel)
  • Already using RTK and the migration cost isn't justified
  • Enterprise requirement for specific tooling
// store/slices/filterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface FilterState {
  categories: string[];
  priceRange: [number, number];
  sortBy: 'price_asc' | 'price_desc' | 'newest' | 'relevance';
  inStockOnly: boolean;
}

const initialState: FilterState = {
  categories: [],
  priceRange: [0, 10000],
  sortBy: 'relevance',
  inStockOnly: false,
};

export const filterSlice = createSlice({
  name: 'filters',
  initialState,
  reducers: {
    setCategories: (state, action: PayloadAction<string[]>) => {
      state.categories = action.payload;
    },
    toggleCategory: (state, action: PayloadAction<string>) => {
      const idx = state.categories.indexOf(action.payload);
      if (idx >= 0) state.categories.splice(idx, 1);
      else state.categories.push(action.payload);
    },
    setPriceRange: (state, action: PayloadAction<[number, number]>) => {
      state.priceRange = action.payload;
    },
    setSortBy: (state, action: PayloadAction<FilterState['sortBy']>) => {
      state.sortBy = action.payload;
    },
    resetFilters: () => initialState,
  },
});

Don't use Redux for: API responses (use React Query), simple UI toggles (use useState), anything that doesn't genuinely need to be global.


Decision Guide

Is this data fetched from an API?
└── YES → React Query (not Redux, not Zustand)

Is this state shared across multiple unrelated components?
├── YES, simple (cart, UI state, auth) → Zustand
├── YES, fine-grained atoms (theme, feature flags) → Jotai
└── YES, large team, strict patterns → Redux Toolkit

Is this state local to one component or its children?
└── YES → useState / useReducer (no library needed)

Is this state that should be in the URL (filters, pagination)?
└── YES → nuqs or Next.js router searchParams

The modern React stack (2026):

  • Server state: React Query
  • Client global state: Zustand
  • URL state: nuqs
  • Form state: React Hook Form
  • Component state: useState

This covers 95% of applications without Redux.


Working With Viprasol

We build React frontends with modern state management patterns — React Query for data fetching, Zustand for client state, React Hook Form for forms. We also help teams migrate from legacy Redux setups that have become unmaintainable.

Talk to our frontend team about your React architecture.


See Also

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms — we build it all with Next.js and React.

Free consultation • No commitment • Response within 24 hours

Viprasol · Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.