Back to QuantaJS Blog

Sunday, August 3, 2025

Building Scalable Next.js Apps with QuantaJS State Management

Cover image for Building Scalable Next.js Apps with QuantaJS State Management

Building Scalable Next.js Apps with QuantaJS State Management

Next.js App Router applications run server code for many users inside the same process. QuantaJS 3.x fits that model well as long as server state is scoped to a request instead of being resolved against a shared ambient container.

This guide uses the current 3.x APIs throughout.

Why QuantaJS + Next.js?

The combination works well for a few reasons:

1. Framework-agnostic core

Store definitions and containers live in @quantajs/core, so the same state model can be used from Server Components, route handlers, tests, and client components.

2. Focused React integration

@quantajs/react provides:

  • useQuanta for a whole resolved store
  • useQuantaValue for narrow state selection
  • useQuantaActions for dispatch-only components
  • useLocalStore for component-owned store state
  • QuantaProvider for client container ownership and snapshot hydration

3. Request-safe SSR

The key server pattern is explicit:

  1. create a container for the request;
  2. resolve stores against it;
  3. populate state;
  4. dehydrate a plain snapshot;
  5. dispose the server container;
  6. hydrate that snapshot at a client boundary.

4. Fine-grained rendering

Components can subscribe to only the state they render instead of waking on every store notification.

Installation

npm install @quantajs/core @quantajs/react

1. Define stores at module scope

A defineStore definition does not hold request state by itself, so it is safe to import from both server and client code.

// lib/stores/user-store.ts
import { defineStore } from '@quantajs/core';

export interface User {
  id: string;
  name: string;
  email: string;
}

export const useUserStore = defineStore('user', {
  state: () => ({
    user: null as User | null,
    preferences: {
      theme: 'light' as 'light' | 'dark',
      language: 'en',
    },
  }),
  getters: {
    displayName: (state) => state.user?.name ?? 'Guest',
    isLoggedIn: (state) => state.user !== null,
  },
  actions: {
    login(user: User) {
      this.user = user;
    },
    logout() {
      this.user = null;
    },
    updatePreferences(
      preferences: Partial<{ theme: 'light' | 'dark'; language: string }>,
    ) {
      Object.assign(this.preferences, preferences);
    },
  },
});

2. Create a container per server request

A Server Component can safely prepare store state as long as it uses a request-scoped container.

// app/page.tsx
import { createContainer } from '@quantajs/core';
import { useUserStore } from '@/lib/stores/user-store';
import { Providers } from './providers';
import { UserProfile } from './user-profile';

export default async function Page() {
  const container = createContainer();

  try {
    const user = useUserStore(container);
    user.user = await loadCurrentUser();

    const snapshot = container.dehydrate();

    return (
      <Providers snapshot={snapshot}>
        <UserProfile />
      </Providers>
    );
  } finally {
    container.dispose();
  }
}

The important part is that useUserStore(container) resolves against the request's container rather than ambient process-wide state.

3. Hydrate at a client boundary

// app/providers.tsx
'use client';

import type { ReactNode } from 'react';
import type { ContainerSnapshot } from '@quantajs/core';
import { QuantaProvider } from '@quantajs/react';

export function Providers({
  snapshot,
  children,
}: {
  snapshot: ContainerSnapshot;
  children: ReactNode;
}) {
  return <QuantaProvider snapshot={snapshot}>{children}</QuantaProvider>;
}

The snapshot is plain serializable data, so it can cross the Server Component boundary as a prop.

Hydration happens during the provider's first render, before children read store state.

4. Read state in client components

Every file that uses QuantaJS React hooks must be a client component.

// app/user-profile.tsx
'use client';

import { useQuanta } from '@quantajs/react';
import { useUserStore } from '@/lib/stores/user-store';

export function UserProfile() {
  const user = useQuanta(useUserStore);

  if (!user.isLoggedIn) {
    return <p>Please log in.</p>;
  }

  return (
    <section>
      <h2>Welcome, {user.displayName}</h2>
      <p>{user.user?.email}</p>
      <button onClick={() => user.logout()}>Logout</button>
    </section>
  );
}

Multiple stores

Larger applications can define several independent stores. They do not need to be registered with the provider ahead of time.

// lib/stores/cart-store.ts
import { defineStore } from '@quantajs/core';

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [] as CartItem[],
    isOpen: false,
  }),
  getters: {
    totalItems: (state) =>
      state.items.reduce((sum, item) => sum + item.quantity, 0),
    totalPrice: (state) =>
      state.items.reduce(
        (sum, item) => sum + item.price * item.quantity,
        0,
      ),
  },
  actions: {
    addItem(item: Omit<CartItem, 'quantity'>) {
      const existing = this.items.find((entry) => entry.id === item.id);

      if (existing) {
        existing.quantity++;
      } else {
        this.items.push({ ...item, quantity: 1 });
      }
    },
    removeItem(id: string) {
      this.items = this.items.filter((item) => item.id !== id);
    },
    toggleCart() {
      this.isOpen = !this.isOpen;
    },
  },
});

The same request container can resolve both stores on the server, and the same client provider can resolve both lazily after hydration.

Server Components and Route Handlers

Server code should never resolve user-specific state through an ambient container.

❌ Shared server state

export async function GET() {
  const user = useUserStore();
  user.user = await loadCurrentUser();

  return Response.json(user.user);
}

✅ Request-scoped state

import { createContainer } from '@quantajs/core';
import { useUserStore } from '@/lib/stores/user-store';

export async function GET() {
  const container = createContainer();

  try {
    const user = useUserStore(container);
    user.user = await loadCurrentUser();

    return Response.json(container.dehydrate());
  } finally {
    container.dispose();
  }
}

