Skip to content
Published on

State Management Complete Comparison 2025: React Context vs Zustand vs Jotai vs Redux Toolkit vs Recoil

Authors

1. The 2025 State Management Landscape

Competition among state management libraries in the React ecosystem has never been fiercer. Here is the weekly download breakdown for 2025:

LibraryWeekly Downloads (2025)Bundle SizeFirst Release
Redux (+ Toolkit)~9M11KB (RTK)2015
Zustand~5.5M1.1KB2019
TanStack Query~4.5M13KB2020
Jotai~2.2M3.4KB2020
Recoil~500K21KB2020

The Paradigm Shift

The most important change in 2025 is the separation of server state and client state. In the past, Redux handled everything. Now, roles are clearly divided:

  • Server State: TanStack Query / SWR (API data, caching, synchronization)
  • Client State: Zustand / Jotai / Redux Toolkit (UI state, form state, theme)
  • URL State: nuqs / next-usequerystate (search filters, pagination)
  • Form State: React Hook Form / Formik (input values, validation)
┌─────────────────────────────────────────────────┐
Application State├──────────────┬──────────────┬───────────────────┤
Server StateClient StateURL / Form StateTanStack QZustand    │  nuqs + RHFSWRJotai      │                   │
│              │   Redux TK   │                   │
└──────────────┴──────────────┴───────────────────┘

2. The Server State Revolution: TanStack Query and SWR

Why Separate Server State?

In the traditional Redux pattern, API call results were stored in the global store. This approach comes with serious problems:

  • Cache invalidation: You must manually manage when to re-fetch data
  • Loading/Error states: You must manually handle isLoading and error states every time
  • Duplicate requests: Multiple components requesting the same data trigger duplicate API calls
  • Optimistic updates: Implementation is extremely complex

TanStack Query (React Query v5)

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

// Server state fetching - automatic caching and revalidation
function useUsers() {
  return useQuery({
    queryKey: ['users'],
    queryFn: () => fetch('/api/users').then(res => res.json()),
    staleTime: 5 * 60 * 1000, // Stay fresh for 5 minutes
    gcTime: 30 * 60 * 1000,   // Garbage collect after 30 minutes
  })
}

// Server state mutation - with optimistic update
function useCreateUser() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (newUser: User) =>
      fetch('/api/users', {
        method: 'POST',
        body: JSON.stringify(newUser),
      }).then(res => res.json()),

    // Optimistic update
    onMutate: async (newUser) => {
      await queryClient.cancelQueries({ queryKey: ['users'] })
      const previous = queryClient.getQueryData(['users'])
      queryClient.setQueryData(['users'], (old: User[]) => [...old, newUser])
      return { previous }
    },
    onError: (err, newUser, context) => {
      queryClient.setQueryData(['users'], context?.previous)
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] })
    },
  })
}

The Stale-While-Revalidate Pattern

User request → Check cache → Return cached data immediately (stale)
                 Fetch new data in background (revalidate)
                 Update UI with fresh data

Thanks to this pattern, users always get an instant response while automatically receiving the latest data.

SWR vs TanStack Query

FeatureTanStack Query v5SWR v2
Bundle size13KB4.2KB
Mutation supportBuilt-in useMutationManual implementation
Optimistic updatesFirst-class supportManual implementation
DevtoolsDedicated DevToolsNone
Infinite scrollinguseInfiniteQueryuseSWRInfinite
SSR supportHydration APINext.js integration

3. React Context: When Is It Enough?

When Context Is Appropriate

React Context is a built-in state sharing mechanism that requires no additional library.

// Suitable for infrequently changing state: theme, auth, locale
const ThemeContext = createContext<ThemeContextType | null>(null)

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

  const value = useMemo(() => ({ theme, setTheme }), [theme])

  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  )
}

function useTheme() {
  const context = useContext(ThemeContext)
  if (!context) throw new Error('useTheme must be used within ThemeProvider')
  return context
}

