useList
Array state with every mutation you keep hand-writing — push, insertAt, move, upsert and friends, all batch-safe, out-of-range-safe, and reference-stable.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-list.jsonPrompt
Build a React + TypeScript "useList" hook (React only — no third-party
dependencies, no browser APIs). It is the array-state toolkit: every mutation
people keep hand-writing, done once, correctly.
Contract
- useList<T>(initial: T[] | (() => T[]), options?: { getKey?: (item: T) => string | number })
- Two overloads: when options.getKey is present the result ALSO carries
updateByKey / removeByKey / upsert, so the keyed API only exists in the type
when a key extractor was actually supplied.
- Result:
- items: readonly T[] — the list. length, isEmpty are conveniences.
- set(next | (prev) => next) — replace the whole list.
- replace(next | (prev) => next) — replace the whole list AND make it the new
reset() baseline (server data arriving, opening another document).
- reset() — go back to the baseline: the initial captured at mount, or the
last replace().
- push(...items) / unshift(...items) / pop() / shift()
- insertAt(index, ...items) / updateAt(index, next | (prev) => next) /
removeAt(index)
- remove(predicate) — drop every match. filter(predicate) — keep every match,
written back to state (not a derived copy). sort(compare?) — sort in place,
written back.
- move(from, to) — lift and re-insert (drag-reorder semantics).
swap(a, b) — exchange two slots.
- clear()
- indexOf(item) / has(item) — by getKey when supplied, else Object.is.
- keyed extras: updateByKey(key, next | (prev) => next), removeByKey(key),
upsert(item) (replace when the key exists, append when it does not).
- initial is captured once, lazily (pass a thunk for expensive seeds). Passing a
different literal on a later render does nothing — that is what replace() is
for.
Behavior
- EVERY writer routes through one setState(prev => ...) functional update. This
is the whole point: push(a); push(b); push(c) inside one handler lands all
three. The hand-written setItems([...items, x]) closes over this render's
snapshot, so three calls compute from the same base and the last one wins —
measure it: three calls, one item.
- Out-of-range and non-finite arguments are always safe, never throw, and the
policy differs by intent:
- Insert / move CLAMP, because "put it around here" is the gesture: insertAt
clamps into [0, length] (999 appends, -4 prepends, NaN appends); move clamps
both indices into [0, length - 1] so dragging past either end parks the row
at that end. move with a non-finite index is a no-op (nothing sane to clamp
to).
- Point writes and swap are NO-OPS when out of range: removeAt(-1),
removeAt(99), updateAt(99), swap(1, 99) all do nothing. Silently editing the
wrong row is far worse than doing nothing, and there is no negative-index
(from-the-end) sugar — -1 means invalid, not "last".
- swap(i, i) is a no-op too.
- No-change means no re-render: every transform returns the PREVIOUS array
reference when the result is equivalent (clear() on an empty list, a sort that
did not reorder anything, updateAt writing an Object.is-equal value, remove /
filter that matched nothing, updateByKey on a missing key). React then bails
out of the update entirely.
- All empty results are normalised to one module-level empty array, so clearing
twice (or setting an empty array over an already empty list) does not hand back
a new [] identity — and does not re-render.
- StrictMode safety: state updaters must be pure because React double-invokes
them. Never generate an id, read the clock, or call a callback inside the
updater. The hook obeys this itself — upsert extracts the key BEFORE
dispatching — and consumers must too: compute the new id in the event handler,
then push the finished object.
- Stable identities: every WRITER is useCallback([]) over one shared `apply`
helper and reads state only from the updater's prev, so a writer can sit in a
dep array or a React.memo prop list forever without causing "Maximum update
depth exceeded". getKey lives in a latest-ref (consumers write it inline) and
is read in the event callback, never inside the updater. The two READERS
(indexOf / has) must see the current items, so they change identity with
items — a latest-ref would hand back last render's answer to a consumer
calling has() inside JSX.
- pop() / shift() return void on purpose. Writes are queued: two pop() calls in
one handler cannot honestly report what each removed, so read items before
calling instead of trusting a fabricated return value.
- The reset baseline lives in state alongside items (initialized in the lazy
useState initializer), not in a ref — nothing is read from or written to a ref
during render.
Rendering & styling
- The hook renders nothing; the consumer owns all UI. For the list around it:
- Row keys come from item identity (the same getKey you pass here), never the
array index, or removing a middle row remounts the wrong inputs.
- Disable move-up on the first row and move-down on the last instead of
relying on the clamp — clamping is the safety net, not the affordance.
- Semantic tokens only: bg-card + border for rows, bg-muted/40 for a payload
readout, text-muted-foreground for captions, focus-visible:ring-2 ring-ring
on every control. Icon-only row buttons need an aria-label naming the row.
- Render an explicit empty state off isEmpty; do not let the list silently
collapse to nothing.
- Nothing here needs animation; gate any reorder transition behind
prefers-reduced-motion.
Customization levers
- Item shape — T is anything. Objects with ids get getKey and the keyed API;
primitives work without it (indexOf falls back to Object.is).
- Key type — string | number. Widen it if you key on something exotic, but keep
the extractor pure: it runs inside updaters.
- Index policy — if your domain really wants Python-style negative indices, map
them before calling (idx < 0 ? length + idx : idx); do not loosen the built-in
no-op rule, it is what keeps a stray -1 from deleting the last row.
- Bounded lists — wrap push with a cap: push only when length < max, or use
set(prev => [...prev, item].slice(-max)) for a ring buffer / log tail.
- Sorting — sort() with no comparator is Array.prototype.sort's string ordering
([10, 9] stays [10, 9]); always pass a comparator for numbers or dates, and
pass an explicit locale to localeCompare for text.
- Persistence / server data — hydrate through replace(serverItems) so reset()
returns to the server's copy rather than the mount-time seed; pair with a
storage hook if it must survive reloads.
- Undo — this hook has one baseline, not a history. If you need step-by-step
revert, keep the array in useUndoRedo and let useList operate on a copy, or
snapshot items before each mutation.Concepts
- One functional updater for every write — each method builds a pure
prev => nexttransform and hands it to the samesetItems, so several writes inside one event compose instead of overwriting each other. The classic hand-writtensetItems([...items, x])reads a stale render snapshot; calling it three times in a handler lands exactly one item. - Clamp vs no-op — two deliberately different out-of-range policies. Insertion and movement clamp (a drag past the end should park at the end); point writes and
swapdo nothing (editing or deleting the wrong row is worse than ignoring a bad index). Non-finite numbers are caught by both paths. - No-op bailout — a transform that changes nothing returns the previous array reference, so React skips the re-render. Clearing an empty list, re-sorting a sorted list, or writing an equal value costs zero renders.
- Keyed upsert — with
getKey,upsert(item)replaces the row that already owns that key and appends otherwise, which is exactly the shape of "a record arrived from the server and I don't know whether I have it yet". The key is extracted before the update is queued, keeping the updater pure under StrictMode. - Baseline vs history —
reset()returns to a single baseline (the mount-timeinitial, or whateverreplace()last installed). That is one snapshot, not an undo stack; step-by-step revert is a different data structure. - Stable writers, live readers — writers never change identity, so they are safe in dependency arrays;
indexOf/hasintentionally change withitemsbecause a cached reader would answer with last render's list.
useStep
A step machine for multi-step flows — async can-go-next gates with a pending flag, visited/completed sets, loop, clamping when steps change, controlled or uncontrolled.
useClipboardPaste
Catches pasted images, files and rich text — page-wide or scoped to a ref — draining clipboardData synchronously, gating files through accept/maxFiles with an itemised rejection list, plus an optional permission-checked active read.