Keep a value between visits, follow changes, and read cookies on the server.

Use the storage helpers to save a value and read it back later. They support localStorage, sessionStorage, and cookies, and let you listen for changes.

For a user preference, such as a theme or sidebar state, pass a storage to createPreference.

Choose a Storage

Choose by how long the value should last and whether the server needs to read it:

StorageLastsShared WithServer Reads It
createLocalStorageUntil removedOther tabs on the siteNo
createSessionStorageWhile the tab is openThis tabNo
createCookieStorageOne year, or maxAge secondsRequests and other tabsYes

A note you want to keep between visits can use localStorage. A temporary draft can use sessionStorage. A preference that changes server-rendered content can use a cookie.

Keep a Value

These helpers store strings under a name, which becomes the localStorage or sessionStorage key, or the cookie's name:

import { createLocalStorage } from "@ui-sdk/core"

const note = createLocalStorage("note")

note.set("Buy milk")
note.get()
note.remove()
note.get()

The first get() returns "Buy milk". After remove(), it returns null.

import * as React from "react"
import {
  createCookieStorage,
  createLocalStorage,
  createSessionStorage,
  type ValueStorage,
} from "@ui-sdk/core"
import {
  Field,
  FieldControl,
  FieldDescription,
  FieldLabel,
} from "@ui-sdk/react/field"
import {
  SegmentedControl,
  SegmentedControlIndicator,
  SegmentedControlItem,
} from "@ui-sdk/react/segmented-control"

const storages: { label: string; storage: ValueStorage; lasts: string }[] = [
  {
    label: "localStorage",
    storage: createLocalStorage("ui-sdk-note"),
    lasts: "Stays until cleared, in every tab.",
  },
  {
    label: "sessionStorage",
    storage: createSessionStorage("ui-sdk-note"),
    lasts: "Stays while this tab is open.",
  },
  {
    label: "Cookie",
    storage: createCookieStorage("ui-sdk-note"),
    lasts: "Stays a year, and the server reads it too.",
  },
]

const noServerValue = () => null

function Example() {
  const [picked, setPicked] = React.useState("localStorage")
  const { storage, lasts } = storages.find(({ label }) => label === picked)!
  const note = React.useSyncExternalStore(
    storage.subscribe,
    storage.get,
    noServerValue
  )
  const [available, setAvailable] = React.useState(true)
  React.useEffect(() => setAvailable(storage.available()), [storage])

  return (
    <div data-slot="stack">
      <div data-slot="controls">
        <SegmentedControl
          aria-label="Keep the note in"
          value={picked}
          onValueChange={(next) => next && setPicked(next)}
        >
          <SegmentedControlIndicator />
          {storages.map(({ label }) => (
            <SegmentedControlItem key={label} value={label}>
              {label}
            </SegmentedControlItem>
          ))}
        </SegmentedControl>
      </div>
      <div data-slot="stack">
        <Field disabled={!available}>
          <FieldLabel>Note</FieldLabel>
          <FieldControl
            value={note ?? ""}
            onChange={(event) => storage.set(event.target.value)}
            placeholder={
              available ? "Type, then reload the page" : "Unavailable here"
            }
          />
          <FieldDescription>
            {available ? lasts : "This browser blocks it here."}
          </FieldDescription>
        </Field>
        <button
          type="button"
          onClick={() => storage.remove()}
          disabled={note === null}
        >
          Clear Note
        </button>
      </div>
    </div>
  )
}

export { Example }
Type a note and reload. Switch storage types, or open another tab, to see which values carry over.

When Storage Is Unavailable

A browser can block storage or run out of space. If a read fails, get() returns null. For localStorage and sessionStorage, set and remove return true when the write succeeds, or false when it fails.

Use available() for an initial check. A later write can still fail, so check its return value too:

if (!note.available()) {
  console.log("Saving is unavailable here.")
}

if (!note.set("Buy milk")) {
  console.log("The note wasn't saved.")
}

A full storage may still let you read or remove an existing value.

For cookies, true means no error was reported. It doesn't confirm that the cookie was saved or removed.

On the server, localStorage and sessionStorage are unavailable. To read a saved value there, use a cookie.

Follow Changes

Read the starting value with get, then subscribe to changes. subscribe doesn't call your listener with the initial value:

function logNote(value: string | null) {
  console.log(value ?? "")
}

logNote(note.get())
const stop = note.subscribe(logNote)