The Performance Trap of Context

The biggest problem with Context is that when the value changes, every consumer re-renders.

// Bad pattern: All state in a single Context
const AppContext = createContext({
  user: null,
  theme: 'light',
  notifications: [],
  sidebar: false,
})

// Problem: Changing just sidebar causes ALL components
// using user, theme, or notifications to re-render!

Solving It with the Composition Pattern

// Good pattern: Separate Context per state concern
function App() {
  return (
    <ThemeProvider>
      <AuthProvider>
        <NotificationProvider>
          <SidebarProvider>
            <Layout />
          </SidebarProvider>
        </NotificationProvider>
      </AuthProvider>
    </ThemeProvider>
  )
}

// Each Context manages only its own state,
// preventing unnecessary re-renders

When Context Is Not Enough

A dedicated state management library is needed when:

  • State changes frequently (counters, timers, real-time data)
  • You need fine-grained subscription to specific state slices
  • Middleware is required (logging, persistence, dev tools)
  • State change logic is complex

4. Redux Toolkit: Is It Still Necessary?

RTK's Current Position

Redux Toolkit (RTK) solved Redux's boilerplate problem and became the official recommendation. In 2025, it remains a strong choice for large-scale enterprise applications.

createSlice: Eliminating Boilerplate

import { createSlice, PayloadAction } from '@reduxjs/toolkit'

interface TodoState {
  items: Todo[]
  filter: 'all' | 'active' | 'completed'
}

const todoSlice = createSlice({
  name: 'todos',
  initialState: { items: [], filter: 'all' } as TodoState,
  reducers: {
    addTodo: (state, action: PayloadAction<string>) => {
      // Immer is built in - no need to worry about immutability
      state.items.push({
        id: crypto.randomUUID(),
        text: action.payload,
        completed: false,
      })
    },
    toggleTodo: (state, action: PayloadAction<string>) => {
      const todo = state.items.find(t => t.id === action.payload)
      if (todo) todo.completed = !todo.completed
    },
    setFilter: (state, action: PayloadAction<TodoState['filter']>) => {
      state.filter = action.payload
    },
  },
})

export const { addTodo, toggleTodo, setFilter } = todoSlice.actions
export default todoSlice.reducer

RTK Query: Built-in Server State Management

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['User', 'Post'],
  endpoints: (builder) => ({
    getUsers: builder.query<User[], void>({
      query: () => '/users',
      providesTags: ['User'],
    }),
    createUser: builder.mutation<User, Partial<User>>({
      query: (body) => ({
        url: '/users',
        method: 'POST',
        body,
      }),
      invalidatesTags: ['User'],
    }),
  }),
})

export const { useGetUsersQuery, useCreateUserMutation } = api

When Redux Toolkit Is Still Necessary

  • Large teams: Teams that need strict patterns and conventions
  • Complex business logic: Complex interactions among multiple states
  • Time-travel debugging: Powerful debugging with Redux DevTools
  • Middleware ecosystem: Rich middleware support including saga, thunk
  • Legacy compatibility: Gradual migration from existing Redux codebases

5. Zustand: The Triumph of Minimalism

Why Zustand?

Zustand is the lightest state management library at a remarkable 1.1KB bundle size. It requires no Provider, has minimal boilerplate, and works perfectly with TypeScript.

Basic Store Pattern

import { create } from 'zustand'

interface BearStore {
  bears: number
  increase: () => void
  decrease: () => void
  reset: () => void
}

const useBearStore = create<BearStore>((set) => ({
  bears: 0,
  increase: () => set((state) => ({ bears: state.bears + 1 })),
  decrease: () => set((state) => ({ bears: state.bears - 1 })),
  reset: () => set({ bears: 0 }),
}))

// Usage: Call anywhere without a Provider
function BearCounter() {
  const bears = useBearStore((state) => state.bears)
  return <span>{bears} bears</span>
}

