← ui

remembered

ui

what a person set, kept for the tab or for good, and back to its default

npx shadcn add @ag/remembered
Already installed? Preview an update
npx shadcn add @ag/remembered --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-10
  • New: what a person set, kept, and given back to its default when it should be. setting(key, { initial, keep, parse }) defines one: its default, how what is kept is checked, and how long it lives ("tab" for the tab's session, "always" until changed, { idle } until untouched that long). useRemembered gives the value, a setter and a reset; recall, remember and reset work outside React. Every reader of a setting agrees, other tabs follow, the server renders the default. A kept object is laid over its default, so a field added later takes the default's; what fails its parse is said and dropped.
Updated 2026-10-10 · release notes

also called local storage, preferences

shelf controls

used by audible, tree, playback, film-mosaic

the demo above, verbatim

remembered.demo.tsx
"use client"

import { Sample } from "@/app/samples"
import {
  fields,
  isBoolean,
  oneOf,
  setting,
} from "@/registry/base-nova/lib/remembered-core"
import { Button } from "@/registry/base-nova/ui/button"
import { useRemembered } from "@/registry/base-nova/ui/remembered"
import {
  ToggleGroup,
  ToggleGroupItem,
} from "@/registry/base-nova/ui/toggle-group"

// #region settings
// Each setting defined once, where it is used: its key, its default, what it may be, and
// how long it lives.
const MOTION = setting("demo:motion", {
  initial: false,
  keep: "always",
  parse: isBoolean,
})
const VIEW = setting("demo:view", {
  initial: "grid" as "grid" | "rows" | "list",
  keep: "tab",
  parse: oneOf(["grid", "rows", "list"] as const),
})
// Forgotten ten seconds after it was last touched: reload after that and it is back.
const FOLDS = setting("demo:folds", {
  initial: { specs: true, traits: false },
  keep: { idle: 10_000 },
  parse: fields<{ specs: boolean; traits: boolean }>({
    specs: isBoolean,
    traits: isBoolean,
  }),
})
// #endregion

/** One setting's row: its value, its control, and its way back to the default. */
function Row({
  name,
  lives,
  changed,
  onReset,
  children,
}: {
  name: string
  lives: string
  changed: boolean
  onReset: () => void
  children: React.ReactNode
}) {
  return (
    <div className="flex items-center gap-3 text-[13px]">
      <span className="w-16 text-foreground/80">{name}</span>
      {children}
      <span className="font-mono text-[11px] text-foreground/45">{lives}</span>
      <Button
        size="sm"
        variant="ghost"
        disabled={!changed}
        onClick={onReset}
        className="ml-auto"
      >
        default
      </Button>
    </div>
  )
}

export default function RememberedDemo() {
  const [motion, setMotion, resetMotion] = useRemembered(MOTION)
  const [view, setView, resetView] = useRemembered(VIEW)
  const [folds, setFolds, resetFolds] = useRemembered(FOLDS)
  return (
    <div className="space-y-8">
      <Sample
        name="lives"
        glance="wide"
        title="three settings, three lives"
        description="set each, then reload: motion stays until changed, the view stays for this tab and is gone in a new one, the folds stay while they are touched and are back to their default ten seconds after. default gives a setting its default again; a value equal to the default is never kept."
        with="settings"
      >
        <div className="flex w-full max-w-[30rem] flex-col gap-3">
          <Row
            name="motion"
            lives="always"
            changed={motion !== MOTION.initial}
            onReset={resetMotion}
          >
            <ToggleGroup
              size="sm"
              aria-label="motion"
              value={motion ? "on" : "off"}
              onValueChange={(v) => setMotion(v === "on")}
            >
              <ToggleGroupItem value="off">off</ToggleGroupItem>
              <ToggleGroupItem value="on">on</ToggleGroupItem>
            </ToggleGroup>
          </Row>
          <Row
            name="view"
            lives="this tab"
            changed={view !== VIEW.initial}
            onReset={resetView}
          >
            <ToggleGroup
              size="sm"
              aria-label="view"
              value={view}
              onValueChange={(v) => setView(v as typeof view)}
            >
              <ToggleGroupItem value="grid">grid</ToggleGroupItem>
              <ToggleGroupItem value="rows">rows</ToggleGroupItem>
              <ToggleGroupItem value="list">list</ToggleGroupItem>
            </ToggleGroup>
          </Row>
          <Row
            name="folds"
            lives="idle 10 s"
            changed={JSON.stringify(folds) !== JSON.stringify(FOLDS.initial)}
            onReset={resetFolds}
          >
            <ToggleGroup
              multiple
              size="sm"
              aria-label="folded"
              value={(["specs", "traits"] as const).filter((k) => folds[k])}
              onValueChange={(on) =>
                setFolds({
                  specs: on.includes("specs"),
                  traits: on.includes("traits"),
                })
              }
            >
              {(["specs", "traits"] as const).map((k) => (
                <ToggleGroupItem key={k} value={k}>
                  {k} {folds[k] ? "folded" : "open"}
                </ToggleGroupItem>
              ))}
            </ToggleGroup>
          </Row>
        </div>
      </Sample>
    </div>
  )
}

the source, every file an install writes

remembered.tsx
"use client"

// REMEMBERED settings in the page (lib/remembered-core is the rule): one value per setting
// for the whole page, so every place that reads it agrees and follows a change at once;
// other tabs follow too (a setting kept "always" or while idle). A "tab" setting lives
// in the tab's sessionStorage, the rest in localStorage; a page that may not keep
// anything (a private window refusing storage) keeps them for the page alone. On the
// server, and before the page hydrates, every setting is its default.
//
//   const [size, setSize, reset] = useRemembered(SIZE)
//   remember(SIZE, 320)   recall(SIZE)   outside React

import * as React from "react"
import {
  forget,
  PREFIX,
  read,
  type Setting,
  type Shelf,
  touch,
  write,
} from "@/registry/base-nova/lib/remembered-core"

const memory = new Map<string, string>()
/** Where a page that may not keep anything keeps it: this page, until it goes. */
const pageOnly: Shelf = {
  getItem: (k) => memory.get(k) ?? null,
  setItem: (k, v) => void memory.set(k, v),
  removeItem: (k) => void memory.delete(k),
}

function shelfOf(s: Setting<unknown>): Shelf {
  try {
    const store = s.keep === "tab" ? window.sessionStorage : window.localStorage
    store.getItem(PREFIX)
    return store
  } catch {
    return pageOnly
  }
}

const values = new Map<string, unknown>()
const listeners = new Map<string, Set<() => void>>()
// Read once a page: the first read touches it, so a setting in use is never forgotten.
const touched = new Set<string>()

function tell(key: string) {
  for (const fn of listeners.get(key) ?? []) fn()
}

/** The setting's value now. */
export function recall<T>(s: Setting<T>): T {
  if (!values.has(s.key)) {
    const shelf = shelfOf(s as Setting<unknown>)
    const now = Date.now()
    values.set(s.key, read(s, shelf, now))
    if (!touched.has(s.key)) {
      touch(s, shelf, now)
      touched.add(s.key)
    }
  }
  return values.get(s.key) as T
}

/** Set it, kept as its setting says, and every reader follows. */
export function remember<T>(s: Setting<T>, value: T): void {
  write(s, shelfOf(s as Setting<unknown>), value, Date.now())
  values.set(s.key, value)
  tell(s.key)
}

/** Back to its default, kept nowhere. */
export function reset<T>(s: Setting<T>): void {
  forget(s, shelfOf(s as Setting<unknown>))
  values.set(s.key, s.initial)
  tell(s.key)
}

// Another tab set one: its value read again here.
let watching = false
function watchTabs() {
  if (watching) return
  watching = true
  window.addEventListener("storage", (e) => {
    if (!e.key?.startsWith(PREFIX)) return
    const key = e.key.slice(PREFIX.length)
    if (!values.has(key)) return
    values.delete(key)
    tell(key)
  })
}

function subscribe(key: string, fn: () => void) {
  watchTabs()
  const set = listeners.get(key) ?? new Set()
  set.add(fn)
  listeners.set(key, set)
  return () => {
    set.delete(fn)
  }
}

/** A setting's value, its setter (a value, or one from the value before), and its way
 *  back to the default. */
export function useRemembered<T>(
  s: Setting<T>,
): [T, (next: T | ((was: T) => T)) => void, () => void] {
  const value = React.useSyncExternalStore(
    (fn) => subscribe(s.key, fn),
    () => recall(s),
    () => s.initial,
  )
  const set = React.useCallback(
    (next: T | ((was: T) => T)) =>
      remember(
        s,
        typeof next === "function" ? (next as (was: T) => T)(recall(s)) : next,
      ),
    [s],
  )
  const back = React.useCallback(() => reset(s), [s])
  return [value, set, back]
}
remembered-core.ts
// REMEMBERED: what a person set, kept, and given back to its default when it should be.
// Every "keep this" of an interface goes through here (a gallery's size, a player's
// volume, a rail's folded sections, a query for the session), so they all keep the same
// way: one definition per setting, its default the base, what was set an override on
// top, and how long it lives said where it is defined:
//
//   "tab"         this tab's session: kept across reloads and navigation, gone with the
//                 tab (a query being looked through, a private view switched on)
//   "always"      until changed (a size, lanes or rows, motion, a volume)
//   { idle: ms }  until untouched that long, then the default again (a fold, a slideshow
//                 setting): a thing set once and forgotten falls back, a thing in use
//                 stays, since every page that reads it touches it
//
// What is kept is checked by the setting's `parse` on every read: what fails it (a shape
// from before, a value from elsewhere) is said in the console and gives the default, and
// a plain object kept is laid over its default first, so a field the setting gained since
// takes its default. Framework-free: the shelf (a Storage's get, set and remove) is
// handed in, ui/remembered binds it to the page's and is what an interface imports.
//
//   const SIZE = setting("gallery:size", { initial: 280, keep: "always", parse: number })
//   read(SIZE, shelf, now)          → 280, or what was set
//   write(SIZE, shelf, 320, now)    → kept, with when

export type Keep = "tab" | "always" | { idle: number }

export interface Setting<T> {
  /** where it is kept, the same in every place that reads it */
  key: string
  /** what it is until a person sets it, and again once forgotten */
  initial: T
  keep: Keep
  /** what was kept, as this setting's value, or undefined when it is not one */
  parse: (raw: unknown) => T | undefined
}

/** The part of a Storage a setting is kept in. */
export interface Shelf {
  getItem(key: string): string | null
  setItem(key: string, value: string): void
  removeItem(key: string): void
}

/** What is kept: the value, and when it was last touched (ms since the epoch). */
interface Kept {
  value: unknown
  at: number
}

/** Settings are kept under this prefix, so a page's other storage is never read. */
export const PREFIX = "remembered:"

export function setting<T>(
  key: string,
  o: { initial: T; keep: Keep; parse: (raw: unknown) => T | undefined },
): Setting<T> {
  if (!key.trim()) throw new Error("a setting needs a key")
  if (typeof o.keep === "object" && !(o.keep.idle > 0))
    throw new Error(
      `${key}: kept while idle for a time above 0 ms, not ${o.keep.idle}`,
    )
  if (o.parse(o.initial) === undefined)
    throw new Error(`${key}: its own default does not parse`)
  return { key, ...o }
}

const plain = (x: unknown): x is Record<string, unknown> =>
  typeof x === "object" && x !== null && !Array.isArray(x)

/** The setting's value as kept, or its default: nothing kept, kept past its idle time,
 *  or kept as something it does not parse (said). */
export function read<T>(s: Setting<T>, shelf: Shelf, now: number): T {
  const text = shelf.getItem(PREFIX + s.key)
  if (text === null) return s.initial
  let kept: Kept
  try {
    kept = JSON.parse(text) as Kept
  } catch {
    console.error(`remembered ${s.key}: not JSON, back to its default`)
    shelf.removeItem(PREFIX + s.key)
    return s.initial
  }
  if (!plain(kept) || typeof kept.at !== "number" || !("value" in kept)) {
    console.error(`remembered ${s.key}: not a kept value, back to its default`)
    shelf.removeItem(PREFIX + s.key)
    return s.initial
  }
  if (typeof s.keep === "object" && now - kept.at > s.keep.idle) {
    shelf.removeItem(PREFIX + s.key)
    return s.initial
  }
  const raw =
    plain(s.initial) && plain(kept.value)
      ? { ...s.initial, ...kept.value }
      : kept.value
  const value = s.parse(raw)
  if (value === undefined) {
    console.error(
      `remembered ${s.key}: ${JSON.stringify(kept.value)} is not one of its values, back to its default`,
    )
    shelf.removeItem(PREFIX + s.key)
    return s.initial
  }
  return value
}

/** Keep a value as set now; the default itself is not kept, so a later default holds. */
export function write<T>(
  s: Setting<T>,
  shelf: Shelf,
  value: T,
  now: number,
): void {
  if (s.parse(value) === undefined)
    throw new Error(
      `remembered ${s.key}: ${JSON.stringify(value)} is not one of its values`,
    )
  if (JSON.stringify(value) === JSON.stringify(s.initial)) {
    shelf.removeItem(PREFIX + s.key)
    return
  }
  shelf.setItem(
    PREFIX + s.key,
    JSON.stringify({ value, at: now } satisfies Kept),
  )
}

/** The setting touched now: its idle time starts again. Nothing when nothing is kept. */
export function touch<T>(s: Setting<T>, shelf: Shelf, now: number): void {
  if (typeof s.keep !== "object") return
  const text = shelf.getItem(PREFIX + s.key)
  if (text === null) return
  shelf.setItem(
    PREFIX + s.key,
    JSON.stringify({ ...(JSON.parse(text) as Kept), at: now }),
  )
}

/** Forget what was set: the default again. */
export function forget<T>(s: Setting<T>, shelf: Shelf): void {
  shelf.removeItem(PREFIX + s.key)
}

// ── parsers for the common shapes, each a value or undefined.

export const isBoolean = (x: unknown): boolean | undefined =>
  typeof x === "boolean" ? x : undefined

export const isNumber = (x: unknown): number | undefined =>
  typeof x === "number" && Number.isFinite(x) ? x : undefined

/** One of a fixed set of values. */
export const oneOf =
  <const V extends string | number>(values: readonly V[]) =>
  (x: unknown): V | undefined =>
    values.includes(x as V) ? (x as V) : undefined

/** A list of strings. */
export const isStrings = (x: unknown): string[] | undefined =>
  Array.isArray(x) && x.every((v) => typeof v === "string")
    ? (x as string[])
    : undefined

/** A list, every item parsed by `item`: all of them right, or undefined. */
export const listOf =
  <T>(item: (x: unknown) => T | undefined) =>
  (x: unknown): T[] | undefined => {
    if (!Array.isArray(x)) return undefined
    const out: T[] = []
    for (const v of x) {
      const p = item(v)
      if (p === undefined) return undefined
      out.push(p)
    }
    return out
  }

/** An object of these fields, each parsed by its own: every field present and right,
 *  or undefined (a kept object is laid over the default first, so a new field takes the
 *  default's). */
export const fields =
  <T extends object>(
    parsers: { [K in keyof T]: (x: unknown) => T[K] | undefined },
  ) =>
  (x: unknown): T | undefined => {
    if (!plain(x)) return undefined
    const out: Record<string, unknown> = {}
    for (const k of Object.keys(parsers)) {
      const v = parsers[k as keyof T](x[k])
      if (v === undefined) return undefined
      out[k] = v
    }
    return out as T
  }

/** A number inside [min, max]. */
export const between =
  (min: number, max: number) =>
  (x: unknown): number | undefined =>
    typeof x === "number" && x >= min && x <= max ? x : undefined