When a list's data changes, because an item is added, removed, or moved, the page's elements have to change to match. Making every element again would be slow and would throw away their state, so frameworks such as React and Vue reuse the elements that are already there. A keyed list update decides which element belongs to which item by a key: an ID you give each item, such as key={todo.id} in React or :key="todo.id" in Vue.
Picture a teacher who knows the class by name rather than by seat. If two pupils swap seats, she still hands each of them their own homework. A teacher who goes by seat would hand Sam's homework to whoever now sits where Sam sat.
It matters because an element carries state of its own that isn't in your data: the text typed into an <input>, whether a checkbox was ticked, which element has focus, how far a box is scrolled. Say each row of a shopping list has a checkbox, you tick Eggs, and then Jam is added at the top:
| Row | Before | After, matched by position | After, matched by key |
|---|---|---|---|
| 1 | Milk | Jam, in Milk's old element | Jam, in a new element |
| 2 | Eggs, ticked | Milk, in Eggs' old element, so Milk now shows the tick | Milk, in its own element |
| 3 | Bread | Eggs, in Bread's old element, with no tick | Eggs, in its own element, still ticked |
| 4 | Nothing | Bread, in a new element | Bread, in its own element |
Without keys, React and Vue match elements to items by position, as in the middle column, and using the array index as the key does the same. With keys, an item whose key is still in the list keeps its element, a new key gets a new element, and a key that's gone loses its element. A key has to be unique among its siblings and stay the same for the same item, which is why an ID from your data is the usual choice.
An element that stays where it is keeps all of its state. One that has to move to a new place keeps what's stored on it, like typed text or a tick, though moving it can lose focus and reset its scroll position. You meet keys as React's key prop, Vue's :key on v-for, and Svelte's keyed {#each} blocks, and React warns in the console during development when items in a list have none.
Write updateList(list, items). items is an array of { id, text }, where each id is a string or a number, unique in the array. The id is the item's key.
Afterwards, list holds exactly one <li> per item, in the array's order, with data-key set to the item's id as a string, and the item's text as its text, inserted as text.
An item whose key already has an <li> in the list keeps that same element: move it if the order changed, and set its text only if the text changed.
Create <li>s for new keys, and remove the <li>s whose keys are gone, along with anything else in the list. Leave alone the nodes that are already right: an item that keeps its place keeps its focus.
const list = document.getElementById("list");
updateList(list, [{ id: 1, text: "Milk" }, { id: 2, text: "Eggs" }]);
const eggs = list.children[1];
updateList(list, [{ id: 3, text: "Bread" }, { id: 2, text: "Eggs" }]);
// <ul id="list"><li data-key="3">Bread</li><li data-key="2">Eggs</li></ul>
list.children[1] === eggs; // true: the same element, never moved
const bread = list.children[0];
updateList(list, [{ id: 2, text: "Eggs" }, { id: 3, text: "Brown bread" }]);
// <ul id="list"><li data-key="2">Eggs</li><li data-key="3">Brown bread</li></ul>
list.children[0] === eggs; // true: moved, not made again
list.children[1] === bread; // true: the same element, with its text changed
const list = document.getElementById("list");
updateList(list, [{ id: "a", text: "Apples" }, { id: "b", text: "Bread" }]);
expect([...list.childNodes].map((node) => node.nodeName)).toEqual(["LI", "LI"]);
expect([...list.children].map((li) => li.dataset.key)).toEqual(["a", "b"]);
expect([...list.children].map((li) => li.textContent)).toEqual(["Apples", "Bread"]);
const list = document.getElementById("list");
updateList(list, [{ id: "a", text: "A" }, { id: "b", text: "B" }]);
const b = list.children[1];
updateList(list, [{ id: "b", text: "B" }, { id: "c", text: "C" }]);
expect(list.children[0]).toBe(b);
expect([...list.children].map((li) => li.dataset.key)).toEqual(["b", "c"]);
const list = document.getElementById("list");
updateList(list, [{ id: 1, text: "One" }, { id: 2, text: "Two" }, { id: 3, text: "Three" }]);
const [one, two, three] = list.children;
updateList(list, [{ id: 3, text: "Three" }, { id: 1, text: "One" }, { id: 2, text: "Two" }]);
expect([...list.children].map((li) => li.dataset.key)).toEqual(["3", "1", "2"]);
expect(list.children[0]).toBe(three);
expect(list.children[1]).toBe(one);
expect(list.children[2]).toBe(two);
const list = document.getElementById("list");
updateList(list, [{ id: "a", text: "A" }, { id: "b", text: "B" }, { id: "c", text: "C" }]);
const b = list.children[1];
updateList(list, [{ id: "a", text: "A" }, { id: "c", text: "C" }]);
expect([...list.children].map((li) => li.dataset.key)).toEqual(["a", "c"]);
expect(b.isConnected).toBe(false);
const list = document.getElementById("list");
updateList(list, [{ id: "a", text: "Milk" }]);
const a = list.children[0];
updateList(list, [{ id: "a", text: "Oat milk" }]);
expect(list.children[0]).toBe(a);
expect(a.textContent).toBe("Oat milk");
const list = document.getElementById("list");
updateList(list, [{ id: "a", text: "A" }, { id: "b", text: "B" }, { id: "c", text: "C" }]);
const old = [...list.children];
const observer = new MutationObserver(() => {});
observer.observe(list, { childList: true, subtree: true, characterData: true });
updateList(list, [{ id: "x", text: "X" }, { id: "a", text: "A" }, { id: "b", text: "B" }, { id: "c", text: "C" }]);
const records = observer.takeRecords();
observer.disconnect();
const onList = records.filter((record) => record.target === list);
const added = onList.reduce((sum, record) => sum + record.addedNodes.length, 0);
const removedOrMoved = onList.reduce((sum, record) => sum + record.removedNodes.length, 0);
const rewritten = records.filter((record) => old.some((li) => li.contains(record.target))).length;
expect(added).toBe(1);
expect(removedOrMoved).toBe(0);
expect(rewritten).toBe(0);
const list = document.getElementById("list");
updateList(list, [{ id: "a", text: "A" }, { id: "b", text: "B" }, { id: "c", text: "C" }]);
const b = list.children[1];
b.tabIndex = -1;
b.focus();
updateList(list, [{ id: "x", text: "X" }, { id: "a", text: "A" }, { id: "b", text: "B" }]);
expect([...list.children].map((li) => li.dataset.key)).toEqual(["x", "a", "b"]);
expect(document.activeElement).toBe(b);
const list = document.getElementById("list");
updateList(list, [{ id: "a", text: "A" }, { id: "b", text: "B" }]);
updateList(list, []);
expect(list.childNodes).toHaveLength(0);
const list = document.getElementById("list");
updateList(list, [{ id: "a", text: "<b>bold</b>" }]);
expect(list.querySelector("b")).toBeNull();
expect(list.children[0].textContent).toBe("<b>bold</b>");
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with Keyed list update that reuses nodes. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.