Home/Blog/State Management in React 2026: Redux Toolkit, Zustand, or Jotai?

State Management in React 2026: Redux Toolkit, Zustand, or Jotai?

By TrackMy Tech Team••Engineering•14 min read
JavaScript code with syntax highlighting on a screen

React state management has changed a lot since hooks arrived in 2019. Redux is no longer the default answer, Recoil is no longer maintained, and most teams now pick from a small set of mature options: Redux Toolkit, Zustand and Jotai, usually alongside a dedicated server-state library such as TanStack Query.

We build Track My Academy, a management platform for sports academies, with a React front end, so we deal with the usual SaaS mix: dashboards, filters, notifications and data scoped to each academy. The examples below use that kind of app as a running example. This post shares how we think about the choice. The question is not which library is "best" but which one fits your application's constraints and your team.

First, separate the kinds of state

Most state management pain comes from treating all state the same. Before choosing a library, sort your state into three buckets:

  • Local UI state: form inputs, a modal's open flag, a hovered row. Keep it in useState or useReducer inside the component that owns it.
  • Shared client state: the selected academy, active filters, user preferences, a notification tray. This is where Redux Toolkit, Zustand and Jotai compete.
  • Server state: data that lives in your API and is cached on the client. It needs fetching, caching, deduplication, invalidation and refetching. Use RTK Query or TanStack Query for this rather than hand-copying API responses into a store.

Once server state is handled by a query library, the remaining shared client state is often small, and the choice between the three libraries becomes much less dramatic.

React itself has also moved on. React 19 has been stable since December 2024, and React Compiler 1.0 (released in 2025) memoizes components and values automatically, which removes a lot of manual useMemo and useCallback work. It does not change how a store notifies subscribers, though. A component subscribed to the wrong slice of state will still re-render when that slice changes, so selector discipline still matters.

Store vs. atoms

The main architectural split is between store-based libraries (Redux Toolkit, Zustand) and atomic libraries (Jotai).

Store-based: one tree, many selectors

Redux and Zustand keep state in a central store. Components subscribe through selectors and re-render only when the selected value changes. Here is a Zustand store for academy filters (Zustand v5):

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

export const useAcademyStore = create()(
  devtools(
    persist(
      (set) => ({
        academies: [],
        selectedAcademyId: null,
        filters: { status: 'active', search: '' },

        setAcademies: (academies) => set({ academies }),
        selectAcademy: (id) => set({ selectedAcademyId: id }),
        updateFilters: (patch) =>
          set((state) => ({ filters: { ...state.filters, ...patch } })),
      }),
      {
        name: 'academy-ui',
        // Persist only UI preferences, not server data
        partialize: (state) => ({ filters: state.filters }),
      }
    )
  )
);

// Derived data lives in plain selector functions
export const selectFilteredAcademies = ({ academies, filters }) =>
  academies.filter(
    (a) =>
      a.status === filters.status &&
      a.name.toLowerCase().includes(filters.search.toLowerCase())
  );

Two details worth copying: store the selected id rather than a copy of the object (so it never goes stale), and use partialize so persist writes only what should survive a reload.

A central store gives you one place to inspect and debug state, and Redux DevTools time-travel works with both Redux and Zustand's devtools middleware.

Atomic: state as a graph of small units

Jotai models state as atoms: small primitive values plus derived atoms that read other atoms. A component subscribes only to the atoms it reads, and derived atoms recompute only when their dependencies change.

import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';

export const academiesAtom = atom([]);
export const selectedAcademyIdAtom = atom(null);
export const filtersAtom = atomWithStorage('academy-filters', {
  status: 'active',
  search: '',
});

// Derived (read-only) atoms
export const selectedAcademyAtom = atom((get) => {
  const id = get(selectedAcademyIdAtom);
  return get(academiesAtom).find((a) => a.id === id) ?? null;
});

export const filteredAcademiesAtom = atom((get) => {
  const { status, search } = get(filtersAtom);
  return get(academiesAtom).filter(
    (a) =>
      a.status === status &&
      a.name.toLowerCase().includes(search.toLowerCase())
  );
});

