Back to QuantaJS Blog

Saturday, September 26, 2026

Announcing QuantaJS 3.0: One Store for Every Framework

Cover image for Announcing QuantaJS 3.0: One Store for Every Framework

Announcing QuantaJS 3.0

QuantaJS 3.0.0 is out, and it is the release the name "framework-agnostic" was always promising.

Until now, @quantajs/core ran anywhere, but React was the only framework with official bindings. 3.0 adds Vue, Svelte and Lit, and an Astro integration that lets islands written in different frameworks share one store. A store you define once now works in all of them, with the same containers, server rendering and persistence.

It is a major version for two reasons. The public API is smaller: things that were deprecated, duplicated or internal are gone. And every package now shares one version number, so the question "which @quantajs/react goes with which @quantajs/core?" no longer exists. The answer is always "the same one".


One store, five frameworks

Here is a store:

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

export const useCart = defineStore('cart', {
  state: () => ({ items: [] as string[] }),
  getters: { count: (s) => s.items.length },
  actions: {
    add(item: string) {
      this.items.push(item);
    },
  },
});

And here is the same button in each framework. Every binding has the same shape: read a value through a selector, get actions without subscribing.

import { useQuantaActions, useQuantaValue } from '@quantajs/react';
import { useCart } from './stores/cart';

export function CartButton() {
  const count = useQuantaValue(useCart, (s) => s.count);
  const cart = useQuantaActions(useCart);
  return <button onClick={() => cart.add('tea')}>{count} items</button>;
}

In each of them, the button re-renders when count changes and at no other time. The selector runs inside QuantaJS's own dependency tracking, so this holds even when you mutate an array in place.

PackageForGuide
@quantajs/reactReact 18 and 19, Next.js App RouterReact
@quantajs/vueVue 3.3 and laterVue
@quantajs/svelteSvelte 4 and 5, SvelteKitSvelte
@quantajs/litLit 2 and 3, web componentsLit
@quantajs/astroAstro 7 islandsAstro

Two of these deserve their own posts:

  • Astro. Islands are isolated by design, and Astro's own recipe for sharing state notes that a store written on the server does not reach client islands. @quantajs/astro gives each request its own container and carries its state into every island, whatever framework it uses. One store, every island.
  • Lit. Web components have had no settled answer for shared state. @quantajs/lit brings stores to them as reactive controllers. State management for web components.

Server state without passing containers around

2.1 introduced containers: one per request, so concurrent requests never share state. The catch was that you had to pass the container everywhere, which falls apart as soon as a framework renders your components for you.

setDefaultContainerResolver lets a server decide which container is the default for the code running right now:

import { AsyncLocalStorage } from 'node:async_hooks';
import {
  createContainer,
  setDefaultContainerResolver,
  type StoreContainer,
} from '@quantajs/core';

const requests = new AsyncLocalStorage<StoreContainer>();
setDefaultContainerResolver(() => requests.getStore());

export async function handle(request: Request) {
  const container = createContainer();
  try {
    // Anything in here, including components rendered on the server,
    // resolves stores against this request's container.
    return await requests.run(container, () => render(request));
  } finally {
    container.dispose();
  }
}

Outside a request, the resolver returns undefined and the usual default applies. The Astro integration is built on exactly this.

CookieAdapter persists small state, such as a theme or locale, in a cookie, so the server sees it on the next request:

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

export const usePrefs = defineStore('prefs', {
  state: () => ({ theme: 'light', recent: [] as string[] }),
  persist: {
    adapter: new CookieAdapter('prefs', { maxAge: 60 * 60 * 24 * 365 }),
    include: ['theme'],
  },
});

A cookie holds at most 4096 bytes. A write that would go over throws, and the store reports it through onError instead of silently keeping stale data. This adapter came from QuantaJS's first community pull request, by @fatihcvs. More on that below.

Also new

  • watch takes equals, to decide whether a new value counts as a change. Pass shallow for a watcher over a derived object.
  • Development warnings no longer reach production. Development mode is read from process.env.NODE_ENV, which bundlers replace at build time.
  • React StrictMode: <QuantaProvider> without a container, and useLocalStore, now survive StrictMode's simulated unmount, and useLocalStore re-renders on changes.
  • Persistence loads only the keys a store persists, so removing a key from include actually stops it being restored.

Smaller

The ES build now ships one file per module, so your bundler drops what you do not import. Every import path shrank by 1.2 to 1.4 KB gzip; reactive plus effect went from 5.7 KB to 4.4 KB.

What each path costs in a production build, minified and gzipped:

You importSize
reactive + effect4.4 KB
defineStore8.6 KB
defineStore + React hooks9.2 KB
defineStore + Vue composables8.8 KB
defineStore + Svelte stores8.8 KB
defineStore + Lit controllers9.1 KB

These numbers are checked on every pull request, against a budget.

Leaner

@quantajs/core exported 46 runtime values in 2.3; it exports 33 in 3.0, counting the new ones. Removed:

  • Name-based lookup (useStore(name), hasStore(name)). Call the definition, or use container.get(name).
  • store.notifyAll(), nextTick() and reactiveEffect.
  • The migration helpers and createPersistenceManager: persist covers both.
  • Internal utilities: debounce, Logger, createLogger, the tracking and sanitising helpers. logger and LogLevel stay.
  • The deprecated type aliases.

Persistence is also typed end to end now: include and exclude accept only your state's keys, and migrations and validators receive stored data as unknown values that you check before trusting. When we moved this site to 3.0, those types caught an unchecked access in one of our own demos.

The migration guide covers every removal, with the replacement for each.


How we checked it

A state library asks you to trust it with your data, so here is what backs this release:

  • 587 tests, plus type tests for the public API.
  • The oldest versions we claim: each framework binding is tested in CI against the oldest framework version it supports: React 18, Vue 3.3, Svelte 4 and Lit 2. Testing Vue 3.3 found a real bug before release: createQuanta() never disposed its container there.
  • Seven example apps (vanilla, React, Next.js, Vue, Svelte, Lit and Astro) are built and checked on every pull request. The Vue, Svelte and Astro examples fire concurrent requests at a server render and check that none sees another's state.
  • The published packages themselves: CI packs each tarball and installs it into a fresh app, the way you would.
  • Provenance: every package is published from GitHub Actions with npm provenance, only from a tagged release, and only after a maintainer approves it.

Upgrading

Stores are unchanged: a defineStore from 2.x works as it is. Update every QuantaJS package together, since they now require each other from the same major:

npm install @quantajs/core@3 @quantajs/react@3

Add @quantajs/devtools@3 if you use DevTools.

Then follow the migration guide for anything you used that was removed.

Thank you

@fatihcvs sent QuantaJS's first community pull requests during this cycle: the cookie adapter, tests for the web storage adapters, and missing assertions in the core suite. Each came with a clear description of how it was checked. Thank you.

If you would like to contribute too, the good first issues are a friendly place to start, and CONTRIBUTING.md gets you set up.

The full list of changes is in the v3.0.0 release notes.