Writes through a storage with the same kind and ID notify listeners on the page, including writes through note itself. LocalStorage also follows changes in other tabs. Cookies follow other tabs in browsers that support cookieStore.

Call the returned function when you no longer need the subscription, such as when the component unmounts:

stop()

Pass the request's Cookie header to read a saved value during server rendering:

import { createCookieStorage } from "@ui-sdk/core"

function readDensity(request: Request) {
  const density = createCookieStorage("density", {
    cookie: request.headers.get("cookie"),
  })

  return density.get()
}

Create the storage inside your request handler, as above, so each visitor's cookie stays separate. In the browser, it reads document.cookie.

On the server, available() returns true when you supply a Cookie header, but set and remove return false. The helper reads the request's cookies; it doesn't set response headers. Its readsOnServer flag lets helpers such as createPreference use the saved value in their initial state.

By default, cookies last one year, use path / and SameSite=Lax, and use Secure on HTTPS pages. You can change these with maxAge, path, domain, sameSite, and secure.

Show a Value Before Hydration

The server can't read localStorage or sessionStorage. For styles that depend on a saved value, script generates JavaScript that puts it on a data-* attribute before hydration:

import { createLocalStorage } from "@ui-sdk/core"

const densityStorage = createLocalStorage("density")
const script = densityStorage.script({
  attribute: "data-density",
  values: ["comfortable", "compact"],
})

Render the element with its default attribute, such as data-density="comfortable". Render script as inline JavaScript immediately after that element. It reads the saved value and updates the preceding element's attribute while the page loads.

Only values listed in values are written. Missing or invalid saved values leave the attribute as the server rendered it. Attribute names must start with data-.

The script updates the attribute. Read the storage when the component mounts and subscribe to keep its state current. When you render the script in your framework, account for the attribute changing before hydration.

Scripts with the same storage kind and ID share one initial read. A cookie storage's script returns undefined, because the server can render the cookie's value itself.

For user preferences, apply them with Preferences to render one script and keep their attributes up to date.

API Reference

idstring
The ID the value is kept under: the localStorage or sessionStorage key, or the cookie's name.
Example: Keep a Value
kindKind
Where the value is kept: "local", "session" or "cookie".
Example: Choose a Storage
readsOnServerboolean
Whether the server reads the value too, so it renders right from the first render. True for cookies.
Example: Read a Cookie on the Server
script(options: StorageScriptOptions) => string
The inline script a component renders right after its root, which puts the saved value on the root before hydration, so CSS shows it on the first paint. Undefined for a cookie, since the server renders its value.
Example: Show a Value Before Hydration
available() => boolean
Whether storage appears available. A later write can still fail. On the server, a cookie with a request header is available for reading only.
Example: When Storage Is Unavailable
get() => string | null
The value kept under the ID, or null when nothing is, or the storage is unavailable.
Example: Keep a Value
set(value: string) => boolean
Saves a value under the ID. Returns false when writing is unavailable or the browser reports a write failure. For cookies, true means the assignment didn't throw; it doesn't confirm the cookie was saved.
Example: Keep a Value
remove() => boolean
Removes the value. Returns false when writing is unavailable or the browser reports a write failure. For cookies, true means the assignment didn't throw; it doesn't confirm the cookie was removed.
Example: Keep a Value
subscribe(listener: (value: string | null) => void) => () => void
Hears the value change, through another storage with the same ID on the page or in another tab. Returns the function that stops it.
Example: Follow Changes
cookie?string | null
The request's Cookie header, so the server reads the same value the browser will.
Example: Read a Cookie on the Server
maxAge?number
How long the cookie lasts, in seconds.
Example: Read a Cookie on the Server
path?string
The paths that send the cookie.
Example: Read a Cookie on the Server
domain?string
The domain that sends the cookie. Defaults to the page's host.
Example: Read a Cookie on the Server
sameSite?"lax" | "none" | "strict"
Whether other sites' requests send the cookie.
Example: Read a Cookie on the Server
secure?boolean
Whether only HTTPS requests send the cookie. Defaults to true on HTTPS pages.
Example: Read a Cookie on the Server
attribute`data-${string}`
The attribute that shows the value, which has to start with data-, like "data-color-scheme".
Example: Show a Value Before Hydration
valuesreadonly string[]
The values it may show. Anything else saved under the ID leaves the root as the server rendered it.
Example: Show a Value Before Hydration