// Write-only atom that wraps an action
export const loadAcademiesAtom = atom(null, async (get, set, userId) => {
  const res = await fetch(`/api/users/${userId}/academies`);
  if (!res.ok) throw new Error('Failed to load academies');
  set(academiesAtom, await res.json());
});

The dependency graph is explicit in the code, which makes derived state easy to follow. The trade-off is that there is no single state tree to look at when debugging.

Redux Toolkit: still the most complete package

Redux Toolkit (RTK) fixed most of classic Redux's boilerplate with createSlice, Immer-powered updates and configureStore. RTK 2.0 (December 2023) tightened TypeScript support and removed long-deprecated APIs. It remains a strong choice when you want one opinionated, well-documented system for client state and server state.

Where RTK shines

RTK Query covers server state with tag-based cache invalidation and optimistic updates. Note the guard on result in providesTags: on an error the result is undefined, and mapping over it would throw.

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

export const academyApi = createApi({
  reducerPath: 'academyApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Academy', 'Student'],
  endpoints: (builder) => ({
    getAcademyStudents: builder.query({
      query: (academyId) => `/academies/${academyId}/students`,
      providesTags: (result) =>
        result
          ? [
              ...result.map(({ id }) => ({ type: 'Student', id })),
              { type: 'Student', id: 'LIST' },
            ]
          : [{ type: 'Student', id: 'LIST' }],
    }),

    updateStudent: builder.mutation({
      query: ({ academyId, studentId, ...patch }) => ({
        url: `/academies/${academyId}/students/${studentId}`,
        method: 'PATCH',
        body: patch,
      }),
      invalidatesTags: (result, error, { studentId }) => [
        { type: 'Student', id: studentId },
      ],
      // Optimistic update with rollback on failure
      async onQueryStarted(
        { academyId, studentId, ...patch },
        { dispatch, queryFulfilled }
      ) {
        const patchResult = dispatch(
          academyApi.util.updateQueryData(
            'getAcademyStudents',
            academyId,
            (draft) => {
              const student = draft.find((s) => s.id === studentId);
              if (student) Object.assign(student, patch);
            }
          )
        );
        try {
          await queryFulfilled;
        } catch {
          patchResult.undo();
        }
      },
    }),
  }),
});

export const { useGetAcademyStudentsQuery, useUpdateStudentMutation } =
  academyApi;

Structure and conventions. Slices, actions and middleware give larger teams a shared vocabulary. When many developers touch the same state, that consistency is worth the extra ceremony.

Debugging. Every change is a named action, so Redux DevTools can show exactly what happened, in order, and replay it.

Existing codebases. If you already have a large Redux app, modernising it to RTK is usually a better investment than a rewrite.

Where RTK costs more

  • Bundle size. RTK plus react-redux is noticeably larger than Zustand or Jotai, and RTK Query adds more on top. Check current numbers on Bundlephobia or in your own bundle analyser rather than trusting figures in blog posts.
  • Ceremony. Even with createSlice, simple shared state takes more files and concepts than in Zustand.
  • Onboarding. Reducers, actions, middleware, thunks and RTK Query's cache model are a lot for a new developer to absorb at once.

Zustand: the pragmatic middle ground

Zustand (from the pmndrs collective) offers a store with almost no ceremony: create it, then read from it with a hook. No provider is required. Many teams move shared client state into small Zustand stores for exactly that reason: the code is shorter and easier for new team members to follow.

Here is a notification store. The unread count is derived rather than stored, so it can never drift out of sync with the list:

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

export const useNotificationStore = create()(
  subscribeWithSelector((set) => ({
    notifications: [],
    preferences: { sound: true, desktop: false },

    addNotification: (notification) =>
      set((state) => ({
        notifications: [
          { ...notification, id: crypto.randomUUID(), read: false },
          ...state.notifications,
        ].slice(0, 50), // keep the latest 50
      })),

    markAsRead: (id) =>
      set((state) => ({
        notifications: state.notifications.map((n) =>
          n.id === id ? { ...n, read: true } : n
        ),
      })),

    markAllRead: () =>
      set((state) => ({
        notifications: state.notifications.map((n) => ({ ...n, read: true })),
      })),

    clearAll: () => set({ notifications: [] }),
  }))
);

