MAISON CODE .
/ Tech · React · State Management · Architecture · Performance

State Management: The Atomic vs. The Monolith

Redux is (mostly) dead. Context is a trap. A technical deep dive into modern React State Management: Zustand, Jotai, and TanStack Query.

AB
Alex B.
State Management: The Atomic vs. The Monolith

At the heart of every React application lies the “State”. If the UI is a function of state ($UI = f(state)$), then managing that state is the most critical engineering decision you make.

For years, we had Redux. It was verbose, centralized, and immutable. It worked, but you wrote more boilerplate than code. Then we had Context. It was built-in and simple. We all rushed to use it. Then our apps started slowing down. Typing in a text input caused the Sidebar to re-render. We realized: Context is a Dependency Injection tool, not a State Management tool.

In 2025, the ecosystem has matured. We now categorize state into three distinct layers, each with a specialized tool.

Why Maison Code Discusses This

We saw a client’s app re-render 4,000 times per second because of a single Context Provider. We moved them to a Tri-Layer State Architecture:

  • Server State: TanStack Query (for caching and hydration).
  • Global Client State: Zustand (for cart and theme).
  • Local State: Signals or useState (for form inputs). This reduced the main thread blocking time by 600ms and improved INP scores to <50ms. We believe state management is the #1 cause of performance death in React apps.

The Theory: The Three Layers

1. Server State (The Cache)

Data that lives on a remote server. You don’t “own” it; you “borrow” it. It can become stale.

  • Examples: User Profile, Product List, Orders.
  • Tool: TanStack Query (React Query) or SWR.
  • Anti-Pattern: Putting API data into Redux/Zustand manually. You end up reinventing caching, deduplication, and loading states.

2. Client State (The UI)

Data that exists only in the browser and is shared across components.

  • Examples: Is the Modal Open? What is the current Theme? What is in the Shopping Cart?
  • Tool: Zustand (for global stores) or Jotai (for atomic updates).

3. Local State (The Component)

Data isolated to a single component or its direct children.

  • Examples: Is the Dropdown open? What is the value of this specific input field?
  • Tool: useState / useReducer.

The Problem with Context

Why not just use createContext for everything? Because of Coarse-Grained Reactivity.

const DataContext = createContext();

function Provider({ children }) {
  const [name, setName] = useState("Alex");
  const [theme, setTheme] = useState("Dark");
  
  // This object reference changes on ANY update
  const value = { name, theme, setName, setTheme }; 

  return <DataContext.Provider value={value}>{children}</DataContext.Provider>;
}

If setName("John") is called:

  1. The value object is recreated.
  2. Every component that calls useContext(DataContext) re-renders.
  3. Even the ThemeToggler component re-renders, even though theme didn’t change!

You can optimize this with useMemo and splitting contexts (NameContext, ThemeContext), but that leads to “Context Hell” (Pyramid of Doom).

The Solution 1: Zustand (The Monolith Replacement)

Zustand is roughly “Redux without the boilerplate.” It uses a Store architecture, but solves the re-render problem with Selectors.

import { create } from 'zustand';

const useStore = create((set) => ({
  bears: 0,
  fish: 0,
  increaseBears: () => set((state) => ({ bears: state.bears + 1 })),
  increaseFish: () => set((state) => ({ fish: state.fish + 1 })),
}));

function BearCounter() {
  // SELECTOR: This component ONLY observes 'bears'
  const bears = useStore((state) => state.bears);
  return <h1>{bears} Bears</h1>;
}

If increaseFish() is called, BearCounter does not re-render. Zustand compares the return value of the selector (state.bears). If it hasn’t changed, it skips the update.

Middleware Power

Zustand has mostly 1KB size, but powerful middleware. Persist: Automatically saves state to localStorage. Immer: Allows mutable syntax (state.bears++) in updates.

import { persist } from 'zustand/middleware';

const useCartStart = create(
  persist(
    (set) => ({
      items: [],
      addItem: (id) => set((state) => ({ items: [...state.items, id] })),
    }),
    { name: 'cart-storage' } // Key in localStorage
  )
);

Access Outside Components

Crucially, Zustand stores can be accessed outside of React (e.g., in utility functions or API interceptors).

