Read before hydration
Some values have to reach <html> before the first paint, such as a theme class. useAppState runs after hydration, which is too late for them. This page covers reading a local or session state ahead of time, from an inline script.
On this page
Never spell the key by hand
The usual way is an inline script with the storage key and the JSON shape written into a string. That drifts from the definition the moment either one changes.
A defineLocalState or defineSessionState definition carries both. storageKey is the key the store writes under, and inlineRead() returns a JavaScript expression for an inline <script>, which evaluates in the browser to the object stored in that definition’s own storage area.
Only local and session definitions have inlineRead(). A cookie state is read on the server instead: where the page receives the request, parseCookies gives the real value from the first render.
Embed inlineRead
inlineRead() returns an expression, so use it as a value inside the script.
state.tsimport { defineLocalState } from '@k8ordo/state';
import * as z from 'zod';
export const themeState = defineLocalState(
'theme',
z.object({ mode: z.enum(['light', 'dark']).optional() }),
);routes/layout.tsximport type { ReactNode } from 'react';
import { themeState } from '../state';
const applyTheme = `(() => {
const s = ${themeState.inlineRead()};
if (s && s.mode === 'dark') {
document.documentElement.classList.add('dark');
}
})();`;
export default function RootLayout({
children,
}: {
children: ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script>{applyTheme}</script>
</head>
<body>{children}</body>
</html>
);
}The script changes <html> before React hydrates it, so render that element with suppressHydrationWarning. From hydration on, treat the store as the source of truth.
The expression is a self-invoking function, so it fits anywhere a value does: the right side of an assignment, an argument, a ternary. The key is escaped for a script context, < included, so any key is safe to emit.
When it is null
The expression never throws. It evaluates to null when:
- nothing is stored
- the stored JSON is corrupt
- the value is not an object (a number, a string, an array or
null) - storage cannot be read at all
- for a local state with a
version, the row was written by another version
What comes back is the raw row
When the script runs, no module has loaded yet, so the schema does not run: what comes back is the raw row as stored, not the salvaged state useAppState will show.
Treat it as untrusted: read only the fields you need, each with its own check and fallback. That is why the example above writes s && s.mode === 'dark'.
Note
@k8ordo/color-scheme builds its pre-paint script on this inlineRead() to put a class on <html>. For a colour scheme, use it rather than writing your own.