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.
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.
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.
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.
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.
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.
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.
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-hooksimport { useSet } from '@jbpark/use-hooks';const [collapsed, actions] = useSet<string>();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)notes.md (not started)intro.mp4 (not started)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>;