Skip links

State Management in 2023: What Actually Works

The State Management Landscape Has Changed

Five years ago, the answer to “how should I manage state in React?” was simple: Redux. It was the default. You installed it on day one, wrote your reducers, connected your components, and accepted the boilerplate as the cost of doing business.

Article Overview

State Management in 2023: What Actually Works

12 sections · Reading flow

01
The State Management Landscape Has Changed
02
First: Categorize Your State
03
React's Built-in Tools: More Powerful Than You…
04
React Query: The Server State Revolution
05
Zustand: The Pragmatist's Choice
06
Jotai: Atomic State for Component-Level Thinking
07
Redux Toolkit: When You Actually Need It
08
Signals: The New Contender
09
XState: When State Has Rules
10
Our Recommended Stack (2023 and Beyond)
11
Migration Strategy: Moving Away from Redux
12
The Real Answer

HARBOR SOFTWARE · Engineering Insights

That era is over. The React ecosystem in 2023 and beyond offers a spectrum of state management approaches, from React’s own built-in primitives to lightweight atomic stores to full-featured state machines. The right choice depends on your application’s complexity, your team’s size, and the specific problems you’re actually solving — not the problems a library’s marketing page promises to solve.

At Harbor Software, we’ve built applications using Redux, Zustand, Jotai, React Query, XState, and plain React context across different projects. This isn’t a theoretical comparison. It’s a field report from production codebases.

First: Categorize Your State

Before choosing a library, understand what kind of state you’re dealing with. Not all state is equal, and conflating different types is the root of most state management pain.

Server State

Data that lives on the server and is cached/synchronized on the client. Examples: user profile, product listings, blog posts, API responses. This is the most common type of “state” in web applications, and it has unique concerns — caching, invalidation, background refetching, optimistic updates, pagination.

Best tool: React Query (TanStack Query) or SWR. Using Redux or Zustand for server state is a mistake we see constantly. These libraries don’t understand caching semantics.

Client State (UI State)

State that exists only in the browser and doesn’t need to survive a page refresh. Examples: modal open/closed, sidebar collapsed, active tab, form input values, dropdown selection.

Best tool: React’s built-in useState and useReducer. You rarely need an external library for this. When you do, it’s usually because the state is shared across distant components.

Shared Client State

Client state that needs to be accessed by multiple components that aren’t in a direct parent-child relationship. Examples: theme preference, user authentication status, notification queue, shopping cart.

Best tool: Context + useReducer for simple cases. Zustand or Jotai for complex cases.

Complex/Stateful Logic

State that follows specific rules, transitions, or workflows. Examples: multi-step forms, payment flows, authentication state machines, drag-and-drop interactions.

Best tool: XState or useReducer with explicit state modeling.

React’s Built-in Tools: More Powerful Than You Think

Before reaching for any library, exhaust React’s built-in capabilities. For many applications — especially those using Server Components — built-in tools handle 80% of state management needs.

useState for local state

This is obvious, but developers often skip past it too quickly. If state is only used by one component or a tight parent-child tree, useState is the correct choice. No library overhead, no abstraction layer, no debugging tools needed.

useReducer for complex local state

When a component has multiple related state values that change together, useReducer provides structure without external dependencies:

type FormState = {
  values: Record<string, string>;
  errors: Record<string, string>;
  touched: Record<string, boolean>;
  isSubmitting: boolean;
};

type FormAction =
  | { type: 'SET_FIELD'; field: string; value: string }
  | { type: 'SET_ERROR'; field: string; error: string }
  | { type: 'TOUCH_FIELD'; field: string }
  | { type: 'SUBMIT_START' }
  | { type: 'SUBMIT_SUCCESS' }
  | { type: 'SUBMIT_ERROR'; errors: Record<string, string> };

function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case 'SET_FIELD':
      return {
        ...state,
        values: { ...state.values, [action.field]: action.value },
        errors: { ...state.errors, [action.field]: '' },
      };
    case 'SUBMIT_START':
      return { ...state, isSubmitting: true };
    case 'SUBMIT_ERROR':
      return { ...state, isSubmitting: false, errors: action.errors };
    default:
      return state;
  }
}

Context for shared state (with caveats)

React Context is the built-in way to share state across component trees without prop drilling. But it has a well-documented performance issue: any component consuming a context re-renders when any value in that context changes.

// Problem: every consumer re-renders when ANY value changes
const AppContext = createContext({ user: null, theme: 'light', notifications: [] });

// Solution: split contexts by update frequency
const UserContext = createContext(null);
const ThemeContext = createContext('light');
const NotificationContext = createContext([]);

For small applications with 2-3 pieces of shared state, split contexts work perfectly. When you find yourself creating 8+ contexts with complex update logic, it’s time to consider a dedicated library.

React Query: The Server State Revolution

React Query (now TanStack Query) changed how we think about data at Harbor. Before React Query, our Redux stores were 60% server data — fetched via thunks, normalized into entities, manually invalidated, with hand-rolled loading and error states for every endpoint.

