7 min read

Type‑Safe State Sync Between Server and Client: React Server Components Meet Zustand

A practical guide to keeping server‑rendered state and client‑side Zustand stores perfectly in sync, with full TypeScript safety.
Type‑Safe State Sync Between Server and Client: React Server Components Meet Zustand

Introduction

Modern React applications are increasingly hybrid: React Server Components (RSC) render the heavy, data‑driven parts on the server, while lightweight client components handle interactivity. This split gives you fast first‑paint times and reduced bundle size, but it also creates a classic problem—how do you keep the server‑generated state and the client‑side store consistent without sacrificing type safety?

Enter Zustand, a tiny, unopinionated state manager that works equally well on the server and the client. By defining the store with TypeScript generics and serialising the initial state from the server, you can achieve a seamless, type‑safe bridge between RSC and client components.

In this article we’ll:

  1. Review the constraints of RSC regarding state.
  2. Show how to type‑safely create a shared Zustand store.
  3. Demonstrate a concrete e‑commerce cart example that syncs server‑rendered data with client interactivity.
  4. Discuss pitfalls (serialization, mutation, hydration) and how to avoid them.
TL;DR – Define a generic Zustand store, expose a getServerState helper for RSC, and hydrate the client store with the same type‑checked payload.

1. The State‑Sync Challenge with React Server Components

RSC run only on the server and may stream JSX to the client. They cannot use browser‑only APIs (e.g., window, localStorage) nor maintain persistent client state. Consequently:

Server (RSC) Client (Client Component)
Fetches data with await in the component body Handles clicks, form inputs, UI animations
Returns read‑only JSX Calls hooks like useState, useEffect
No direct access to React context created on the client Must receive data via props or a shared store

If a server component renders a product list with prices, a client component that adds an item to a cart needs to share the same source of truth. Throwing data through props works for static pages, but for interactive flows (cart updates, live inventory) you quickly end up with duplicated fetches or race conditions.

The goal: One source of truth that both the server and the client can read, while the client can also write to it. The store must be:

  • Typed – compile‑time guarantees that the shape of the state matches the API contract.
  • Serializable – server‑side state can be embedded in the HTML payload.
  • Isolated per request – no cross‑user leakage in a multi‑tenant server.

2. Why Zustand Fits the Bill

Zustand’s API is a single function create<T>() that returns a hook (useStore) and a plain store object. There is no built‑in provider component, which means you can:

  • Instantiate a store per request on the server.
  • Export the same store instance to client components after hydration.
  • Leverage TypeScript inference for actions and state.

Unlike Redux or Recoil, Zustand does not enforce a global singleton, so you can safely create a request‑scoped store on the server and then reuse its snapshot on the client.


3. Defining a Type‑Safe Store

First, let’s define the shape of the cart state and actions in a reusable store.ts file.

// store.ts
import { createStore, StoreApi } from 'zustand';
import { immer } from 'zustand/middleware/immer';

// 1️⃣ Define the state shape
export interface CartItem {
  id: string;
  name: string;
  price: number; // cents to avoid floating point issues
  quantity: number;
}

// 2️⃣ Define the store interface (state + actions)
export interface CartStore {
  items: Record<string, CartItem>;
  // derived data – not stored, but computed
  totalCents: () => number;
  // actions
  addItem: (item: Omit<CartItem, 'quantity'>, quantity?: number) => void;
  removeItem: (id: string) => void;
  setQuantity: (id: string, quantity: number) => void;
}

// 3️⃣ Factory that creates a *new* store for each request
export const createCartStore = (initial?: Partial<CartStore>) =>
  createStore<CartStore>()(
    immer((set, get) => ({
      items: initial?.items ?? {},
      totalCents: () =>
        Object.values(get().items).reduce(
          (sum, i) => sum + i.price * i.quantity,
          0,
        ),
      addItem: (item, qty = 1) =>
        set((state) => {
          const existing = state.items[item.id];
          const quantity = (existing?.quantity ?? 0) + qty;
          state.items[item.id] = { ...item, quantity };
        }),
      removeItem: (id) =>
        set((state) => {
          delete state.items[id];
        }),
      setQuantity: (id, quantity) =>
        set((state) => {
          if (quantity <= 0) {
            delete state.items[id];
          } else {
            const itm = state.items[id];
            if (itm) itm.quantity = quantity;
          }
        }),
    })),
  );