function BearControls() {
  const increase = useBearStore((state) => state.increase)
  return <button onClick={increase}>Add bear</button>
}

Middleware: persist, devtools, immer

import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'

interface AppStore {
  user: User | null
  preferences: Preferences
  setUser: (user: User | null) => void
  updatePreference: (key: string, value: unknown) => void
}

const useAppStore = create<AppStore>()(
  devtools(
    persist(
      immer((set) => ({
        user: null,
        preferences: { theme: 'light', language: 'en' },

        setUser: (user) => set((state) => {
          state.user = user
        }),

        updatePreference: (key, value) => set((state) => {
          state.preferences[key] = value
        }),
      })),
      {
        name: 'app-storage', // localStorage key
        partialize: (state) => ({
          preferences: state.preferences,
        }),
      }
    ),
    { name: 'AppStore' } // DevTools label
  )
)

Slice Pattern: Splitting Large Stores

// userSlice.ts
const createUserSlice = (set: SetState, get: GetState) => ({
  user: null,
  login: async (credentials: Credentials) => {
    const user = await authApi.login(credentials)
    set({ user })
  },
  logout: () => set({ user: null }),
})

// cartSlice.ts
const createCartSlice = (set: SetState, get: GetState) => ({
  items: [],
  addItem: (item: CartItem) =>
    set((state) => ({ items: [...state.items, item] })),
  totalPrice: () =>
    get().items.reduce((sum, item) => sum + item.price, 0),
})

// Combine
const useStore = create((...args) => ({
  ...createUserSlice(...args),
  ...createCartSlice(...args),
}))

Zustand's Strengths

  • 1.1KB bundle size (gzipped)
  • No Provider needed: Use anywhere in the component tree
  • Selective subscription: Subscribe to only what you need with (state) => state.bears
  • Works outside React: Also works in vanilla JS
  • SSR compatible: Perfect compatibility with Next.js

6. Jotai: The Power of Atomic State

Atom-based Design

Jotai manages state in "atom" units. Each atom is independent, and only the components that subscribe to it re-render.

import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'

// Basic atoms
const countAtom = atom(0)
const nameAtom = atom('Guest')

// Derived atom
const doubleCountAtom = atom((get) => get(countAtom) * 2)

// Read/Write atom
const countWithLogAtom = atom(
  (get) => get(countAtom),
  (get, set, newValue: number) => {
    console.log(`Count changed: ${get(countAtom)} -> ${newValue}`)
    set(countAtom, newValue)
  }
)

// Usage
function Counter() {
  const [count, setCount] = useAtom(countAtom)
  const doubleCount = useAtomValue(doubleCountAtom)

  return (
    <div>
      <p>Count: {count}, Double: {doubleCount}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
    </div>
  )
}

Async Atoms and Suspense Integration

// Async atom - automatic React Suspense integration
const userAtom = atom(async () => {
  const response = await fetch('/api/user')
  return response.json()
})

// Async atom with dependencies
const userPostsAtom = atom(async (get) => {
  const user = await get(userAtom)
  const response = await fetch(`/api/users/${user.id}/posts`)
  return response.json()
})

// Loading handled via Suspense
function UserPosts() {
  const posts = useAtomValue(userPostsAtom)
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <UserPosts />
    </Suspense>
  )
}

atomFamily: Dynamic Atom Creation

import { atomFamily } from 'jotai/utils'

// Create independent atoms per ID
const todoAtomFamily = atomFamily((id: string) =>
  atom({
    id,
    text: '',
    completed: false,
  })
)

// Each Todo is managed by an independent atom,
// so changing one Todo does not cause others to re-render
function TodoItem({ id }: { id: string }) {
  const [todo, setTodo] = useAtom(todoAtomFamily(id))
  return (
    <label>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => setTodo(prev => ({ ...prev, completed: !prev.completed }))}
      />
      {todo.text}
    </label>
  )
}