React Query replaces all of that with a declarative caching layer:

function useProjects(filters) {
  return useQuery({
    queryKey: ['projects', filters],
    queryFn: () => api.getProjects(filters),
    staleTime: 5 * 60 * 1000,     // Data is fresh for 5 minutes
    gcTime: 30 * 60 * 1000,       // Keep in cache for 30 minutes
    placeholderData: keepPreviousData, // Show old data while fetching new
  });
}

// Usage in component
function ProjectList() {
  const [filters, setFilters] = useState({ status: 'active' });
  const { data, isLoading, error } = useProjects(filters);

  if (isLoading) return <Skeleton />;
  if (error) return <Error message={error.message} />;
  return <Grid projects={data} />;
}

What you get for free: automatic caching with configurable staleness, background refetching when the window regains focus, automatic retry on failure, deduplication of identical requests, loading and error states, pagination and infinite scroll support, and optimistic updates.

Our rule: if the data comes from an API, it goes through React Query. Period. This single decision eliminated roughly 40% of our Redux code across projects.

Zustand: The Pragmatist’s Choice

Zustand is what Redux would look like if it were designed in 2023 instead of 2015. It’s a small (1KB), fast, unopinionated store with no boilerplate, no providers, and no reducers unless you want them.

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

interface CartStore {
  items: CartItem[];
  addItem: (product: Product, quantity: number) => void;
  removeItem: (productId: string) => void;
  updateQuantity: (productId: string, quantity: number) => void;
  clearCart: () => void;
  totalItems: () => number;
  totalPrice: () => number;
}

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

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

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

        updateQuantity: (productId, quantity) =>
          set((state) => ({
            items: state.items.map(i =>
              i.productId === productId ? { ...i, quantity } : i
            ),
          })),

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

        totalItems: () => get().items.reduce((sum, i) => sum + i.quantity, 0),

        totalPrice: () =>
          get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
      }),
      { name: 'cart-storage' } // localStorage persistence
    )
  )
);

Usage is dead simple — no Provider wrapper needed:

function CartIcon() {
  const totalItems = useCartStore(state => state.totalItems());
  return (
    <button className="relative">
      <ShoppingCart />
      {totalItems > 0 && (
        <span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
          {totalItems}
        </span>
      )}
    </button>
  );
}

function AddToCartButton({ product }) {
  const addItem = useCartStore(state => state.addItem);
  return (
    <button onClick={() => addItem(product, 1)}>
      Add to Cart
    </button>
  );
}

Zustand’s selector pattern (state => state.totalItems()) means components only re-render when the specific slice of state they consume changes. This solves Context’s re-render problem without any additional optimization.

We use Zustand at Harbor for: shopping carts, notification queues, multi-step wizard state, UI preferences, and any shared client state that React Query doesn’t cover.

Jotai: Atomic State for Component-Level Thinking

Jotai takes a different philosophical approach. Instead of a single store, you create individual atoms of state that can be composed and derived. It’s inspired by Recoil but simpler and more production-ready.

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

// Primitive atoms
const searchQueryAtom = atom('');
const selectedCategoryAtom = atom('all');
const sortOrderAtom = atom<'asc' | 'desc'>('desc');

// Derived atom (computed from other atoms)
const filteredProductsAtom = atom(async (get) => {
  const query = get(searchQueryAtom);
  const category = get(selectedCategoryAtom);
  const sort = get(sortOrderAtom);

  const response = await fetch(
    `/api/products?q=${query}&category=${category}&sort=${sort}`
  );
  return response.json();
});

// Usage
function SearchBar() {
  const [query, setQuery] = useAtom(searchQueryAtom);
  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

function ProductGrid() {
  const products = useAtomValue(filteredProductsAtom);
  return <Grid items={products} />;
}

Jotai shines when you have many small, interconnected pieces of state — like a complex filter panel where each filter is independent but the results depend on all of them. Each atom is its own subscription, so changing the search query only re-renders components that read the search query (and derived atoms that depend on it).

Redux Toolkit: When You Actually Need It

Redux still has legitimate use cases, but they’re narrower than the ecosystem implies. Redux Toolkit (RTK) reduced the boilerplate significantly, and RTK Query handles server state well. But for most applications, the combination of React Query + Zustand covers every need with less code.

Where Redux still makes sense:

  • Very large teams (10+ frontend developers) where the strict structure and conventions prevent chaos
  • Complex state with many interdependencies where the ability to dispatch actions from anywhere and have middleware intercept them is genuinely useful
  • Time-travel debugging is a hard requirement (undo/redo functionality, replay-based debugging)
  • Existing large codebase that already uses Redux well — don’t rewrite for the sake of rewriting
// Redux Toolkit slice — much better than classic Redux
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

export const fetchProjects = createAsyncThunk(
  'projects/fetch',
  async (filters: ProjectFilters) => {
    const response = await api.getProjects(filters);
    return response.data;
  }
);

const projectsSlice = createSlice({
  name: 'projects',
  initialState: {
    items: [],
    status: 'idle',
    error: null,
  },
  reducers: {
    projectUpdated: (state, action) => {
      const index = state.items.findIndex(p => p.id === action.payload.id);
      if (index !== -1) state.items[index] = action.payload;
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchProjects.pending, (state) => { state.status = 'loading'; })
      .addCase(fetchProjects.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.items = action.payload;
      })
      .addCase(fetchProjects.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.error.message;
      });
  },
});

