Hooks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * Every empty list shares one reference: repeated `clear()` — or a filter that
 * removes everything — leaves `items` with the same identity, so a consumer with
 * `items` in a dependency array is never woken by a meaningless change.
 */
const EMPTY: readonly never[] = []

/** The identity `getKey` returns. It is compared with `===`, so strings and numbers only. */
export type ListKey = string | number

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-list.json

Prompt

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 => next transform and hands it to the same setItems, so several writes inside one event compose instead of overwriting each other. The classic hand-written setItems([...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 swap do 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 historyreset() returns to a single baseline (the mount-time initial, or whatever replace() 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/has intentionally change with items because a cached reader would answer with last render's list.

On This Page