Jotai's Strengths

  • 3.4KB bundle size (gzipped)
  • Atomic re-rendering: Only components subscribed to the changed atom re-render
  • React Suspense integration: Handle async state declaratively
  • Bottom-up approach: Compose state from small units
  • DevTools support: Jotai DevTools, React DevTools integration

7. Recoil: Meta's Choice, but in Decline

Recoil's Current Status

Recoil is an atomic state management library created by Meta (Facebook). However, as of 2025, maintenance has been sluggish and release intervals have widened.

Basic Usage

import { atom, selector, useRecoilState, useRecoilValue } from 'recoil'

const todoListAtom = atom({
  key: 'todoList',
  default: [] as Todo[],
})

const filteredTodoSelector = selector({
  key: 'filteredTodos',
  get: ({ get }) => {
    const list = get(todoListAtom)
    const filter = get(todoFilterAtom)
    switch (filter) {
      case 'completed': return list.filter(t => t.completed)
      case 'active': return list.filter(t => !t.completed)
      default: return list
    }
  },
})

// atomFamily - dynamic atoms
const userAtomFamily = atomFamily({
  key: 'user',
  default: (userId: string) => async () => {
    const res = await fetch(`/api/users/${userId}`)
    return res.json()
  },
})

Recoil vs Jotai

CriteriaRecoilJotai
Bundle size21KB3.4KB
Key requiredMandatory (string key)Not required
ProviderRecoilRoot requiredOptional
MaintenanceSluggishActive
SSRLimitedFull support
CommunityDecliningGrowing

Since Jotai provides all of Recoil's features in a smaller bundle with active maintenance, Jotai is recommended for new projects.


8. Signals: A New Paradigm

What Are Signals?

Signals are state primitives that provide fine-grained reactivity. Unlike React's re-rendering model, when a Signal changes, only the DOM parts that use that Signal are updated directly.

Preact Signals

import { signal, computed, effect } from '@preact/signals-react'

// Create signals
const count = signal(0)
const doubled = computed(() => count.value * 2)

// Use in components - direct DOM update without re-rendering
function Counter() {
  return (
    <div>
      <p>Count: {count}</p>
      <p>Doubled: {doubled}</p>
      <button onClick={() => count.value++}>+1</button>
    </div>
  )
}

// Side effects
effect(() => {
  console.log(`Count is now: ${count.value}`)
})

SolidJS Signals

import { createSignal, createMemo, createEffect } from 'solid-js'

function Counter() {
  const [count, setCount] = createSignal(0)
  const doubled = createMemo(() => count() * 2)

  createEffect(() => {
    console.log(`Count: ${count()}`)
  })

  return (
    <div>
      <p>Count: {count()}, Doubled: {doubled()}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
    </div>
  )
}

Angular Signals (v17+)

import { signal, computed, effect } from '@angular/core'

@Component({
  template: `
    <p>Count: {{ count() }}, Doubled: {{ doubled() }}</p>
    <button (click)="increment()">+1</button>
  `
})
class CounterComponent {
  count = signal(0)
  doubled = computed(() => this.count() * 2)

  constructor() {
    effect(() => {
      console.log(`Count: ${this.count()}`)
    })
  }

  increment() {
    this.count.update(c => c + 1)
  }
}

React and Signals

The React team has not officially adopted Signals. Instead, React 19's compiler (React Forget) automatically performs similar optimizations. In other words, React chose compiler-based optimization over Signals.

ApproachFrameworksPrinciple
SignalsPreact, Solid, AngularFine-grained reactivity tracking at runtime
CompilerReact 19Automatic memoization at build time
ProxyMobX, ValtioChange detection via Proxy objects

9. Comprehensive Comparison Table

Feature Comparison

