usePersistentState
StableuseState that survives a reload, stays in step across components and tabs, and never breaks when storage is blocked.
A drop-in for useState that saves to localStorage as JSON. It reads the saved value while the state is created, so the first paint already shows it, and keeps every component using the same key, and other tabs, in step. readStored, writeStored and clearStored are the safe one-liners underneath. Every key is stored under the prefix rc-kit:v1:.
Updated View source
A count that survives a reload
import { usePersistentState } from '@rchursin/product/lib/storage'import { Button, IconButton } from '@rchursin/product/ui'
/** * A count saved in this browser: reload the page and it is still there. The badge on the right * is a second component reading the same key, and it follows every change without props. */const KEY = 'library:hooks:reviews-today'
function ReviewCounter() { const [count, setCount, reset] = usePersistentState(KEY, 0) return ( <div className="flex flex-col gap-4"> <div className="flex items-center gap-3"> <IconButton icon="minus" label="Remove one review" variant="secondary" onClick={() => setCount((n) => Math.max(0, n - 1))} /> <output aria-live="polite" className="min-w-14 text-center text-[40px]/none font-light tracking-[-0.03em] text-fg tabular-nums"> {count} </output> <IconButton icon="plus" label="Add one review" variant="secondary" onClick={() => setCount((n) => n + 1)} /> </div>Usage
import { usePersistentState, readStored, writeStored, clearStored, VIEWER_PREFS } from '@rchursin/product/lib/storage'const [view, setView, resetView] = usePersistentState<'list' | 'board'>('tasks:view', 'list')Examples
A tip that stays dismissed
readStored in an effect, writeStored on dismiss, clearStored to bring it back.
import { useEffect, useRef, useState } from 'react'import { clearStored, readStored, writeStored } from '@rchursin/product/lib/storage'import { Button, IconButton } from '@rchursin/product/ui'
/** * For a one-off flag you don't need a hook: read it in an effect (storage only exists in the * browser), write it when the person acts. clearStored(prefix) removes every key that starts * with the prefix. None of them throw, even when storage is blocked. */const KEY = 'library:hooks:tip:keyboard'
export default function StoredHelpers() { // null until the browser has been asked, so a dismissed tip never flashes on load const [dismissed, setDismissed] = useState<boolean | null>(null) const closeButton = useRef<HTMLButtonElement>(null) const againButton = useRef<HTMLButtonElement>(null) const acted = useRef(false)
useEffect(() => { setDismissed(readStored(KEY, false))API
usePersistentState(key, initial, revive?)
| Prop | Type | Default | Description |
|---|---|---|---|
keyrequired | string | – | Stored as "rc-kit:v1:" + key. Read once, when the state is created: to switch records, remount the component (a React key). |
initialrequired | T | (() => T) | – | Used when nothing is saved, when revive throws, during the prerender, and by reset(). Keep it stable (a constant or a function defined outside the component). |
revive | (raw: unknown) => T | – | Turns the parsed JSON back into T (Dates, defaults for missing fields). Throw to reject bad data and fall back to initial. |
returns | readonly [T, Dispatch<SetStateAction<T>>, () => void] | – | The value, a setter like useState’s, and reset(). Changes are written after render, only when the JSON actually changed. |
readStored · writeStored · clearStored · VIEWER_PREFS
| Prop | Type | Default | Description |
|---|---|---|---|
readStored(key, fallback) | <T>(key: string, fallback: T) => T | – | The parsed value, or fallback when it is missing, unreadable or storage is blocked. It doesn’t check the shape. |
writeStored(key, value) | (key: string, value: unknown) => void | – | Saves JSON; silent when storage is full or blocked. usePersistentState hooks in other tabs follow it; hooks in this tab don’t, since only their own setters tell them. |
clearStored(prefix?, { keep? }) | (prefix = '', { keep = [] }: { keep?: readonly string[] } = {}) => void | – | Removes every key that starts with the prefix, except the keys listed in keep. With no prefix and no keep it removes everything the kit saved in this browser, the visitor’s theme choice included. |
VIEWER_PREFS | readonly string[] | – | ["theme", "code-wrap"]: the visitor’s own choices, not sample data. Pass it as keep to reset everything else: clearStored("", { keep: VIEWER_PREFS }). |
Accessibility
- Saved state isn’t announced by itself. When a reload brings back something a person wouldn’t expect (a filter, a running timer), show it on screen.
- Storage is per browser and per device; private windows forget it when they close. Don’t use it for anything a person would be upset to lose.
Guidelines
Do
- Namespace keys by feature: "tasks:view", "timer:stopwatch".
- Pass revive for anything that isn’t plain JSON (Dates, Sets) or whose shape may change between versions.
- Offer a way to reset what is saved, and keep the visitor’s own choices with keep: VIEWER_PREFS.
Don’t
- Don’t store secrets or personal data in localStorage.
- Don’t pass a new initial function on every render; it re-subscribes the sync listeners each time.
- Don’t expect the saved value in prerendered HTML: the server uses initial.
In use
Related
- Timer engineClocks that keep honest time: stopwatch and countdown hooks built on timestamps, one shared frame loop, and duration parsing.
- Dark modeOne class flips every token: on <html> for the whole page, or on any wrapper for one part of it.
- TasksThe whole task tracker: a keyboard-first list and board, a detail view, bulk edits, and undo for every change.
- StopwatchA stopwatch with laps: a square that fills once a minute, hundredths drawn per frame, and Undo after a reset.
Source
The component’s own files, exactly as they run on this site.
/** * Per-viewer persistence for the kit demos (localStorage, wrapped so private windows and * blocked storage never break the page). Demo data only; real data belongs on a server. */import { useCallback, useEffect, useId, useRef, useState } from "react"
const PREFIX = "rc-kit:v1:"
export function readStored<T>(key: string, fallback: T): T { try { const raw = window.localStorage.getItem(PREFIX + key) return raw === null ? fallback : (JSON.parse(raw) as T) } catch { return fallback }}
export function writeStored(key: string, value: unknown) { try { window.localStorage.setItem(PREFIX + key, JSON.stringify(value))