Hooks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * 一份 entries:数组、另一个 `Map`、`Object.entries(...)` 的结果、生成器 ——
 * 凡是能迭代出 `[key, value]` 的都算。
 */
export type MapEntries<K, V> = Iterable<readonly [K, V]>

/**
 * 初始 entries。传函数则是**惰性初始化**,只在挂载时调用一次(语义同 `useState`);
 * 之后再传一份新的字面量**不会**覆盖当前 map —— 那是 `replace()` 的活。
 */

Installation

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

Prompt

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 Map reference, 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 Map compares keys the way JavaScript compares identities: 1 and "1" stay separate, NaN matches 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 through String(key) and drags a prototype along.
  • Presence is not valueset(key, undefined) stores an entry, remove(key) deletes one. has is the only way to tell them apart, which is exactly why every equality check inside the hook tests has before comparing values.
  • One functional updater — all writers hand a pure prev => next transform to the same setState, 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 mapactions never changes identity (safe in dep arrays and as a React.memo prop), while get/has read 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, so set then get in the same handler still sees the old value.

On This Page