React & Frontend14 min readSeptember 4, 2026

State Management in Modern React: Zustand vs Context vs Server State

Stop putting API data in Redux or global context. Learn how modern React architecture cleanly separates server cache from client UI state.

Nazmul Hawlader
Nazmul Hawlader
Senior Shopify & Full-Stack Engineer

The Architecture Shift: Server Cache vs. Transient Client State

The historical mistake is easy to reconstruct. In the Redux era, every API response went into the store, because the store was the only place components could share data. Then Context arrived and we did the same thing with less ceremony. Ten years on, a lot of production stores still look like this:

MARKDOWN SNIPPET
 BEFORE: everything is "state"                 AFTER: each kind of state has an owner

 ┌──────────── one global store ─────────┐     ┌── Remix loaders / actions ──┐
 │ products[]   orders[]   user          │     │ server data: read, mutate,  │
 │ isLoading    error      cart[]        │     │ revalidate                  │
 │ modalOpen    wizardStep  filters      │     └─────────────────────────────┘
 │ theme        toasts      draft        │     ┌── Zustand (small stores) ───┐
 └───────────────────────────────────────┘     │ wizard, drawer, player      │
                                               └─────────────────────────────┘
                                               ┌── React Context ────────────┐
                                               │ theme, locale, session      │
                                               └─────────────────────────────┘

When we audit a codebase, the large majority of what sits in the global store (in our experience often 80% or more) is a remote cache: a temporary, possibly stale copy of data the server owns. Cache semantics are hard, since you need deduplication, cancellation, staleness rules and invalidation, and store-based fetching reimplements all of them, usually badly, in every slice.

Remix removes that whole category of problem by moving it into the router:

  • Loaders run on navigation, in parallel, on the server. The component receives resolved data with useLoaderData(). There's no isLoading flag and no fetch-in-useEffect.
  • Actions handle mutations through <Form> and useFetcher, using plain web-standard FormData and Request.
  • Automatic revalidation re-runs the active loaders after every action, so the UI reflects the server without hand-written invalidation.

What's left after that is small: state the client owns, meaning interaction state that never needs to be on a server. That's what the rest of this article is about.

The Problem with React Context for Frequent Updates

Context is dependency injection. It's built to deliver low-frequency values (theme, auth session, locale) to any depth without prop threading. It isn't a subscription system, and that distinction is where teams get hurt.

The mechanics of the trap

When a Provider renders, React compares its new value to the old one with Object.is. If they differ, React walks the subtree and schedules a re-render for every component that read that context. Two consequences follow:

  • No selectors. useContext(Ctx) subscribes to the whole value. A component that only reads cart.length re-renders when sidebarOpen changes.
  • React.memo doesn't help. A memoized parent can skip its own render, but the context change propagates through it to the consumers below.

Here's the shape we find in the wild:

TYPESCRIPT SNIPPET
// ❌ "Megastore" context
interface AppContextValue {
  user: SessionUser | null;
  cartCount: number;
  toasts: Toast[];
  drawerOpen: boolean;
  setDrawerOpen: (open: boolean) => void;
  pushToast: (t: Toast) => void;
}

const AppContext = createContext<AppContextValue | null>(null);

export function AppProvider({ children }: { children: ReactNode }) {
  const [drawerOpen, setDrawerOpen] = useState(false);
  const [toasts, setToasts] = useState<Toast[]>([]);
  // ...user and cartCount come from somewhere

  return (
    <AppContext.Provider
      value={{ user, cartCount, toasts, drawerOpen, setDrawerOpen,   // new object every render
               pushToast: (t) => setToasts((ts) => [...ts, t]) }}
    >
      {children}
    </AppContext.Provider>
  );
}

A <CartBadge /> that reads only cartCount now re-renders every time a toast appears or the drawer opens. The inline object literal makes it worse, since even an unrelated re-render of the provider's parent hands every consumer a new value.

A profile walkthrough

Here's how we reproduce this in React DevTools:

  • Components tab → settings → "Highlight updates when components render." Toggle the drawer and watch the whole page flash.
  • Profiler tab → settings → "Record why each component rendered while profiling."
  • Record, click the drawer toggle once, and stop.
  • In the flamegraph, every context consumer is colored, and hovering shows "The context changed." The ranked chart shows a consumer that has nothing to do with drawers, such as CartBadge, committed along with the rest.

That last observation is the diagnosis. The cost isn't any single render, but that render count scales with consumers, not with what changed.

What we do about it

Memoizing the value (useMemo) stops re-renders from unrelated parent renders, but any dependency change still re-renders everyone. Splitting into ten contexts isolates updates at the price of provider hell and still no selectors. So we keep Context for what it's good at: small, stable values.

