Next.js Integration
Next.js runs your code in one Node process serving every request. That single fact drives everything on this page: state that lives at module scope on the server is shared by every visitor.
QuantaJS 2.1 handles this with containers. The pattern is short, and the rest of this page is variations on it.
Installation
npm install @quantajs/react @quantajs/core
# or
pnpm add @quantajs/react @quantajs/core
# or
yarn add @quantajs/react @quantajs/core
@quantajs/react preserves the 'use client' directive through its build, so
the hooks work in the App Router without a transpilePackages entry.
The rule that matters:
Never resolve a store against the ambient container in server code. On the server, create a container per request and pass it explicitly. Getting this wrong does not throw — it silently shows one visitor another visitor's data.
The pattern
1. Define stores at module scope
A defineStore definition holds no state, so importing it does nothing
per-request. This file is safe to import from anywhere, server or client.
// lib/store.ts
import { defineStore } from '@quantajs/core';
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
getters: {
doubled: (s) => s.count * 2,
},
actions: {
increment() {
this.count++;
},
},
});
2. Create a container per request in a Server Component
// app/page.tsx — Server Component
import { createContainer } from '@quantajs/core';
import { useCounterStore } from '../lib/store';
import { Providers, Counter } from './providers';
export default async function Page() {
const container = createContainer();
const counter = useCounterStore(container);
// Anything that varies per request: a DB read, the session, a fetch.
counter.count = await loadCountForUser();
const snapshot = container.dehydrate();
container.dispose();
return (
<Providers snapshot={snapshot}>
<Counter />
</Providers>
);
}
dehydrate() returns a plain, already-copied object, so the container has
served its purpose the moment you take the snapshot — dispose it right away.
3. Hand the snapshot to a client boundary
// app/providers.tsx
'use client';
import type { ReactNode } from 'react';
import type { ContainerSnapshot } from '@quantajs/core';
import { QuantaProvider, useQuanta } from '@quantajs/react';
import { useCounterStore } from '../lib/store';
export function Providers({
snapshot,
children,
}: {
snapshot: ContainerSnapshot;
children: ReactNode;
}) {
return <QuantaProvider snapshot={snapshot}>{children}</QuantaProvider>;
}
export function Counter() {
const counter = useQuanta(useCounterStore);
return (
<p>
count = {counter.count} (doubled: {counter.doubled}){' '}
<button onClick={() => counter.increment()}>increment</button>
</p>
);
}
On the client there is no container prop, so the provider creates and owns
one. That is correct in a browser: the leak containers prevent is a server
problem, and one container per tab is exactly right.
The snapshot is applied synchronously during the first render, before children mount — not in an effect. An effect runs after the first paint, so the first client render would use default state, disagree with the server's markup, and correct itself a frame later. That is the hydration mismatch this avoids.
Snapshots cross the RSC boundary as props:
Because the snapshot is a plain object, passing it from a Server Component to a
Client Component is an ordinary serialisable prop. Date, Map and Set do
not survive that serialisation — normalise them before dehydrating if your
state holds them.
Where to put the provider
Two shapes, and the choice is about whether your server-rendered state is global or per-route.
Per-route (recommended). The Server Component that owns the data creates the container and renders the provider, as above. Each route hydrates what it needs.
Root layout. Fine when the provider carries no snapshot — it simply gives the tree a container:
// app/layout.tsx
import { QuantaProvider } from '@quantajs/react';
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<QuantaProvider>{children}</QuantaProvider>
</body>
</html>
);
}
You can also skip the provider entirely in a client-only app; hooks fall back to the ambient container, which in a browser is one per tab.
No `stores` prop:
2.0.0's <QuantaProvider stores={{ user, cart }}> is gone. Stores resolve into
the provider's container lazily on first use, so you no longer enumerate them.
Server Components and Server Actions
A Server Component can read and fill a request-scoped store — that is step 2 above. What it must not do is mutate a module-scope instance:
// ❌ Cross-request leak. This object is shared by every visitor.
import { userStore } from '@/stores/user';
export async function GET(req) {
userStore.user = await getCurrentUser(req);
}
// ✅ Per request.
import { createContainer } from '@quantajs/core';
import { useUserStore } from '@/lib/stores';
export async function GET(req) {
const container = createContainer();
try {
const user = useUserStore(container);
user.profile = await getCurrentUser(req);
return Response.json(container.dehydrate());
} finally {
container.dispose();
}
}
The same goes for Server Actions. A Server Action runs on the server and returns data to the client — it should not be reaching for a store singleton to mutate. Have it return the data and let a client component write it into the store, or return a snapshot the client hydrates.
Client Components
Every file using QuantaJS hooks needs 'use client', like any hook-using file
in the App Router.
// components/UserProfile.tsx
'use client';
import { useQuanta } from '@quantajs/react';
import { useUserStore } from '@/lib/stores';
export default function UserProfile() {
const user = useQuanta(useUserStore);
if (user.load.pending) return <p>Loading…</p>;
if (!user.profile) return <p>Please log in</p>;
return (
<div>
<h1>Welcome, {user.displayName}</h1>
<button onClick={() => user.logout()}>Logout</button>
</div>
);
}
Note user.load.pending — the action lifecycle is
built in, so there is no loading field to maintain by hand.
Narrow subscriptions
useQuanta subscribes to the whole store. In a component that renders often,
subscribe to only what you read:
'use client';
import { useQuantaValue, useQuantaActions, shallow } from '@quantajs/react';
import { useCartStore } from '@/lib/stores';
export default function CartIcon() {
const { totalItems, isOpen } = useQuantaValue(
useCartStore,
(s) => ({ totalItems: s.totalItems, isOpen: s.isOpen }),
{ equalityFn: shallow },
);
const cart = useQuantaActions(useCartStore);
return (
<button onClick={() => cart.toggleCart()}>
Cart ({totalItems}){isOpen && <span>Open</span>}
</button>
);
}
Resolve stores through hooks, never by calling useCartStore() inside a
component — that bypasses the provider and reads the ambient container, which
under a per-request container would be a different store than the rest of the
page.
Persistence
Storage adapters are SSR-safe as of 2.1: they degrade to a no-op on the server
rather than throwing from their constructor, so a store with persist
configured can be imported and rendered server-side without guards.
// lib/stores/cart.ts
import { defineStore, LocalStorageAdapter } from '@quantajs/core';
export const useCartStore = defineStore('cart', {
state: () => ({ items: [] as CartItem[] }),
getters: {
totalItems: (s) => s.items.reduce((n, i) => n + i.quantity, 0),
totalPrice: (s) => s.items.reduce((n, i) => n + i.price * i.quantity, 0),
},
actions: {
addItem(product: Product) {
const existing = this.items.find((i) => i.id === product.id);
if (existing) existing.quantity++;
else this.items.push({ ...product, quantity: 1 });
},
},
persist: {
adapter: new LocalStorageAdapter('cart'),
debounceMs: 300,
},
});
If a store both persists and hydrates from SSR, the snapshot is applied first
and the persisted value restores over it. await store.$hydrated tells you
when that has finished.
TypeScript
Nothing to restate. defineStore infers state, getters and actions, and
useQuanta carries the inference through to the component.
// lib/stores/user.ts
import { defineStore } from '@quantajs/core';
interface User {
id: string;
name: string;
role: 'user' | 'admin';
}
export const useUserStore = defineStore('user', {
state: () => ({ profile: null as User | null }),
getters: {
displayName: (s) => s.profile?.name ?? 'Guest',
isAdmin: (s) => s.profile?.role === 'admin',
},
actions: {
async load(id: string) {
const res = await fetch(`/api/users/${id}`, { signal: this.$signal });
this.profile = await res.json();
},
logout() {
this.profile = null;
},
},
});
const user = useQuanta(useUserStore);
user.displayName; // string
user.isAdmin; // boolean
user.load('42'); // Promise<void>
user.load.pending; // boolean
DevTools
Opt-in as of 2.1. Enable it inside a client boundary:
'use client';
import { enableDevTools } from '@quantajs/react';
if (process.env.NODE_ENV === 'development') {
enableDevTools();
}
@quantajs/devtools is an optional peer loaded through a dynamic import, so it
stays out of your production bundle.
Troubleshooting
Hydration mismatch. Check that the snapshot reaches
<QuantaProvider snapshot={…}> and is not applied in a useEffect. Also check
that the server-side store was filled before dehydrate() was called.
One user seeing another user's data. Somewhere in server code, a store was resolved without a container. Search for definition calls with no argument in files that run on the server.
Container "…": cannot resolve store after dispose(). A store is being
resolved from a container that was already disposed — usually dispose() moved
above the render, or a client component holding a server container.
Store state resets on navigation. Each Server Component render creates a new container by design. State meant to survive navigation belongs in the client provider's container, not in a per-request one.
A complete working example
The repository ships a runnable App Router example that this page is based on,
verified in CI against a real next build:
git clone https://github.com/quanta-js/quanta
cd quanta/examples/nextjs-app
pnpm install && pnpm dev
Next Steps
- Server-Side Rendering — the framework-agnostic version
- Containers
- React Integration
- Migrating to 2.1