A widget refreshes its list by assigning container.innerHTML = newMarkup on every update. What starts breaking as the widget grows, and how do you reason about it?
answer
- assignment replaces, it does not update
- new node objects every time
- listeners live on nodes, not markup
- typed values never reach the attributes
- += re-serializes the whole subtree
basics
~20 sAssigning innerHTML discards every existing child and parses replacement nodes, so anything held on the old nodes is lost: attached event listeners, typed input values, checkedness, focus, selection and scroll position, plus any JavaScript references, which now point at detached nodes.
solid answer
~50 sThe `innerHTML` setter is not an update, it is a replacement: it throws away every existing child node and parses fresh ones from the string. Everything that lived on those node objects rather than in the markup goes with them — attached event listeners, the current value and checkedness of form controls, focus, text selection, scroll position, and any element references your code was holding, which now point at detached nodes. The symptoms show up as "the handler works once and then stops", "the field clears while I am typing", and "focus jumps away on every refresh". `innerHTML += ...` is the worse form, because it serializes the whole current subtree back to a string, concatenates, and re-parses everything — destroying state even in the parts you did not touch. `insertAdjacentHTML('beforeend', markup)` appends without disturbing existing children, and when only text changes, writing `textContent` on the specific node changes nothing else.
code
javascript · 13 linesconst box = document.createElement('div');
document.body.append(box);
box.innerHTML = '<input id="a"><button>go</button>';
const input = box.querySelector('input');
input.value = 'typed';
box.querySelector('button').addEventListener('click', () => console.log('clicked'));
box.innerHTML += '<span>extra</span>'; // re-serializes and re-parses everything
console.log(box.querySelector('input') === input); // false — a new node
console.log(box.querySelector('input').value); // "" — typed text gone
console.log(input.isConnected); // false — old node detachedgo deeper
Know that assigning innerHTML wipes out every existing child and builds new ones, so listeners attached to the old elements stop working after a refresh.
Explain which state is carried by node objects rather than by markup — listeners, current value, checkedness, focus, scroll — and why innerHTML += re-serializes and re-parses the entire subtree.
Diagnose from symptoms: a handler that fires once, a field that clears mid-typing, a list that scrolls to top. Then argue when wholesale replacement is acceptable and when the write must be narrowed to what actually changed.
Own the rendering discipline: which regions of the UI are declared disposable, where interactive state is allowed to live, and how the codebase keeps data-driven markup generation from becoming both a state-loss and an injection surface.
## What the assignment actually does `container.innerHTML = markup` runs two steps. First it parses the string into a document fragment. Then it removes every existing child of `container` and inserts the fragment's nodes in their place. The old nodes are not updated, reused, or diffed against the new ones — they are detached and, once nothing references them, collected. Even if the new markup is byte-for-byte identical to the old, you get an entirely new set of node objects. That is the whole mechanism. Every symptom below is a consequence of it. ## What lives on nodes rather than in markup A node object carries state that its HTML serialization does not express. Replace the node and the state is gone: - **Event listeners** added with `addEventListener`. They are attached to the node object; the serialized markup contains no trace of them. This produces the signature bug: the button works, the list refreshes, the button is dead. - **Current form state.** A typed value lives in the control's value property, not in the `value` attribute, so the serializer never writes it. Same for a checkbox's checkedness against the `checked` attribute, and for which `<option>` is currently selected. - **Focus.** If the focused element is removed, focus falls back to the body. A widget that rebuilds on every keystroke will yank the caret out of the field on the first character. - **Selection and caret position** inside text, for the same reason. - **Scroll position** of any scrollable descendant. - **Your own references.** Variables holding elements from the old subtree still work, but they now point at detached nodes: reads succeed, writes succeed, and nothing appears on screen. This is a particularly slippery failure because nothing throws. ```js const box = document.createElement('div'); box.innerHTML = '<input><button>go</button>'; const input = box.querySelector('input'); input.value = 'typed'; box.querySelector('button').addEventListener('click', () => console.log('hi')); box.innerHTML += '<span>extra</span>'; box.querySelector('input') === input; // false — a different node box.querySelector('input').value; // "" — the typed text is gone // the click listener died with the discarded button ``` ## Why += is the worst version `el.innerHTML += x` desugars to `el.innerHTML = el.innerHTML + x`. The getter serializes the *entire current subtree* into a string; the setter then re-parses the whole thing. So appending one row rebuilds every row, and the amount of serializing and parsing grows with the size of the container rather than the size of the addition. Worse, that round-trip through text is lossy in exactly the ways listed above, so an operation that reads like "add one item" silently resets everything already there. `insertAdjacentHTML('beforeend', markup)` is the direct replacement: it parses only the new string and inserts the result next to the existing children without touching them. Positions are `'beforebegin'`, `'afterbegin'`, `'beforeend'`, `'afterend'`. It carries the same markup-sink caution as `innerHTML` — never feed it untrusted strings. ## Diagnosing it in the wild The bug report rarely says "innerHTML". It says one of these: - a control works the first time and never again; - the search field loses what the user typed, or loses focus, on every result update; - a checkbox visibly resets after an unrelated part of the panel refreshes; - a list scrolls back to the top whenever anything changes. When you see any of those, look for a wholesale write to `innerHTML` on an ancestor of the affected element. Confirming it is quick: hold a reference to a node before the refresh and check identity afterwards, or log `document.activeElement` before and after. ## The judgment to apply The question is not "is `innerHTML` bad" — it is "is this subtree genuinely disposable". Wholesale replacement is fine for a region that holds no interactive state, no listeners you attached individually, and no references you kept: a purely presentational panel rendered from data, thrown away and redrawn. The moment the region contains form controls the user interacts with, or you are holding node references across the refresh, the cost of the shortcut is state loss, and the fix is to narrow the write. Change only what changed: assign `textContent` on the one node whose text differs, flip a property on the one control, add the one new row with `insertAdjacentHTML` rather than re-emitting all of them. Narrower writes also mean less markup being generated from data, which shrinks the surface where an untrusted string could reach a markup sink at all.
- How do you append markup to a container without disturbing its existing children?`container.insertAdjacentHTML('beforeend', markup)`. It parses only the new string and inserts the resulting nodes at the given position — `beforebegin`, `afterbegin`, `beforeend`, `afterend` — leaving existing children, their listeners and their state untouched. Unlike `innerHTML +=`, it never serializes and re-parses what is already there. It is still a markup sink, so untrusted data stays out of it.
- Why does typed text vanish even when the container is reassigned exactly the same markup string?Because the markup was never carrying it. What the user typed lives in the control's `value` property; the `value` attribute still holds the authored default, and the serializer writes attributes. So the round trip through a string reproduces the original markup faithfully and loses the live state entirely. `checked` and `selected` disappear the same way.
- How would you confirm this is the cause from a bug report about a handler that stops working?Capture a reference to the element before a refresh and compare identity afterwards — if `before !== container.querySelector(sel)`, the node was replaced and any listener on it went too. Logging `document.activeElement` around the refresh catches the focus-loss variant. Then search upward from the affected element for a write to `innerHTML`.
saying these in an interview costs you the question
- Reassigning identical markup leaves the same nodes in place
- Event listeners are restored because the markup is the same
- innerHTML += only appends, it does not touch existing children
- The old element reference still points at what is on screen
- Typed values are safe because they appear in the HTML