Skip to main content

State

Hooks that manage a piece of component state — persisted, undoable, controlled/uncontrolled, or just a boolean.

useLocalStorage​

State synced with localStorage. The value survives reloads and updates across tabs.

Stored value: 0

The value persists across page reloads.

const [count, setCount] = useLocalStorage('demo-count', 0);

useSessionStorage​

State synced with sessionStorage, shown next to useLocalStorage. The session value survives reloads but not a closed tab.

sessionStorage: 0
localStorage: 0

Reload the page: both values stay. Open the page in a new tab: only the localStorage value carries over.

const [count, setCount] = useSessionStorage('demo-count', 0);

useHistoryState​

State with undo/redo support. Every setValue call snapshots the previous value; undo/redo step through that history.

Current value: 0

Change the value a few times, then use Undo/Redo to step through history.

const { value, setValue, undo, redo, canUndo, canRedo } = useHistoryState(0);

useControllableState​

Backs a controlled/uncontrolled prop pair (`value`/`defaultValue`/`onChange`) with a single hook — falls back to internal state when `value` is undefined, and always calls `onChange` on updates.

Toggle the checkbox freely while uncontrolled; forcing a value switches the hook to controlled mode, mirroring that prop instead.

const [checked, setChecked] = useControllableState({
value: externalValue, // undefined = uncontrolled
defaultValue: false,
onChange: value => console.log('checked:', value),
});

useHash​

Reads and writes the URL hash. Pick a fruit, then reload or press Back: the selection comes from the URL.

Hash: (none)
const [hash, setHash] = useHash();

const page = PAGES.some(p => p.key === hash) ? hash : PAGES[0].key;

setHash('settings'); // adds a history entry
setHash('settings', { replace: true }); // replaces the current one

hash is location.hash without the #, decoded, and '' when there is none (also on the server). Every component using the hook reads the same value, including after back/forward. setHash('') removes the # from the URL in both modes. A hash changed elsewhere with history.pushState or replaceState (some routers do this) fires no hashchange, so the hook doesn't see it until the next hashchange.

usePendingCommit​

Keeps a controlled component's last commit until the parent renders again, so two commits in the same tick build on each other. Each button adds two tags in one click; the plain one loses the first.

Tags: (none)
const pending = usePendingCommit<string, { code: string }>(document);

const latestDocument = () => pending.read()?.code ?? document;

const commit = (nextCode: string) => {
pending.write({ code: nextCode });
onChange(nextCode);
};

A write lasts until the end of the task it was made in (the event handler and anything else in the same tick), or until the effects of the next commit run if that comes first. read() also returns undefined as soon as a render's base differs from the one the write was built on (Object.is), so the render that receives the new value never reads a stale write. If the parent rejects the change, by rendering with the same base or by not rendering at all, the next event starts from base again. It can't tell a rejection from a parent that applies the change later (startTransition, a debounce), so the write is dropped for that too. The returned object keeps its identity while base stays the same.

usePrevious​

Returns the value from the previous render — useful for comparing against the current value, e.g. to detect a false-to-true transition.

Current: 0
Previous: (none yet)
const [count, setCount] = useState(0);
const previous = usePrevious(count);

useToggle​

A boolean toggle with a toggle function and a direct setter — the state shape dropdowns, collapses, drawers, and modals all share.

State: closed
const [open, toggle, setOpen] = useToggle(false);

useSet​

A Set of keys held in state, with add, delete, toggle, set and clear. Each row's collapsed state is one key in the set.

pnpm add @jbpark/use-hooks
import { useSet } from '@jbpark/use-hooks';
const [collapsed, actions] = useSet<string>();
Collapsed: (none)
const [collapsed, { toggle, set, clear }] = useSet<string>();

const isExpanded = (id: string) => !collapsed.has(id);

<button onClick={() => toggle(id)} />;
<button onClick={() => clear()}>Expand all</button>;
<button onClick={() => set(ids)}>Collapse all</button>;

// `delete` is a reserved word, so rename it when destructuring.
const [, { delete: remove }] = useSet<string>();

Every change produces a new Set, so the value works as a dependency and with memo. An action that changes nothing, including set with the same values, keeps the current Set. toggle(value, true) only adds and toggle(value, false) only removes. The initial value is read on the first render only, and the actions keep one identity for the life of the component.

useMap​

A Map of key-value pairs held in state, with set, update, delete, setAll and clear. Each file's upload progress is one entry, keyed by its id.

cover.png (not started)
x
notes.md (not started)
x
intro.mp4 (not started)
x
Entries: 0
const [progress, actions] = useMap<string, number>();

actions.set(file.id, 0);
actions.update(file.id, previous => Math.min(100, (previous ?? 0) + 25));
actions.delete(file.id);

progress.get(file.id);

The useSet counterpart for key-value pairs. Every change produces a new Map, so the value works as a dependency and with memo. An action that changes nothing keeps the current Map: setting a key to the value it already has (compared with Object.is), deleting a missing key, setAll with the same entries, or clear() on an empty map. update passes the current value (undefined for a missing key), so several updates in one event build on each other. Keys keep their insertion order and can be any value, not only strings. The initial entries are read on the first render only, and the actions keep one identity for the life of the component.

useMultiSelect​

Checkbox-style multi-select for a list, with shift-click range selection. Selection is clamped against the current item count, so it stays valid if the list shrinks.

0 selected. Click to toggle, shift-click to select a range.

const { selected, isSelected, toggle, clear } = useMultiSelect(items.length);
// shift-click range-select is mouse-only, so the modifier is captured on
// the CAPTURE phase (before Checkbox's own click-driven onChange fires)
// and read back in onChange, which fires exactly once per real toggle
// (mouse click or keyboard Space).
const shiftHeldRef = useRef(false);

<div
onClickCapture={e => {
shiftHeldRef.current = e.shiftKey;
}}
>
<Checkbox
checked={isSelected(index)}
onChange={() => toggle(index, shiftHeldRef.current)}
>
{item}
</Checkbox>
</div>;