useMap
Map-shaped React state: set, setAll, remove, replace and reset over a real Map, with any key type, copy-on-write updates, no-op bail-outs and a never-changing actions object.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-map.jsonPrompt
Build a React + TypeScript "useMap" hook (React only — no third-party
dependencies, no browser APIs). It is Map-shaped state: the value it hands back
is a real Map, not an object pretending to be one, and every mutation produces a
new Map so React can see it.
Contract
- useMap<K, V>(initial?: Iterable<readonly [K, V]> | (() => Iterable<readonly [K, V]>)):
readonly [ReadonlyMap<K, V>, UseMapActions<K, V>]
- `initial` defaults to `[]` and accepts anything iterable of [key, value]
pairs: an array of tuples, another Map, Object.entries(obj), a generator. Pass
a thunk for an expensive seed. It is read ONCE, on mount, exactly like
useState's initial argument — handing a different literal on a later render
changes nothing; that is what replace() is for. The entries are copied into a
fresh Map, so a caller mutating its own source later cannot reach into state.
- Actions (all eight keep ONE identity for the component's whole life):
- set(key, value | (prev: V | undefined) => V) — write one key. The updater
form receives `undefined` for a key that does not exist yet, so
set(id, prev => (prev ?? 0) + 1) is safe as a first write.
- setAll(entries) — batch upsert in one commit: existing keys are written in
place, new keys are appended, later duplicates inside `entries` win.
- remove(key) — delete one key. Missing key: no-op, never throws.
- clear() — drop everything.
- replace(entries) — swap the whole map AND install it as the new reset()
baseline (server data arrived, another document was opened).
- reset() — go back to the baseline: the mount-time `initial`, or whatever
replace() last installed.
- get(key) / has(key) — read the LAST COMMITTED map from a closure that
cannot see the current render (a React.memo'd child, a setTimeout, a
listener registered once). During render, read the map from the tuple
instead: that is this frame's truth.
- Result tuple: [map, actions]. `map` only changes identity when its contents
really changed, so it is safe in a dependency array. `actions` never changes
identity, so it is safe as a prop to a memoized child.
Behavior
- Copy-on-write is the change signal. Every writer does `new Map(prev)`, mutates
the copy, and returns it. Never mutate the map in place — an in-place
map.set() keeps the same reference, React bails out of the render, and the
screen silently stops matching the data. Cost: one O(n) copy per committed
write (n = entry count), which is free up to the low thousands; see the levers
for what to do beyond that.
- No change means no re-render. A transform that would produce an equivalent map
returns the PREVIOUS reference and React skips the update entirely:
set() with an Object.is-equal value for an EXISTING key, setAll() where every
incoming pair already matches, remove() of a missing key, clear() on an empty
map, replace() with content equal to both the current map and the baseline,
reset() when the map already is the baseline.
- Key equality is the Map's own SameValueZero, not string coercion: 1 and "1"
are two different keys, NaN matches NaN, -0 and 0 are the same key (stored as
+0), and an object key matches only the very same reference — a structurally
identical copy is a different key. That is the whole reason to keep a Map:
an object record folds every key through String(key) (1 and "1" collide, every
object becomes "[object Object]") and inherits prototype landmines
("toString" in record is true, record.__proto__ = v creates no own property).
- Insertion order is preserved, and writing an existing key keeps its slot
rather than moving it to the end, so a list rendered from the map does not
reshuffle when a row updates.
- Presence and value are independent. set(key, undefined) STORES an entry whose
value is undefined: has(key) becomes true, get(key) returns undefined. Only
remove(key) deletes. Equality checks must therefore test has() before
comparing values, or "store undefined over a missing key" is silently
swallowed.
- Batch-safe writes. Every writer routes through the same
setState(prev => ...) functional update, so three calls in one handler compose:
set(k, p => (p ?? 0) + 1) three times lands +3. The hand-written equivalent,
setRecord({ ...record, k: record.k + 1 }) three times, computes all three from
the same render snapshot and lands +1.
- StrictMode purity. State updaters run twice in development, so they must stay
pure: no id generation, no clock reads, no callbacks inside them. The hook
obeys this itself by MATERIALIZING iterables before dispatching — setAll and
replace do Array.from / new Map at call time, because a one-shot iterator
(generator, other.entries()) consumed inside an updater is already exhausted
on the second invocation and entries would silently vanish.
- Reads from closures. get()/has() answer from a ref holding the last COMMITTED
map, synced in a useInsertionEffect (the earliest commit-phase hook React
offers: before layout effects, before paint), never written during render.
Writes are queued like any setState, so set(k, v) followed immediately by
get(k) inside the same handler still returns the OLD value — read the tuple's
map during render, and treat get/has as an escape hatch for stale closures.
- The reset baseline lives in the same state object as the map (initialized in
the lazy useState initializer), not in a ref, so nothing is read from or
written to a ref during render and reset() needs no effect.
- Degenerate cases, all defined: empty initial (an empty Map, has() false for
every key including "toString"); setAll([]) dispatches nothing at all;
duplicate keys inside one setAll — last wins; replace([]) empties the map and
makes "empty" the baseline; reset() after clear() restores the baseline;
remove/get/has on any missing key never throw. One caveat inherited from
useState: if V is itself a function type, set(k, fn) is read as the updater
form — write set(k, () => fn).
- Cleanup: there is nothing asynchronous to cancel. The hook starts no timers,
no rAF, no listeners, no observers, and touches no browser API — so it is
SSR-safe as written, and the only lifecycle resource is the insertion-effect
ref sync, which React tears down with the component. Any subscription that
FEEDS the map (a socket pushing rows) belongs to the caller and must be
unsubscribed in that caller's effect.
Rendering & styling
- The hook renders nothing and owns no DOM; the consumer owns all UI. Rules for
the list that usually sits on top of it:
- React keys come from the map key when it is a string or number
(Array.from(map) gives [key, value] pairs, in insertion order). Object keys
need a stable id field of their own — never the array index, or removing a
middle row remounts the wrong inputs.
- Semantic tokens only: bg-card + border for the panel, bg-muted/40 for rows
and readouts, text-muted-foreground for captions, text-destructive for a
zero/limit reading, focus-visible:ring-3 ring-ring on every control.
- ARIA contract for per-key controls: icon-only row buttons carry an
aria-label naming the row ("Increment retries for api-gateway"); a live
size/status readout that updates after an action carries aria-live="polite";
a group of per-key toggles is a list of buttons with aria-pressed, not a
radiogroup.
- Keyboard map: nothing custom. Rows are native buttons, so Tab/Shift+Tab
move between them and Enter/Space activate — do not intercept those. If you
add row navigation, use a roving tabindex so the group is one tab stop.
- A control that turns ITSELF unavailable (the last decrement, clear() on an
empty map, remove() of the key that is about to vanish) must use
aria-disabled + an early return in the handler, never the native disabled
attribute: the browser blurs a node the instant it becomes disabled and
focus lands on <body>.
- No animation is required. If rows animate in or out, gate the transition
behind prefers-reduced-motion; the list must still be readable with motion
off.
Customization levers
- Value shape — V is anything: a number (counters), an object (per-row draft
state), a discriminated union (per-item "idle | uploading | failed"). For
object values, always write through the updater form so a partial edit reads
the current entry: set(id, prev => ({ ...(prev ?? DEFAULT), open: true })).
- Key type — K is anything a Map accepts. Composite keys need a deterministic
string ("row:12|col:3"), because a fresh tuple/object literal is a new key
every time. If you want structural matching, hash the key yourself in a thin
wrapper (set(hash(k), v)) rather than loosening the hook.
- Extra helpers, same pattern — update(key, fn) that no-ops on a missing key,
toggle(key) for a boolean map, getOrSet(key, factory), removeAll(keys): each
one is a pure prev => next transform handed to the same single writer, and
each must return `prev` unchanged when nothing changed to keep the bail-out.
- Very large or very hot maps — the O(n) copy per write is the price of
reference-as-signal. Past a few thousand entries or sub-frame write rates,
either shard the state into several useMap instances, or move to a mutable
Map in a ref plus a version counter (useSyncExternalStore), or a persistent
data structure (immer's MapSet plugin, immutable.js).
- Persistence — a Map is not JSON. Serialize with Array.from(map) and rehydrate
with replace(entriesFromStorage) so reset() returns to the stored copy rather
than the mount-time seed; pair with a storage hook for reload survival.
- Derived views — sorting/filtering belongs in useMemo over Array.from(map),
keyed on the map identity: because the identity only changes on real changes,
that memo actually holds.
- Undo — this hook has one baseline, not a history. For step-by-step revert,
keep entries in an undo/redo stack and feed the map with replace().Concepts
- Copy-on-write as the change signal — every write clones the map and edits the clone, because React compares references. An in-place
map.set()is the classic "the data changed but nothing re-rendered" bug: same reference, no render, and the screen quietly drifts away from the truth. The cost is one O(n) copy per committed write, which is why the bail-out below matters. - No-op bail-out — a transform that changes nothing returns the previous
Mapreference, so React skips the update: re-writing an equal value, deleting a key that was never there, clearing an already empty map. That is also what makes the map safe to put in a dependency array — its identity is a real "the contents changed" signal, not render noise. - SameValueZero keys — a
Mapcompares keys the way JavaScript compares identities:1and"1"stay separate,NaNmatches itself, and an object key matches only the same reference (a structurally identical copy is a different key). An object record cannot express any of that — it runs every key throughString(key)and drags a prototype along. - Presence is not value —
set(key, undefined)stores an entry,remove(key)deletes one.hasis the only way to tell them apart, which is exactly why every equality check inside the hook testshasbefore comparing values. - One functional updater — all writers hand a pure
prev => nexttransform to the samesetState, so several writes in one event compose instead of overwriting each other; iterables are materialized before dispatch so StrictMode's double-invoked updater cannot consume a one-shot generator twice. - Stable actions, live map —
actionsnever changes identity (safe in dep arrays and as aReact.memoprop), whileget/hasread a last-committed ref so a stale closure still gets a real answer. During render you read the tuple's map instead: that is this frame's snapshot, and writes are queued, sosetthengetin the same handler still sees the old value.
useIndexedDb
An async IndexedDB-backed store hook with loading state, versioned upgrades, blocked-deadlock handling, quota errors, key-range queries and cross-tab sync.
useRaf
A requestAnimationFrame loop hook that hands every frame a delta/elapsed payload, with start/stop controls, an fps cap and automatic hidden-tab suspension.