FrançaisPlayground

Storage in an app

@fluixi/core/storage binds @fluixi/storage to the app: it picks the backend for where the code runs, keeps server state per request, and reads the same value in the server render and in the browser.

const prefs = $storage({ scope: 'cookie', namespace: 'ui' });
const theme = $persisted(prefs, 'theme', { fallback: 'light' });

<button onClick={() => theme.set((t) => (t === 'light' ? 'dark' : 'light'))}>
  {() => theme()}
</button>

The server renders the theme the visitor chose, from their cookie, and the browser starts from the same value: no flash, no hydration mismatch. In package code the same calls are useStorage and persisted, imported from @fluixi/core/storage.

Scopes

useStorage(options) takes the options of createStorage (namespace, migrations, a serializer) and a scope:

Scope Server Browser
auto (default) the request's own storage localStorage
cookie the request's cookies, written back as Set-Cookie document.cookie
request values for this request only values for this page
local, session throws: there is no such storage localStorage, sessionStorage

Use cookie for anything the server render has to agree with: a theme, a language, a dismissed banner. Use local for what only the browser cares about. auto takes a storage from a <StorageProvider> first, when there is one.

On the server, state never lives in a module. Each request gets its own adapters, so two visitors rendering at once never see each other's values. Called on the server outside any request (at module level, in a script), useStorage throws instead of quietly sharing one store between everyone.

Cookies

const prefs = useStorage({
  scope: 'cookie',
  namespace: 'ui',
  cookie: { maxAge: 60 * 60 * 24 * 365, sameSite: 'lax' },
});

httpOnly defaults to false here, since the browser half has to read the cookie. Set it to true for a value only the server reads. The other attributes keep their secure defaults: Secure, SameSite=Lax, Path=/.

A cookie set during the render reaches the response, streamed pages included, until the body's first chunk has gone out. After that the headers are sent and the write throws, saying so. Values decided late belong in middleware, a route's data or a server function.

persisted

persisted(storage, key, options) is a signal whose value lives in a storage key. It starts with the stored value, writes through on set, and follows changes made by any other storage over the same adapter, and by other tabs where the backend reports them.

const sidebar = persisted(useStorage({ namespace: 'ui' }), 'sidebar', { fallback: true });

sidebar();                // true until changed
sidebar.set((open) => !open);
sidebar.remove();         // back to the fallback

Options: fallback (the value while the key is absent; null without it), ttl for every write, name for devtools. A write the storage refuses throws and leaves the signal as it was.

Over an asynchronous or encrypted storage, persisted holds the fallback until it has read the stored value (await signal.ready), sets the signal at once on set, and goes back if the write fails. That read cannot finish during the server render, so keep such values out of what the server renders.

Do not mirror a signal into storage with an effect; persisted is the one place both live.

Asynchronous and encrypted storage

useAsyncStorage has the same scopes, every operation awaited, and takes encryption:

const vault = useAsyncStorage<{ token: string }>({
  namespace: 'auth',
  encryption: { keyProvider },
});
await vault.set('token', token, { encrypt: true });

See Encryption for what it protects and what it does not.

Providing a storage

<StorageProvider storage={createStorage({ adapter: memoryStorage() })}>
  <Settings />
</StorageProvider>

Every useStorage() below with the auto scope gets that storage: a test's memory storage, an app-specific one. An asynchronous storage goes to useAsyncStorage() the same way.

Carrying values to the browser

A request-scoped value computed on the server is gone by the time the browser runs. hydrateStorage sends chosen keys with the page:

const scratch = useStorage<{ draft: Draft }>({ scope: 'request', namespace: 'editor' });
hydrateStorage(scratch, ['draft']);

Call it with the same keys on both sides. On the server it puts the values, with their expiry and version, in the page data; in the browser the first call writes them into the storage, and later calls leave the browser's own value alone. Page data is readable by anyone who sees the page: never hydrate a secret. An encrypted value throws here rather than travel in clear.

The theme

A theme is the most common value both sides must agree on. createColorScheme, createTheme and their providers keep it in a cookie through this storage, send the attribute with the page and apply a stored choice before paint. See Theming.

Intrinsics

In an app built by @fluixi/start, these need no import:

Intrinsic Stands for
$storage(options) useStorage
$asyncStorage(options) useAsyncStorage
$persisted(storage, key, options) persisted, read like a signal