what a person set, kept for the tab or for good, and back to its default
npx shadcn add @ag/rememberedAlready installed? Preview an update
npx shadcn add @ag/remembered --dry-runYour 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).useRememberedgives the value, a setter and a reset;recall,rememberandresetwork 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.
the demo above, verbatim
"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
"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: 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