export const selectUnreadCount = (state) =>
  state.notifications.reduce((count, n) => count + (n.read ? 0 : 1), 0);

// React to changes outside components, e.g. the tab title
useNotificationStore.subscribe(selectUnreadCount, (unread) => {
  document.title = unread > 0 ? `(${unread}) Academy Dashboard` : 'Academy Dashboard';
});

Components subscribe through selectors:

function UnreadBadge() {
  // Re-renders only when the number changes
  const unread = useNotificationStore(selectUnreadCount);
  return unread > 0 ? <Badge>{unread}</Badge> : null;
}

By default Zustand compares the selected value with Object.is, a strict reference check, not a shallow one. Returning a primitive (like a count) or an existing object from the store is cheap. Returning a new array or object on every call is the most common Zustand bug; see the pitfalls section below.

Zustand's limitations

  • No built-in server-state cache. Pair it with TanStack Query (or RTK Query) instead of writing your own fetching and invalidation logic.
  • Derived state is up to you. Selector functions recompute on every store change. That is fine for cheap calculations. For expensive ones, memoize, or move the logic into a query library's select option.
  • Few guardrails. Zustand lets you build a clean architecture or a tangled one with equal ease. Agree on conventions early: one store per domain, actions defined inside the store, components that only read through selectors.

Jotai: atomic precision and composability

Jotai fits best when state is a web of derived values, for example a dashboard where many widgets compute different views of the same data. Each widget reads only the atoms it needs, so an update touches only the widgets that depend on it.

import { atom } from 'jotai';
import { atomFamily } from 'jotai-family';

const enrollmentsAtom = atom([]);

// One derived atom per course, created on demand
export const courseStatsAtom = atomFamily((courseId) =>
  atom((get) => {
    const rows = get(enrollmentsAtom).filter((e) => e.courseId === courseId);
    const total = rows.length;
    return {
      totalStudents: total,
      averageProgress: total ? rows.reduce((s, e) => s + e.progress, 0) / total : 0,
      completionRate: total ? rows.filter((e) => e.completed).length / total : 0,
    };
  })
);

A note on versions: Jotai 3, released in September 2026, removed atomFamily from jotai/utils. It now lives in the separate jotai-family package, as shown above. If you are still on Jotai 2.x, atomFamily is still available from jotai/utils. Atom families cache an atom per parameter, so call courseStatsAtom.remove(id) when an entity goes away, or the cache will grow without limit.

Async atoms and Suspense

Async atoms work with React Suspense and error boundaries:

import { Suspense } from 'react';
import { atom, useAtomValue } from 'jotai';
import { atomFamily } from 'jotai-family';
import { ErrorBoundary } from 'react-error-boundary';

const academyAtom = atomFamily((academyId) =>
  atom(async () => {
    const res = await fetch(`/api/academies/${academyId}`);
    if (!res.ok) throw new Error('Failed to load academy');
    return res.json();
  })
);

function AcademyDetails({ academyId }) {
  const academy = useAtomValue(academyAtom(academyId)); // suspends while loading
  return <h2>{academy.name}</h2>;
}

export function AcademyPage({ academyId }) {
  return (
    <ErrorBoundary fallback={<p>Could not load academy.</p>}>
      <Suspense fallback={<p>Loading…</p>}>
        <AcademyDetails academyId={academyId} />
      </Suspense>
    </ErrorBoundary>
  );
}

This is elegant, but a plain async atom has no cache expiry, background refetching or retry policy. For real API data, TanStack Query is usually the better tool, and Jotai has an official TanStack Query integration if you want both.

Jotai's challenges

  • A different mental model. Developers used to one central store need some time before thinking in atoms feels natural.
  • Debugging. jotai-devtools exists and is useful, but you are inspecting a graph of atoms rather than one log of actions.
  • TypeScript. Atom families and write-only atoms can produce dense type errors until the team is comfortable with Jotai's types.

Performance: what actually matters