Compare this to the React Query + Zustand equivalent that achieves the same result. The Redux version requires more files, more types, more indirection. For a team of 3-5 developers, the overhead isn’t justified.

Signals: The New Contender

Signals (from Preact Signals, Angular, SolidJS, and now various React adapters) represent a fundamentally different reactivity model. Instead of re-rendering entire component trees when state changes, signals update only the specific DOM nodes that depend on the changed value.

// Conceptual example using @preact/signals-react
import { signal, computed } from '@preact/signals-react';

const count = signal(0);
const doubled = computed(() => count.value * 2);

function Counter() {
  // Only the text node updates, not the entire component
  return (
    <div>
      <p>Count: {count}</p>
      <p>Doubled: {doubled}</p>
      <button onClick={() => count.value++}>Increment</button>
    </div>
  );
}

Signals are compelling for performance-critical UIs — spreadsheet-like grids, real-time dashboards, complex forms with hundreds of fields. However, the React integration story is still evolving. React’s core team has signaled (pun intended) that they consider the component re-render model fundamental to React’s design, and signals work somewhat against that grain.

Our stance at Harbor: watch signals closely, experiment in non-critical projects, but don’t adopt for production React applications until the ecosystem stabilizes.

XState: When State Has Rules

For workflows with explicit states and transitions — authentication flows, multi-step forms, payment processing — XState provides guarantees that no other library offers. It’s a state machine library, and state machines make impossible states impossible.

import { createMachine, assign } from 'xstate';
import { useMachine } from '@xstate/react';

const authMachine = createMachine({
  id: 'auth',
  initial: 'idle',
  context: { user: null, error: null },
  states: {
    idle: {
      on: { LOGIN: 'authenticating' }
    },
    authenticating: {
      invoke: {
        src: 'loginService',
        onDone: {
          target: 'authenticated',
          actions: assign({ user: (_, event) => event.data })
        },
        onError: {
          target: 'error',
          actions: assign({ error: (_, event) => event.data.message })
        }
      }
    },
    authenticated: {
      on: { LOGOUT: 'idle' }
    },
    error: {
      on: {
        RETRY: 'authenticating',
        RESET: 'idle'
      }
    }
  }
});

With XState, you literally cannot transition from “idle” to “authenticated” without going through “authenticating.” You cannot be in an “error” state without a prior failed authentication attempt. The machine definition is the documentation — you can visualize it as a state chart diagram.

Our Recommended Stack (2023 and Beyond)

After building 15+ production applications with various combinations, here’s what we’ve standardized on at Harbor Software:

  1. Server state: React Query (TanStack Query). Non-negotiable. If it comes from an API, it goes through React Query.
  2. Shared client state: Zustand. Simple, fast, no boilerplate, great DevTools. Perfect for carts, notifications, user preferences, UI state that spans components.
  3. Local component state: React useState and useReducer. Don’t reach for a library when built-in tools work.
  4. Complex workflows: XState for anything with explicit states and transitions (auth flows, multi-step forms, payment processing).
  5. Form state: React Hook Form. Forms are their own category of state management pain, and RHF handles it better than any general-purpose state library.

This combination covers every state management need we’ve encountered without overlap or redundancy. Each tool handles the category of state it was designed for, and they compose cleanly — React Query fetches data, Zustand manages client state, XState governs workflows, and React’s built-in hooks handle everything local.

Migration Strategy: Moving Away from Redux

If you’re on Redux and want to migrate, do it incrementally:

  1. Install React Query. Move all data fetching from Redux thunks to React Query hooks. This is the highest-impact, lowest-risk change.
  2. Identify remaining Redux state. After removing server state, you’ll likely find 60-80% of your Redux code is gone. What remains is pure client state.
  3. Replace remaining slices with Zustand stores (or Context, if the state is simple enough). One slice at a time, one PR at a time.
  4. Remove Redux. Once no components import from Redux, uninstall it.

The Real Answer

The state management problem isn’t a library problem. It’s a categorization problem. When developers ask “what state management library should I use?” they’re asking the wrong question. The right question is “what kinds of state does my application have, and which tool handles each kind best?”

The answer is almost always a combination. And the combination that works best is the one where each tool stays in its lane. Let React Query handle server state. Let Zustand or Context handle shared client state. Let React hooks handle local state. Let XState handle workflows. Stop trying to find one library that does everything — that’s how you end up with 10,000 lines of Redux in a CRUD app.

Leave a comment

Explore
Drag