TYPESCRIPT SNIPPET
// ✅ Context for low-frequency injection: small, memoized, single concern
const SessionContext = createContext<{ user: SessionUser | null } | null>(null);

export function SessionProvider({ user, children }: { user: SessionUser | null; children: ReactNode }) {
  const value = useMemo(() => ({ user }), [user]);
  return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
}

export function useSession() {
  const ctx = useContext(SessionContext);
  if (!ctx) throw new Error("useSession must be used inside <SessionProvider>");
  return ctx;
}

In our Remix apps, the user in this provider comes straight from the root loader, so it's server data delivered through Context, changing only on login and logout. Anything that changes more often than that gets a different tool.

Zustand: The Precision Tool for Client UI State

Zustand's store is a plain object living outside the React tree, exposing getState, setState and subscribe. Its React hook uses useSyncExternalStore, React's primitive for subscribing to external data safely under concurrent rendering. Each hook call takes a selector, and on every store change React re-runs that selector and compares the result with Object.is. The component re-renders only if its selected value changed. That's the selector capability Context lacks, and it needs no provider.

Three properties make it a good fit for what remains after the server data moves out:

  • Atomic selectors give fine-grained subscriptions.
  • Outside-React access through getState() lets a fetch wrapper open a modal without hooks.
  • Tiny footprint (around 1 KB gzipped) means we don't hesitate to create several small stores instead of one big one.

A real flow: multi-step checkout wizard

The wizard's draft (which step we're on, what's been typed) is transient client state. The addresses to choose from and the cart are server data. The boundary between them is the design decision, so here's how we draw it. The store keeps only the ID of the chosen address, never a copy of the address.

TYPESCRIPT SNIPPET
// app/stores/wizard-store.ts
import { create } from "zustand";
import { devtools } from "zustand/middleware";

export const WIZARD_STEPS = ["contact", "shipping", "review"] as const;
export type WizardStep = (typeof WIZARD_STEPS)[number];

// `type`, not `interface`, so it stays assignable to JSON when submitted
export type WizardDraft = { email: string; addressId: string; note: string };

interface WizardState {
  step: WizardStep;
  draft: WizardDraft;
  patch: (partial: Partial<WizardDraft>) => void;
  next: () => void;
  back: () => void;
  reset: () => void;
}

const initialDraft: WizardDraft = { email: "", addressId: "", note: "" };

const move = (step: WizardStep, delta: 1 | -1): WizardStep => {
  const i = WIZARD_STEPS.indexOf(step) + delta;
  return WIZARD_STEPS[Math.min(Math.max(i, 0), WIZARD_STEPS.length - 1)];
};

export const useWizardStore = create<WizardState>()(
  devtools(
    (set) => ({
      step: "contact",
      draft: initialDraft,
      patch: (partial) =>
        set((s) => ({ draft: { ...s.draft, ...partial } }), false, "wizard/patch"),
      next: () => set((s) => ({ step: move(s.step, 1) }), false, "wizard/next"),
      back: () => set((s) => ({ step: move(s.step, -1) }), false, "wizard/back"),
      reset: () => set({ step: "contact", draft: initialDraft }, false, "wizard/reset"),
    }),
    { name: "wizard", enabled: import.meta.env.DEV },
  ),
);
TYPESCRIPT SNIPPET
// app/stores/drawer-store.ts (a second, separate store)
import { create } from "zustand";

type DrawerId = "cart" | "filters";

interface DrawerState {
  open: DrawerId | null;
  openDrawer: (id: DrawerId) => void;
  closeDrawer: () => void;
}

export const useDrawerStore = create<DrawerState>()((set) => ({
  open: null,
  openDrawer: (open) => set({ open }),
  closeDrawer: () => set({ open: null }),
}));

The Remix route owns the server side: the loader supplies the cart and addresses, and the action validates and places the order.

TYPESCRIPT SNIPPET
// app/routes/checkout.tsx
import { data, redirect } from "@remix-run/node";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { useFetcher, useLoaderData } from "@remix-run/react";
import { useEffect } from "react";
import { z } from "zod";
import { useWizardStore } from "~/stores/wizard-store";
import { getCart, listAddresses, placeOrder, requireUser } from "~/data.server";

export async function loader({ request }: LoaderFunctionArgs) {
  const user = await requireUser(request);
  const [cart, addresses] = await Promise.all([getCart(user.id), listAddresses(user.id)]);
  return { cart, addresses };
}

const CheckoutInput = z.object({
  email: z.string().email(),
  addressId: z.string().min(1),
  note: z.string().max(500),
});

export async function action({ request }: ActionFunctionArgs) {
  const user = await requireUser(request);
  const parsed = CheckoutInput.safeParse(await request.json());
  if (!parsed.success) {
    return data({ errors: parsed.error.flatten().fieldErrors }, { status: 400 });
  }
  const order = await placeOrder(user.id, parsed.data);   // server re-validates addressId belongs to user
  return redirect(`/orders/${order.id}`);
}

