Skip to the code
cf.

Keyed list update that reuses nodes

HardDOM

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

What the tests check

  1. renders one li per item with data-key and text, replacing what was there
    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"]);
    
  2. keeps the same element for a key that stays
    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"]);
    
  3. reorders the existing elements, with number ids
    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);
    
  4. removes the elements whose keys are gone
    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);
    
  5. updates changed text on the same element
    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");
    
  6. adding an item leaves every other node untouched
    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);
    
  7. keeps focus on an item that doesn't need to move
    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);
    
  8. an empty array empties the list
    const list = document.getElementById("list");
    updateList(list, [{ id: "a", text: "A" }, { id: "b", text: "B" }]);
    updateList(list, []);
    expect(list.childNodes).toHaveLength(0);
    
  9. inserts text as text, never as HTML
    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>");
    

Learn more

solution.jsSaved in this browser

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).