The same rule applies to Server Actions: return data or a snapshot to the client instead of mutating a process-wide store instance.

Narrow subscriptions

Use useQuantaValue when a component renders only a small slice of a store.

'use client';

import { shallow } from '@quantajs/core';
import { useQuantaActions, useQuantaValue } from '@quantajs/react';
import { useCartStore } from '@/lib/stores/cart-store';

export function CartButton() {
  const summary = useQuantaValue(
    useCartStore,
    (state) => ({
      totalItems: state.totalItems,
      isOpen: state.isOpen,
    }),
    { equalityFn: shallow },
  );

  const cart = useQuantaActions(useCartStore);

  return (
    <button onClick={() => cart.toggleCart()}>
      Cart ({summary.totalItems})
      {summary.isOpen && ' — open'}
    </button>
  );
}

A dispatch-only component can use useQuantaActions without subscribing to state at all.

Component-scoped stores

For state that belongs to one mounted component, define the store once and resolve it with useLocalStore.

'use client';

import { defineStore } from '@quantajs/core';
import { useLocalStore } from '@quantajs/react';

const useTodoDraftStore = defineStore('todo-draft', {
  state: () => ({
    text: '',
    filter: 'all' as 'all' | 'active' | 'completed',
  }),
  actions: {
    setText(text: string) {
      this.text = text;
    },
  },
});

export function TodoEditor() {
  const draft = useLocalStore(useTodoDraftStore);

  return (
    <input
      value={draft.text}
      onChange={(event) => draft.setText(event.target.value)}
    />
  );
}

Each mounted editor owns separate state.

Persistence

Browser persistence can be configured in the store definition.

import { defineStore, LocalStorageAdapter } from '@quantajs/core';

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [] as CartItem[],
  }),
  actions: {
    add(item: CartItem) {
      this.items.push(item);
    },
  },
  persist: {
    adapter: new LocalStorageAdapter('cart'),
    debounceMs: 300,
  },
});

The storage adapters are server-safe: they degrade to a no-op when browser storage is unavailable.

If the same store is hydrated from SSR and restored from persistence, the server snapshot is applied first and persisted state restores afterward. await store.$hydrated tells you when that process is complete.

Async actions

QuantaJS exposes action lifecycle state directly.

export const useProfileStore = defineStore('profile', {
  state: () => ({
    profile: null as User | null,
  }),
  actions: {
    async load(id: string) {
      const response = await fetch(`/api/users/${id}`, {
        signal: this.$signal,
      });

      if (!response.ok) {
        throw new Error('Could not load profile');
      }

      this.profile = await response.json();
    },
  },
});
'use client';

import { useQuanta } from '@quantajs/react';

function Profile() {
  const profile = useQuanta(useProfileStore);

  if (profile.load.pending) return <p>Loading…</p>;
  if (profile.load.error) return <p>Could not load profile.</p>;

  return <p>{profile.profile?.name}</p>;
}

Root provider vs route provider

A root provider is useful when all state is client-owned and no request-specific snapshot is needed:

// app/layout.tsx
import { QuantaProvider } from '@quantajs/react';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <QuantaProvider>{children}</QuantaProvider>
      </body>
    </html>
  );
}

For request-specific server state, prefer the per-route pattern shown earlier so the server snapshot is created close to the data it represents.

TypeScript

defineStore infers state, getters, and actions.

const useSettingsStore = defineStore('settings', {
  state: () => ({
    theme: 'system' as 'light' | 'dark' | 'system',
  }),
  actions: {
    setTheme(theme: 'light' | 'dark' | 'system') {
      this.theme = theme;
    },
  },
});

React hooks preserve that type information:

const theme = useQuantaValue(
  useSettingsStore,
  (state) => state.theme,
);

const settings = useQuantaActions(useSettingsStore);
settings.setTheme('dark');

Testing

For store tests, create a fresh container per test.

import { createContainer } from '@quantajs/core';

it('logs out', () => {
  const container = createContainer();

  try {
    const user = useUserStore(container);
    user.user = {
      id: '1',
      name: 'Ada',
      email: 'ada@example.com',
    };

    user.logout();

    expect(user.user).toBeNull();
  } finally {
    container.dispose();
  }
});

For component tests, wrap the component in QuantaProvider just as the real application does.

Performance checklist

  • Use useQuantaValue when a component reads only a slice of state.
  • Use useQuantaActions for dispatch-only components.
  • Keep store definitions at module scope.
  • Use useLocalStore for component-owned state.
  • Create one server container per request.
  • Dehydrate before disposing the server container.
  • Keep snapshots serializable.
  • Measure before splitting subscriptions further.

Common troubleshooting

Hydration mismatch

Make sure the server fills store state before calling dehydrate(), and pass that snapshot directly to QuantaProvider rather than applying it later in an effect.

One user's data appears in another request

Search server code for store definitions being resolved without an explicit container.

State resets on navigation

Decide whether that state is request-owned or browser-owned. Request-scoped server containers are intentionally short-lived. State that must survive client navigation belongs in the client provider's container or persistence layer.

A container reports that it is disposed

The code is trying to resolve or use request-scoped state after the server container's lifecycle ended. Take the snapshot before disposal and do not pass the container itself to a client component.

Conclusion

QuantaJS 3.x and Next.js work best when ownership is explicit:

  • store definitions are module-scoped;
  • server state lives in a container created for one request;
  • snapshots cross the Server Component boundary as data;
  • client components resolve state through QuantaProvider;
  • subscriptions are narrowed only where it matters.

That model keeps Next.js state predictable, prevents cross-request leaks, and scales from small client-only pages to SSR-heavy applications.

For the framework reference and runnable example, see the Next.js integration guide.