export default function Checkout() {
  const { cart, addresses } = useLoaderData<typeof loader>();
  const step = useWizardStore((s) => s.step);            // atomic selector
  const reset = useWizardStore((s) => s.reset);

  useEffect(() => () => reset(), [reset]);               // transient state must not outlive the flow

  return (
    <main>
      <StepIndicator />
      {step === "contact" && <ContactStep />}
      {step === "shipping" && <ShippingStep addresses={addresses} />}
      {step === "review" && <ReviewStep totalFormatted={cart.totalFormatted} />}
    </main>
  );
}

Selectors in practice

The steps show the subscription discipline. ShippingStep re-renders when the chosen address changes, and StepIndicator re-renders only when the step changes. Typing in the contact form re-renders neither.

TYPESCRIPT SNIPPET
import { useShallow } from "zustand/react/shallow";

function StepIndicator() {
  const step = useWizardStore((s) => s.step);            // not affected by keystrokes in `draft`
  return <ol>{WIZARD_STEPS.map((s) => <li key={s} aria-current={s === step}>{s}</li>)}</ol>;
}

function ContactStep() {
  const email = useWizardStore((s) => s.draft.email);
  const { patch, next } = useWizardStore(useShallow((s) => ({ patch: s.patch, next: s.next })));
  return (
    <form onSubmit={(e) => { e.preventDefault(); next(); }}>
      <input type="email" required value={email} onChange={(e) => patch({ email: e.target.value })} />
      <button>Continue</button>
    </form>
  );
}

function ShippingStep({ addresses }: { addresses: Array<{ id: string; label: string }> }) {
  const addressId = useWizardStore((s) => s.draft.addressId);
  const { patch, next, back } = useWizardStore(
    useShallow((s) => ({ patch: s.patch, next: s.next, back: s.back })),
  );
  // Derive the object from server data. The store only holds the id.
  const selected = addresses.find((a) => a.id === addressId) ?? null;

  return (
    <fieldset>
      {addresses.map((a) => (
        <label key={a.id}>
          <input type="radio" checked={a.id === addressId} onChange={() => patch({ addressId: a.id })} />
          {a.label}
        </label>
      ))}
      <button type="button" onClick={back}>Back</button>
      <button type="button" disabled={!selected} onClick={next}>Continue</button>
    </fieldset>
  );
}

function ReviewStep({ totalFormatted }: { totalFormatted: string }) {
  const draft = useWizardStore((s) => s.draft);
  const back = useWizardStore((s) => s.back);
  const fetcher = useFetcher<typeof action>();
  const submitting = fetcher.state !== "idle";
  const errors = fetcher.data && "errors" in fetcher.data ? fetcher.data.errors : null;

  return (
    <section>
      <p>Total: {totalFormatted}</p>
      {errors && <ul>{Object.values(errors).flat().map((m) => <li key={m}>{m}</li>)}</ul>}
      <button type="button" onClick={back}>Back</button>
      <button
        type="button"
        disabled={submitting}
        onClick={() => fetcher.submit(draft, { method: "post", encType: "application/json" })}
      >
        {submitting ? "Placing order…" : "Place order"}
      </button>
    </section>
  );
}

Note the division of labor: the wizard flow is instantaneous, purely client-side and doesn't touch the server, while the final submit is a normal Remix action with validation, a redirect and fetcher.state for pending UI. The wizard needs JavaScript, and we accept that for this flow. For flows that must work without JS, we render a single <Form> with hidden inputs instead.

Note: Outside React: because the store is external, non-React code can use it directly. Our fetch wrapper does useDrawerStore.getState().closeDrawer() before a hard redirect, with no hook and no provider.

Architectural Comparison

Here's how data flows through each tool. The key difference is what triggers an update and who is the source of truth.

MARKDOWN SNIPPET
1) SERVER STATE: Remix loaders & actions
   (navigation-driven, the server is the source of truth)

      URL change ──▶ loader() ──▶ useLoaderData() ──▶ render
          ▲                                             │
          │        automatic revalidation               │  <Form> / fetcher.submit
          └────────────── action() ◀────────────────────┘

2) TRANSIENT CLIENT STATE: Zustand
   (event-driven, the client is the source of truth)

      event handler ──▶ store.setState()
      non-React code ──▶ store.getState()/subscribe()
                              │
                              ▼
              for each subscriber: selector(prev) === selector(next)?
                  ├─ equal ──▶ nothing happens
                  └─ changed ─▶ re-render THAT component only

3) STATIC APP CONTEXT: React Context
   (provider-driven, changes rarely)

      <Provider value={v}> ──▶ Object.is(prev, v)?
                                 ├─ equal ──▶ nothing happens
                                 └─ changed ─▶ re-render EVERY consumer