CriteriaContextRedux TKZustandJotaiRecoil
Bundle size0KB (built-in)11KB1.1KB3.4KB21KB
BoilerplateMediumLow (RTK)Very lowVery lowMedium
Learning curveLowMediumLowLowMedium
TypeScriptGoodExcellentExcellentExcellentGood
SSR supportGoodGoodExcellentExcellentLimited
DevToolsReact DTRedux DTRedux DTJotai DTLimited
MiddlewareNoneRichpersist/devtoolsUtilitiesNone
Selective subscriptionNouseSelectorselector fnPer atomselector
Provider requiredYesYesNoOptionalYes
Async supportuseEffectcreateAsyncThunkManualSuspenseSuspense

Performance Benchmark (1,000-item List)

ScenarioContextRedux TKZustandJotai
Single item update12.3ms2.1ms1.8ms0.9ms
Full list update15.2ms8.7ms7.9ms8.2ms
Memory usageBaseline+2.1MB+0.3MB+0.5MB
Initial render45ms52ms44ms46ms

10. Project Selection Guide

Decision Framework

What is the project scale?
├── Small (1-3 people, simple UI)
│   ├── Does state change frequently?
│   │   ├── YesZustand
│   │   └── NoReact Context
│   └── Is server data the focus?
│       └── YesTanStack Query alone is sufficient
├── Medium (4-10 people, complex UI)
│   ├── Are there many independent states?
│   │   ├── YesJotai
│   │   └── NoZustand
│   └── Is server state complex?
│       └── YesTanStack Query + Zustand/Jotai
└── Large (10+ people, enterprise)
    ├── Is there existing Redux code?
    │   ├── YesMigrate to Redux Toolkit
    │   └── NoZustand + TanStack Query
    └── Is strict architecture required?
        └── YesRedux Toolkit
ScenarioRecommended Combination
Simple landing pageReact Context
SaaS dashboardZustand + TanStack Query
E-commerceJotai + TanStack Query
Social media appZustand + TanStack Query
Large enterpriseRedux Toolkit + RTK Query
Real-time collaboration toolZustand + WebSocket middleware
Editor/IDE-type toolJotai (fine-grained state control)

11. Migration Guide

From Redux to Zustand

// Before: Redux Toolkit
// store.ts
const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: (state) => { state.value += 1 },
    decrement: (state) => { state.value -= 1 },
  },
})

// Component
function Counter() {
  const count = useSelector((state: RootState) => state.counter.value)
  const dispatch = useDispatch()
  return <button onClick={() => dispatch(increment())}>Count: {count}</button>
}

// After: Zustand
const useCounterStore = create<CounterStore>((set) => ({
  value: 0,
  increment: () => set((s) => ({ value: s.value + 1 })),
  decrement: () => set((s) => ({ value: s.value - 1 })),
}))

function Counter() {
  const count = useCounterStore((s) => s.value)
  const increment = useCounterStore((s) => s.increment)
  return <button onClick={increment}>Count: {count}</button>
}

From Context to Jotai

// Before: React Context
const CountContext = createContext({ count: 0, setCount: () => {} })

function CountProvider({ children }) {
  const [count, setCount] = useState(0)
  return (
    <CountContext.Provider value={{ count, setCount }}>
      {children}
    </CountContext.Provider>
  )
}

function Counter() {
  const { count, setCount } = useContext(CountContext)
  return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
}

// After: Jotai
const countAtom = atom(0)

function Counter() {
  const [count, setCount] = useAtom(countAtom)
  return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
}
// Provider removed! 50% less code!

Migration Checklist

  • Classify existing state into server vs client
  • Move server state to TanStack Query
  • Move client state to your chosen library
  • Update test code
  • Compare performance benchmarks
  • Gradual rollout (migrate feature by feature)

12. Interview Questions and Quiz

Top 10 Interview Questions

Q1. Explain the difference between server state and client state, and recommend appropriate tools for each.

Server state is asynchronous data fetched from APIs where caching, invalidation, and synchronization are key. TanStack Query or SWR is appropriate. Client state is purely frontend state such as UI toggles and form inputs where Zustand, Jotai, or Context is appropriate.