// api.ts
import { useAuthStore } from './authStore';

const token = useAuthStore.getState().token; // Non-hook access
fetch('/api', { headers: { Authorization: token } });

The Solution 2: Jotai (The Atomic Approach)

Jotai (inspired by Recoil) takes a different approach. Instead of one big store, you have thousands of tiny Atoms. State is built Bottom-Up.

import { atom, useAtom } from 'jotai';

// Declare atoms
const priceAtom = atom(10);
const quantityAtom = atom(2);

// Computed (Derived) Atom
const totalAtom = atom((get) => get(priceAtom) * get(quantityAtom));

function Cart() {
  const [total] = useAtom(totalAtom);
  return <div>Total: {total}</div>; 
}

Use Case: Highly interactive apps like a Spreadsheet, Diagram Editor, or Dashboard where dependencies are complex. If you update priceAtom, only the components listening to price or total re-render. It is surgical precision.

Server State: Dealing with Async

We strongly advocate for TanStack Query. It handles the hard parts of async state:

  • Stale-While-Revalidate: Show old data while fetching new.
  • Focus Refetching: Update data when user tabs back to the window.
  • Deduplication: If 10 components ask for User Profile, only 1 network request is made.
const { data, isLoading } = useQuery({
  queryKey: ['user', id],
  queryFn: () => fetchUser(id),
  staleTime: 1000 * 60, // Data is fresh for 1 minute
});

Mixing this with Zustand is common. Zustand holds the filter state. React Query holds the list data derived from that filter.

Summary: The Decision Matrix

RequirementRecommendationWhy?
API DataTanStack QueryCaching, Deduping, Loading states built-in.
Global UIZustandSimple, Small, Selectors prevent re-renders.
Complex GraphJotaiAtomic dependency tracking is powerful.
Form StateReact Hook FormUncontrolled inputs perform better.

10. Signals: The Future? (Preact/Solid)

React re-renders components. Signals (adopted by Preact, Solid, Vue, Angular) update the DOM directly. const count = signal(0); <div>{count}</div> When count.value++ happens, the Component function does NOT re-run. Only the text node in the DOM updates. This is O(1) complexity. React is exploring this with “React Compiler” (Forget), but Signals are a fundamentally more efficient primitive for fine-grained reactivity. We monitor this space closely. For high-performance dashboards (crypto trading), we sometimes use Preact + Signals instead of React.

11. Persistent State (Local-First)

Zustand persist is simple. But what about valid Offline-First data? We are moving towards Local-First Software (LoFi). Tools like RxDB or Replicache. The “State Manager” is actually a local database that syncs in the background. This treats the Server as a “Secondary” source of truth. The Client is Primary. This architecture makes apps feel instant (0ms latency).

13. The Observer Pattern (MobX)

Before Signals, there was MobX. It uses “Observable” objects and “Observers” (Components). It feels like magic. You mutate an object user.name = "John", and the component updates. We don’t recommend MobX for new projects because it hides too much complexity (Magic Proxies). It is notoriously hard to debug “Why did this render?”. However, for very specific, data-dense apps (like a Spreadsheet), MobX is faster than Redux because of its fine-grained dependency tracking.

14. Redux Toolkit (RTK): The Modernization

If you must use Redux (Enterprise Legacy), use RTK. It behaves like Zustand.

  • No switch statements.
  • Built-in Immer (Mutable syntax).
  • Built-in RTK Query (similar to TanStack Query). Migration from old Redux to RTK reduces code size by 60%. But if you are starting fresh: Zustand is 1KB, RTK is 40KB. Choose wisely.

15. Honoring the Fallen: Recoil

We must mention Recoil (Meta). It invented the “Atom” concept for React. But it is currently unmaintained (Meta moved to other internal tools). Jotai picked up the torch. If you have a Recoil codebase, migrate to Jotai. The API is 90% similar (useRecoilState -> useAtom). Do not start new projects with Recoil.

16. Conclusion

State Management is no longer about finding “The One Library to Rule Them All.” It is about composing specialized tools. At Maison Code, we default to the Zustand + React Query stack. It is robust, performant, and developer-friendly.


Refactoring Legacy Redux?

Is your application buried under boilerplate and slow reducers?

Hire our Architects.