@k8ordo/state

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.ts
import { defineLocalState } from '@k8ordo/state';
import * as z from 'zod';

export const themeState = defineLocalState(
  'theme',
  z.object({ mode: z.enum(['light', 'dark']).optional() }),
);
routes/layout.tsx
import 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'.

Information

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.

k8ordo

React libraries that use Baseline features without holding back.

© 2026 k8o — MIT License

Typeset in Noto Sans JP & M PLUS 2