Key Points

  • immer middleware gives us mutable‑style updates while keeping immutability under the hood.
  • The store is pure TypeScript – no any, no unknown. The compiler will flag any misuse of addItem arguments.
  • totalCents is a selector function that computes derived data on demand.

4. Server‑Side: Rendering with React Server Components

Assume we have a Next.js 13+ app (app/) using the app router. The server component fetches product data and wants to render the cart summary.

// app/cart/CartSummary.server.tsx
import { createCartStore } from '@/store';
import { getCartFromDB } from '@/lib/cart';
import { use } from 'react';

// 1️⃣ Create a request‑scoped store
const cartStore = createCartStore();

// 2️⃣ Populate it with persisted data (e.g., from a cookie or DB)
export const CartSummary = async () => {
  const persisted = await getCartFromDB(); // returns Record<string, CartItem>
  cartStore.getState().addItem = (item, qty = 1) => {
    // No‑op on server – we only need the data, not UI mutations
  };
  // Directly seed the store (bypass actions for speed)
  cartStore.setState({ items: persisted });

  // 3️⃣ Use the store in a server‑component context via `use`
  const total = use(cartStore.getState().totalCents);

  return (
    <section>
      <h2>Cart Summary (Server)</h2>
      <p>Total: ${(total / 100).toFixed(2)}</p>
    </section>
  );
};

// 4️⃣ Export the *snapshot* for the client to hydrate
export const getCartState = () => ({
  items: cartStore.getState().items,
});

Explanation

  • use(cartStore.getState().totalCents) works because use is the RSC‑specific hook that lets you run arbitrary functions during rendering. It does not create a client hook; it simply evaluates the selector on the server.
  • The helper getCartState will be imported by a client component to initialise its own copy of the store.

5. Client‑Side: Hydrating the Store

Now we build a client component that lets users add or remove items. The component imports the server‑side snapshot and re‑creates the same store.

// components/CartClient.tsx
'use client';

import { createCartStore, CartStore } from '@/store';
import { useEffect, useState } from 'react';
import { getCartState } from '@/app/cart/CartSummary.server';

// 1️⃣ Re‑create the store (singleton for the page)
const cartStore = createCartStore();

// 2️⃣ Hydrate it once on mount
export const CartClient = () => {
  const [hydrated, setHydrated] = useState(false);

  useEffect(() => {
    // The server‑side snapshot is embedded in the HTML via a <script> tag
    const payload = (window as any).__CART_STATE__ as ReturnType<typeof getCartState>;
    if (payload) {
      cartStore.setState({ items: payload.items });
    }
    setHydrated(true);
  }, []);

  // Subscribe to the store for UI rendering
  const items = cartStore((s) => Object.values(s.items));
  const total = cartStore((s) => s.totalCents());

  if (!hydrated) return null; // avoid flash of wrong total

  return (
    <section>
      <h2>Cart (Client)</h2>
      <ul>
        {items.map((i) => (
          <li key={i.id}>
            {i.name} – ${(i.price / 100).toFixed(2)} × {i.quantity}
            <button onClick={() => cartStore.getState().setQuantity(i.id, i.quantity - 1)}>-</button>
            <button onClick={() => cartStore.getState().setQuantity(i.id, i.quantity + 1)}>+</button>
            <button onClick={() => cartStore.getState().removeItem(i.id)}>Remove</button>
          </li>
        ))}
      </ul>
      <p>
        <strong>Total: ${(total / 100).toFixed(2)}</strong>
      </p>
    </section>
  );
};

Embedding the Server Snapshot

In the page layout we can inject the JSON payload safely:

// app/cart/page.tsx
import { CartSummary, getCartState } from './CartSummary.server';
import { CartClient } from '@/components/CartClient';