When to use which

Our decision path, top to bottom:

MARKDOWN SNIPPET
Is it a copy of something the server owns?
 ├─ yes ─▶ Remix loader (+ action for changes)
 └─ no
    Should it survive reload, be linkable, or work with Back?
     ├─ yes ─▶ URL search params (read them in the loader)
     └─ no
        Is it used by one component or a small subtree?
         ├─ yes ─▶ useState / useReducer
         └─ no
            Does it change rarely (theme, locale, session)?
             ├─ yes ─▶ React Context (small, memoized)
             └─ no ──▶ Zustand store with atomic selectors

A few rules of thumb sit on top of it:

  • Use Remix loaders for anything that would otherwise need an isLoading flag.
  • Use the URL for filters, sorting, pagination and tabs. A global store there breaks Back, link sharing and server rendering.
  • Use Zustand for interaction state shared across distant components: drawers, wizards, playback, selection.
  • Use Context for values that change at most a few times per session.

Best Practices and Production Pitfalls

Don't synchronize server data into Zustand

The most common bug we see in code review:

TYPESCRIPT SNIPPET
// ❌ Mirroring loader data into a store
const { items } = useLoaderData<typeof loader>();
const setItems = useItemsStore((s) => s.setItems);
useEffect(() => { setItems(items); }, [items, setItems]);   // extra render, two sources of truth

Every revalidation now renders twice, the store lags a frame behind, and the two copies can disagree. If a component needs the data, read it from useLoaderData. If a distant component needs it, pass it down, use the route's data via useRouteLoaderData, or lift it into Context (if it's stable).

The pattern we use for selection-style UI is to store identifiers and derive objects at render time:

TYPESCRIPT SNIPPET
const selectedIds = useSelectionStore((s) => s.selectedIds);         // Set<string> of ids only
const { items } = useLoaderData<typeof loader>();
const selectedItems = items.filter((i) => selectedIds.has(i.id));    // reconciles automatically

When a revalidation removes an item, the derived list drops it, with no manual cleanup and no ghost selection.

Hydration and SSR

Remix renders on the server, so client stores need care:

  • Module-level stores are singletons on the server. Every request in the same process shares them. That's safe for pure UI interaction state whose initial value is the same for everyone and is only mutated in client event handlers (our wizard and drawer). It's unsafe for anything user-specific. For user-seeded state, create the store per request with createStore inside a useState(() => ...) initializer and provide it via context.
  • Zustand's server snapshot uses the store's initial state, so server HTML and the first client render agree. Problems start when something else changes that first render.
  • Persisted stores need deferred hydration. localStorage doesn't exist on the server, so a persist-ed store must use skipHydration: true and call persist.rehydrate() in an effect after mount, or you'll get a hydration mismatch.
  • Transient state can outlive its route, because the store outlives components. That's why the wizard resets in an effect cleanup.

Treat loader data as a fresh snapshot

After each revalidation, expect new object identities in useLoaderData(), even for unchanged records. Memoize children by ids and primitive props, not by whole loader objects, or React.memo will never hit.

Profiling checklist

  • Profile with "Record why each component rendered" on, and look for "The context changed" and "Hooks changed" as reasons.
  • After you fix a hot path, wrap the consumers in <Profiler> and count commits in a test, so a regression fails the build. (Profiler is a no-op in standard production builds.)
  • Grep for useStore() with no selector. It subscribes to the whole store and defeats the point.
  • In Zustand v5, selectors that return a new object or array each call without useShallow cause an infinite render loop. Treat that error as a bug report, not an obstacle.
  • Test in React StrictMode. It exposes effects that assume they run exactly once.

Engineering Takeaway

The performance problems and bugs in most React state layers come from one root cause: one tool doing three jobs. A global store that caches server data, holds interaction state and injects configuration will be wrong for all three.

Draw the boundaries explicitly:

  • Server state belongs to Remix. Loaders read, actions write, revalidation reconciles, and no client store mirrors it.
  • Transient client state belongs to small Zustand stores. Subscribe narrowly with atomic selectors, store ids instead of copies, and reset when the flow ends.
  • Static app context belongs to React Context, used sparingly for values that almost never change.

With those boundaries in place, re-renders track what actually changed, data can't drift between two owners, and new engineers can tell where a piece of state lives by asking what kind of state it is. That predictability is the real payoff, more than any benchmark.

Nazmul Hawlader

Written by Nazmul Hawlader

Top Rated

Senior Full-Stack Engineer & Official Shopify App Store developer. Founder of Stockly and Kilo (kilo.nazmulcodes.org), specializing in high-performance Shopify apps, client-side media compression, and sub-second web performance.

Recommended Related Articles