Q2. What is the performance problem with React Context, and how do you solve it?

When Context's value changes, every component subscribing to that Context re-renders. Solutions include splitting Context, memoizing values with useMemo, or switching to Zustand/Jotai.

Q3. What is the core difference between Zustand and Jotai?

Zustand uses a store-based (top-down) approach that groups related state into a single store. Jotai uses an atom-based (bottom-up) approach that splits state into minimal units. Zustand is better when a single store feels natural; Jotai is better when there are many independent states.

Q4. Explain why Redux Toolkit is still relevant in 2025.

Its strengths include strict pattern enforcement, time-travel debugging, a rich middleware ecosystem, built-in RTK Query, and code consistency in large teams.

Q5. Explain the Stale-While-Revalidate pattern.

Cached (stale) data is returned immediately for a fast UX, while new data is fetched in the background (revalidate) and the UI is updated. This is the core strategy of TanStack Query and SWR.

Q6. How do Signals differ from React's re-rendering model?

React re-executes the entire component function on state change and diffs the Virtual DOM. Signals directly update only the DOM parts that use the changed value, avoiding unnecessary component re-execution.

Q7. What problem does Zustand's persist middleware cause in SSR, and how do you fix it?

localStorage does not exist on the server, causing hydration mismatch. Fix this with the skipHydration option or manual hydration inside useEffect.

Q8. Explain the difference between Jotai's derived atom and Recoil's selector.

They are functionally similar, but Jotai requires no string key and has a bundle size 6x smaller. Jotai atoms are identified by reference equality, eliminating key collision risk.

Q9. What is the queryKey design strategy for TanStack Query?

Use hierarchical key structures (e.g., ['users', userId, 'posts']) to enable fine-grained cache invalidation. Invalidating a parent key automatically invalidates child keys.

Q10. What criteria should you use to choose a state management tool for a new project?

Consider project scale, team experience, SSR requirements, bundle size constraints, and state complexity. Most projects should start with the TanStack Query + Zustand combination.

Quiz (5 Questions)

Q1. Why does Zustand not require a Provider?

Zustand creates stores at module scope as global singletons. Because state is managed outside the React component tree, there is no need to wrap components with a Provider. This simplifies setup and eliminates Provider nesting issues.

Q2. What is the difference between TanStack Query's staleTime and gcTime?

staleTime is how long data remains in a "fresh" state. Fresh data is not re-fetched. gcTime (garbage collection time) is how long inactive query data stays in memory before being removed. Defaults are 0 for staleTime and 5 minutes for gcTime.

Q3. Name two advantages of Jotai's atom over Recoil's atom.

First, no string key is required. Jotai atoms are identified by JavaScript object reference, eliminating key collision risk. Second, the bundle size is 3.4KB, roughly one-sixth of Recoil's 21KB.

Q4. Can useMemo alone fully solve React Context's re-rendering problem?

No. useMemo prevents unnecessary re-creation of the value object, but when actual values inside the object change, all consumers still re-render. You need to split Context or use a library that supports selective subscriptions (Zustand, Jotai).

Q5. What is the most important consideration when migrating from Redux Toolkit to Zustand?

Zustand does not have the same middleware chain concept as Redux, so you must redesign sagas and complex async flows. Additionally, Redux DevTools' time-travel debugging is only partially available in Zustand. A strategy of temporarily running both libraries in parallel is needed for gradual migration.


References

  1. Zustand GitHub Repository
  2. Jotai Documentation
  3. Redux Toolkit Official Docs
  4. TanStack Query Documentation
  5. Recoil Documentation
  6. Preact Signals
  7. SolidJS Reactivity
  8. Angular Signals RFC
  9. SWR Documentation
  10. React Documentation - Context
  11. npm trends: State Management Libraries
  12. Theo Browne - State Management in 2025
  13. Jack Herrington - Zustand vs Jotai
  14. TkDodo - Practical React Query