For most apps, the library matters less than how you subscribe:

  • Bundle size. Zustand and Jotai's cores are a few kilobytes. RTK with react-redux is larger, and RTK Query adds more. This matters most for mobile users on slow networks, but measure it in your own build.
  • Re-renders. All three libraries can update a single row in a long list without re-rendering the others, as long as each row subscribes to its own data. In Redux and Zustand that means a per-item selector (for example state.byId[id]) and memoized list components. In Jotai, an atom per item (via atomFamily or splitAtom) gives you this almost by default.
  • Update cost. For simple synchronous updates, React's rendering work usually outweighs the library's own overhead. Differences show up with expensive derived state recomputed on every change, which Jotai avoids through dependency tracking and Redux avoids with createSelector.

Profile before you migrate. React DevTools' Profiler and the "highlight updates" option will tell you quickly whether state management is really your bottleneck.

Common pitfalls

Over-globalising state

Putting every input value and modal flag into a global store makes everything harder to follow. Keep state local until two or more distant components genuinely need it.

Selectors that return new objects

import { useShallow } from 'zustand/react/shallow';

// Bad: filter() returns a new array on every call.
// In Zustand v5 this causes an infinite re-render loop
// ("Maximum update depth exceeded").
const active = useStudentStore((s) => s.students.filter((x) => x.active));

// Better: select the stable source and derive in the component...
const students = useStudentStore((s) => s.students);
const activeStudents = students.filter((x) => x.active);

// ...or, when picking several fields, compare shallowly with useShallow
const { name, role } = useUserStore(
  useShallow((s) => ({ name: s.name, role: s.role }))
);

In Zustand v5, passing shallow as a second argument to a hook created with create no longer works. Use useShallow, or createWithEqualityFn from zustand/traditional if you need a custom equality function. In Redux, use createSelector from RTK for derived arrays.

Atom sprawl (Jotai)

Hundreds of tiny atoms with unclear ownership are as hard to maintain as one huge store. Start with coarser atoms and split them only when profiling shows a hotspot.

Copying server data into client stores

If API responses are being copied into Redux slices or Zustand stores by hand, you are rebuilding a cache badly. Move that data to RTK Query or TanStack Query.

Decision framework

Choose Redux Toolkit when:

  • You want one opinionated system for client state and server state (RTK Query)
  • A large team benefits from strict conventions and action-level debugging
  • You already have a Redux codebase
  • Complex middleware (auditing, analytics, sync) is central to the app

Choose Zustand when:

  • Shared client state is moderate and you want minimal boilerplate
  • Server state is already handled by TanStack Query
  • Fast onboarding matters; the core API is small
  • You want stores that can be used outside React components

Choose Jotai when:

  • You have lots of derived state and dependency chains
  • You need fine-grained updates in dense interfaces such as dashboards or editors
  • You like Suspense-first patterns and state defined close to the features that use it

Hybrid is normal

You do not have to pick one library for everything. A common and effective setup for a SaaS dashboard is:

  • TanStack Query for server state (API data, caching, optimistic updates)
  • Zustand for shared UI state (filters, selections, notification tray)
  • React Context for rarely changing values (theme, authenticated user, feature flags)
  • useState for everything local

Each tool does one job, and none of them has to stretch beyond what it is good at.

Checklist before you commit

  • Classified your state as local, shared client or server
  • Chosen a server-state tool (RTK Query or TanStack Query)
  • Measured bundle impact in your own build
  • Profiled real re-render hotspots (if you are refactoring)
  • Confirmed DevTools and TypeScript support meet your needs
  • Agreed conventions: store or atom boundaries, where actions live, selector rules
  • Planned an incremental migration path rather than a big-bang rewrite

Conclusion

In 2026, React state management is a solved problem with several good answers. Redux Toolkit is the most complete and opinionated. Zustand gives you a store with almost no ceremony. Jotai offers fine-grained, composable state for apps with heavy derived data. Pair any of them with a proper server-state library and keep local state local, and most of the "state management problem" goes away.

Prototype a real feature with your top candidate before committing, and let profiling, not trends, drive any migration. If you are planning a React or SaaS build and want a second opinion on architecture, book a free consultation with TrackMy Tech.

←BACK TO BLOG
STAY UPDATED

MORE FROM THE PRODUCT BLOG

SEE ALL POSTS