In a delegated list handler, why keep per-item state in a JavaScript Map captured by the handler's closure rather than storing it in data-* attributes on the elements?
answer
- the DOM is a view, not a store
- attributes hand back strings
- closure already surrounds the container
- id in the markup, object in the Map
- re-render wipes what you wrote there
basics
~20 sDataset values are strings, so state stored in the DOM must be parsed on every read and is lost whenever the element is re-rendered. Keep a Map in the handler's closure keyed by a stable id, and put only that id in the markup.
solid answer
~50 sThe DOM is a presentation surface, not a data store. Anything you write into a `data-*` attribute comes back as a string, so objects, dates and booleans have to be serialised on write and parsed on read — and they are gone the moment the row is re-rendered from scratch. The delegation idiom gives you a better home for free: the handler is already a closure over the container, so let it close over a `Map` of item id to a real JavaScript value, and let the markup carry only the id. The handler then reads `state.get(row.dataset.id)` and gets a live object with its original types and identity. The DOM keeps exactly what it needs to be a view — an id to identify the row, and classes to reflect state — and the state itself stays where you can reason about it.
code
javascript · 26 linesfunction createList(container) {
const state = new Map();
let nextId = 1;
container.addEventListener('click', (event) => {
const row = event.target.closest('li[data-id]');
if (!row || !container.contains(row)) return;
const item = state.get(row.dataset.id);
item.done = !item.done;
row.classList.toggle('done', item.done);
console.log(item.label, item.done, item.addedAt instanceof Date);
});
return function addItem(label) {
const id = String(nextId++);
state.set(id, { label, done: false, addedAt: new Date() });
const li = document.createElement('li');
li.dataset.id = id;
li.textContent = label;
container.append(li);
};
}
const ul = document.body.appendChild(document.createElement('ul'));
const addItem = createList(ul);
addItem('Write tests');go deeper
Know that reading a data-* attribute gives you a string, and that the delegated handler can simply close over a Map of id to object instead. Put the id in the markup and nothing else.
Explain the three concrete wins — types and identity preserved, no parsing on read, no loss on re-render — and show the lookup: match the row with closest(), then state.get(row.dataset.id).
Own the lifecycle: pair every row removal with the Map delete, and be able to argue Map-keyed-by-id versus WeakMap-keyed-by-node with the tradeoff each one makes about re-renders and cleanup.
Frame it as a single-source-of-truth boundary: the model lives in JavaScript, the DOM is a projection with a declared read surface (ids, classes, ARIA, test hooks). Say what belongs in that surface so other teams do not quietly turn attributes into an API.
## The shape of the pattern A delegated handler is a closure. You create it inside a function that also owns the container, and that scope can hold anything you want the handler to see later: ```js function createList(container) { const state = new Map(); // id -> real JS values container.addEventListener('click', (event) => { const row = event.target.closest('li[data-id]'); if (!row || !container.contains(row)) return; const item = state.get(row.dataset.id); item.done = !item.done; row.classList.toggle('done', item.done); }); return (id, item) => { state.set(id, item); /* ...render row... */ }; } ``` One listener, one state container, one id in the markup. The DOM holds the identity of the row; the closure holds what the row *is*. ## Why the closure is the better home **Types survive.** A `Map` value can be an object, a `Date`, a nested array, even a function. `data-*` storage flattens everything through string conversion: `el.dataset.count` is `"3"`, not `3`, so `dataset.count + 1` silently produces `"31"`. Anything structured has to be JSON round-tripped, which loses types that JSON has no representation for. **Identity survives.** If the same object also lives in the array you render from, the closure hands you *that* object — mutating it and re-rendering stays consistent. A serialised copy in an attribute is a second source of truth that will drift. **Privacy.** State in the closure is reachable only through the functions you return. State in the DOM is world-readable and world-writable: any other script, extension or stylesheet selector can read it and depend on it, which turns an implementation detail into an accidental contract. **Cost.** Reading and writing attributes touches the DOM; reading a `Map` does not. This rarely dominates, but it is free to avoid. ## What the DOM should still carry Not nothing — a stable id. The handler receives an element and needs to turn it into a state entry, so the element must identify itself. A `data-id` is the smallest possible bridge: a string that means "look me up". Also keep in the DOM whatever the *view* legitimately needs — classes reflecting state so CSS can style them, ARIA attributes conveying state to assistive technology. The rule is directional: state flows from the closure into the DOM as presentation, never the reverse. ## Keying and cleanup A `Map` keyed by id has one hazard: it does not know when a row disappears. Remove 10,000 rows over a session and you still hold 10,000 entries, which is a slow leak of your own making. Whatever code removes a row must delete its entry — put both in the same function so they cannot drift apart. The alternative is keying by the element itself in a `WeakMap`. Then discarding the element makes its entry unreachable and collectable with no bookkeeping. The trade is that you can no longer look state up by id — only by holding the element — and the entry does not survive a re-render that replaces the node. Use a `Map` keyed by id when rows are recreated from data; use a `WeakMap` keyed by node when the node is the identity and lives as long as the state should. ## When the DOM genuinely is the right place Be honest about the exceptions. Put a value in the markup when: - it is markup-rendered on the server and must be present before any script runs; - CSS needs it, via an attribute selector; - it *is* presentation — a label, an image source, a `disabled` flag; - an external consumer, such as a test selector or analytics attribute, is meant to read it. What does not belong there is the model: the object your code mutates, computes with, and re-renders from. ## The failure this prevents The usual bug goes like this. Someone stores `data-count="0"`, increments it with `el.dataset.count = el.dataset.count + 1`, and ships `"01"`. Someone else stores a serialised object and it drifts from the array the page renders from. Then a re-render rebuilds the row from data and every value written into the markup by the handler is silently gone. Keeping state in the closure removes all three failure modes at once, and the delegated handler already had the scope to do it.
- What would push you to key the state by the element in a WeakMap instead of by id in a Map?When the element itself is the identity and you want cleanup for free: dropping the node makes its entry unreachable, so removed rows cannot accumulate state. The cost is that you can only look up by holding the element, and the entry does not survive a re-render that replaces the node — so id-keyed Maps still win when rows are rebuilt from data.
- What goes wrong if rows are removed but their Map entries never are?The Map grows for the lifetime of the page, holding state for rows that no longer exist. It is a leak you authored rather than one the platform imposed, and it also breaks any code that iterates the Map expecting it to mirror what is on screen. Put the removal and the delete in the same function.
- Is there any state you would still write into the markup?Yes — anything the view or an outside consumer legitimately reads: classes and ARIA attributes reflecting state, values rendered on the server that must exist before scripts run, attributes CSS selects on, and stable test hooks. The line is that those are projections of state, while the object you mutate and re-render from stays in JavaScript.
saying these in an interview costs you the question
- Treats data-* attributes as a general-purpose object store
- Forgets dataset reads always come back as strings
- Increments a dataset number and ships string concatenation
- Never deletes Map entries when rows are removed
- Keeps two sources of truth — the array and the markup