export default async function CartPage() {
  const snapshot = await getCartState();

  return (
    <>
      <CartSummary />
      {/* Hydration script */}
      <script
        dangerouslySetInnerHTML={{
          __html: `window.__CART_STATE__ = ${JSON.stringify(snapshot)};`,
        }}
      />
      <CartClient />
    </>
  );
}

Because snapshot is derived from the same CartStore type, any change to the store’s shape instantly propagates to both server and client code—the compiler catches mismatches.


6. Making the Pattern Reusable

For larger apps you’ll want a generic helper that hides the boilerplate.

// lib/ssrZustand.ts
import { StoreApi, UseBoundStore } from 'zustand';

export const createIsomorphicStore = <S extends object>(
  factory: (initial?: Partial<S>) => UseBoundStore<StoreApi<S>>,
) => {
  // Server: create a fresh instance per request
  const serverStore = factory();

  // Client: singleton that will be hydrated later
  const clientStore = factory();

  const getServerSnapshot = () => serverStore.getState();

  const useHydratedStore = (selector: (state: S) => any) => clientStore(selector);

  return { serverStore, clientStore, getServerSnapshot, useHydratedStore };
};

Usage:

// store/cart.ts
import { createIsomorphicStore } from '@/lib/ssrZustand';
import { createCartStore } from '@/store';

export const {
  serverStore: serverCart,
  clientStore: clientCart,
  getServerSnapshot: getCartSnapshot,
  useHydratedStore: useCart,
} = createIsomorphicStore(createCartStore);

Now the server component does:

import { serverCart, getCartSnapshot } from '@/store/cart';

export const CartSummary = async () => {
  // ...seed serverCart
  const total = serverCart.getState().totalCents();
  // render...
};

export const getCartState = getCartSnapshot;

And the client component:

import { clientCart, useCart } from '@/store/cart';

export const CartClient = () => {
  const items = useCart((s) => Object.values(s.items));
  const total = useCart((s) => s.totalCents());

  // UI...
};

All type information is shared automatically.


7. Common Pitfalls & How to Avoid Them

Problem Cause Fix
State leakage between users Using a global singleton on the server. Always create a new store per request (createCartStore() inside the server component).
Hydration mismatch warnings Serialising functions or non‑JSON‑compatible values. Store only plain data (no class instances, Dates → ISO strings).
Performance hit from large payloads Shipping the entire store snapshot to the client. Keep the snapshot minimal; only send what the client needs (e.g., items).
Stale server data after client mutation Client updates are never persisted back to the DB. Implement an API route (/api/cart) that receives mutations and writes them server‑side; optionally re‑validate with revalidatePath.
Type divergence Manually editing the snapshot shape. Derive the snapshot type directly from ReturnType<typeof getCartSnapshot>; never copy‑paste.

8. Bonus: Validation with Zod (Optional)

If you already use Zod for request validation, you can reuse the schema to guarantee the server snapshot conforms to the expected type.

import { z } from 'zod';
import { CartItem } from '@/store';

export const CartItemSchema = z.object({
  id: z.string(),
  name: z.string(),
  price: z.number().int(),
  quantity: z.number().int().positive(),
});

export const CartSnapshotSchema = z.object({
  items: z.record(CartItemSchema),
});

When you fetch from the DB:

const raw = await db.cart.findMany(...);
const snapshot = CartSnapshotSchema.parse({ items: raw });

The store receives a typed object, and the client can safely assume the same shape.


9. Recap

  1. Define a typed Zustand store with actions and derived selectors.
  2. Instantiate a fresh store on the server, seed it with persisted data, and expose a snapshot helper.
  3. Embed the snapshot in the HTML and hydrate a client‑side store that shares the same type definition.
  4. Use the store in client components with the regular useStore hook; the UI stays in sync with server‑rendered output.
  5. Guard against pitfalls by keeping the store request‑scoped, serializable, and validated.

By following this pattern you get the best of both worlds: instant server‑rendered UI plus full client interactivity, all under the watchful eye of TypeScript’s type system.