Frontend State Management: Redux, Zustand, Pinia, and Signals

Philip Rehberger Sep 9, 2026 6 min read

Pick a state management approach based on app shape, not framework defaults. Covers when context alone is enough.

Frontend state management has gone through cycles. Redux was the default for years, replaced for new projects by Zustand and Jotai. The latest debate is whether signals — primitives popularized by SolidJS and now coming to React — change the picture again.

The honest answer is: most apps need less state management than they think, the right tool depends on the shape of the state, and any of the modern options is fine when used appropriately.

What State Actually Lives Where

A frontend app has several kinds of state, and they want different tools.

Local UI state. A modal's open/closed, a form's current input, whether a dropdown is hovered. This belongs in the component (useState, ref, etc.). Reaching for a global store is overkill.

Server state. Data fetched from an API. This wants caching, refetching on focus, optimistic updates, and reconciliation with the server. React Query / TanStack Query handle this better than any general-purpose state library.

Shared app state. User identity, theme, notifications, anything that multiple unrelated components need. This is what state management libraries are for.

Routing state. Current URL, query parameters. The router owns this. Trying to duplicate it in a store is a recipe for desync bugs.

If you confuse server state with shared app state, you end up reinventing caching badly. Most "Redux is too complex" complaints are actually "Redux is the wrong tool for what I am doing."

The Modern Options

Redux (with Redux Toolkit)

The traditional answer. Actions, reducers, immutability, devtools. Redux Toolkit eliminated most of the boilerplate complaints, but the model still has overhead for small state.

import { createSlice, configureStore } from '@reduxjs/toolkit';

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: (state) => { state.value++ },
  },
});

const store = configureStore({ reducer: counterSlice.reducer });

Strengths: mature devtools, time-travel debugging, well-understood patterns at scale, good for very large apps with complex action flows.

Weaknesses: more code per state slice than the alternatives, the action/reducer ceremony rarely pays off for app-sized state.

Zustand

A minimal store API. Create a store with state and methods; use it via a hook.

import { create } from 'zustand';

const useCounter = create((set) => ({
  value: 0,
  increment: () => set((s) => ({ value: s.value + 1 })),
}));

// In a component
const { value, increment } = useCounter();

Strengths: trivial API, easy migration from useState, no provider needed, works with selectors for performance.

Weaknesses: less structure than Redux for very large apps, fewer devtools features (though it has a Redux devtools integration).

Zustand is the default for new React projects in 2026 unless there is a reason to pick something else.

Jotai (and Recoil)

Atomic state — small, composable state primitives.

import { atom, useAtom } from 'jotai';

const countAtom = atom(0);
const doubledAtom = atom((get) => get(countAtom) * 2);

function Counter() {
  const [count, setCount] = useAtom(countAtom);
  const [doubled] = useAtom(doubledAtom);
  return <>{doubled}</>;
}

Strengths: fine-grained subscriptions (components re-render only when their specific atoms change), composability, great for derived state.

Weaknesses: state is scattered across many atoms, can become hard to navigate in large apps without discipline.

Jotai is increasingly popular for apps with lots of derived state — design tools, editors, dashboards with many computed values.

Pinia (Vue)

Vue's official state management. Stores are defined with options or composition syntax.

import { defineStore } from 'pinia';

export const useCounterStore = defineStore('counter', {
  state: () => ({ value: 0 }),
  actions: { increment() { this.value++ } },
});

// In a component
const counter = useCounterStore();
counter.increment();

Strengths: Vue-native, TypeScript-friendly, excellent devtools, both options and composition syntax.

Weaknesses: none specific — Pinia is the right default for Vue.

Signals

Signals are reactive primitives that update consumers automatically when the value changes. They originated in SolidJS, popularized by Preact, and are now part of Vue (via ref) and coming to React (via useSignal libraries and eventually a built-in primitive).

import { signal } from '@preact/signals-react';

const count = signal(0);

function Counter() {
  return <button onClick={() => count.value++}>{count}</button>;
}

Strengths: fine-grained reactivity, often less re-rendering than hook-based approaches, conceptually simple.

Weaknesses: in React specifically, signals fight with hooks-based mental models; the ecosystem is still warming up.

The signals story is evolving fast. For Vue, signals are already the model. For React, they are a credible alternative but not yet the default.

When to Reach for State Management

A useful heuristic: state lives in a store when it has any of these properties:

  • Used by multiple components that are not in a parent-child relationship
  • Persisted across navigation
  • Mutated by side effects (websocket messages, queue events, polling)
  • Synced to localStorage or sessionStorage

If none of these apply, the state should be local to a component or its parent.

Common Anti-Patterns

Storing form input state in a global store. A form's draft values belong in the form. Putting them in Zustand makes the form harder to reuse and harder to reason about.

Treating server state as app state. When you save fetched data in a Zustand store and then update it manually, you have built a worse version of React Query. Use a server-state library.

Selectors that return new objects every render. This defeats the library's memoization and causes unnecessary re-renders.

// Bad — returns a new array every time
const items = useStore((s) => s.items.filter((i) => i.active));

// Good — select primitives or stable references
const itemCount = useStore((s) => s.items.length);

One mega-store. Splitting state across multiple stores is fine and usually better. A single store with 200 fields is harder to navigate than five stores with 40 fields each.

A Recommended Stack

For a new React app in 2026, the stack that holds up:

  • React Query (TanStack Query) for server state
  • Zustand for shared client state
  • useState for component-local state
  • The router (TanStack Router or React Router) for URL state

For a new Vue app:

  • TanStack Query or VueUse's useFetch for server state
  • Pinia for shared state
  • ref for component-local state

For a Svelte app:

  • TanStack Query or Svelte's load functions for server state
  • Svelte stores for shared state
  • let for component-local state

The pattern across all three: split server state from client state, keep local state local, and use shared state libraries only for what genuinely needs sharing.

The Honest Truth

Most apps need much less state management than developers reach for. A typical SPA needs:

  • The current user
  • A notifications array
  • The theme (or it lives in CSS)
  • A handful of UI flags

That is maybe 50 lines of Zustand or Pinia. The rest is server state (use a server-state library) or component state (use useState).

When in doubt, start with the smallest tool. Move to Zustand or Pinia when you have multiple consumers. Reach for Redux only when you genuinely need its devtools and structure — usually in large apps with complex action flows or strong audit requirements.


Picking a state management story for a new app, or unwinding one that has grown too complex? We help teams match the tool to the actual shape of their state. scopeforged.com

Share this article

Related Articles

Need help with your project?

Let's discuss how we can help you build reliable software.