Skip to the solution
HardFrontend12 min read

How does Vue's virtual DOM diff keyed lists?

705 views

The problem

Question presented to candidate: "When a v-for list changes order, how does Vue decide which DOM nodes to keep, move, create or remove? Walk me through the keyed diff algorithm, and tell me what goes wrong if I use the index as the key."

What a strong answer should cover:

  • Keys give each VNode an identity, so Vue can match old and new children by key rather than by position.
  • Vue 3's patchKeyedChildren steps: sync the common prefix, sync the common suffix, then the simple cases (only additions, or only removals), then the general case.
  • The general case: a key-to-new-index map, patch or unmount each old child, then use the longest increasing subsequence of old positions to decide which nodes stay put and move only the rest.
  • Concrete costs: reversing 5 items moves 4; moving one item moves 1; with keys, existing nodes are never re-rendered just because they moved.
  • Index keys (or none) mean patching in place by position: no moves, but text rewrites on every shifted row and DOM state (inputs, focus, transitions) attached to the wrong item.
  • Duplicate keys trigger a dev warning.

Clarifying questions expected:

  • "Are we talking about the keyed path, or unkeyed lists as well?"

Code / implementation expected: Optional β€” a demo of index keys losing input state, or a walk-through of an example reorder.

  • vue
  • virtual-dom
  • diffing
  • internals

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

12 min read Β· 11 sections

Read the code

VueIndex keys vs stable ids: watch typed input jump rows on prepend
<script setup>
import { ref } from "vue";

let nextId = 4;
const todos = ref([
  { id: 1, name: "Write tests" },
  { id: 2, name: "Fix bug" },
  { id: 3, name: "Ship it" },
]);

// Prepend: the classic case where index keys go wrong.
const prepend = () => todos.value.unshift({ id: nextId++, name: "New task" });
</script>

<template>
  <button @click="prepend">Prepend a task</button>
  <div class="cols">
    <section>
      <h4>:key="index"</h4>
      <!-- Each row has an uncontrolled input: its typed text lives in the DOM, not in state. -->
      <div v-for="(t, index) in todos" :key="index" class="row">
        <span>{{ t.name }}</span> <input placeholder="note" />
      </div>
    </section>
    <section>
      <h4>:key="t.id"</h4>
      <div v-for="t in todos" :key="t.id" class="row">
        <span>{{ t.name }}</span> <input placeholder="note" />
      </div>
    </section>
  </div>
  <p>Type a note next to "Write tests" in both columns, then prepend.</p>
</template>
Instrumenting the DOM to count creates, moves and text rewrites (jsdom)
import { JSDOM } from "jsdom";
const dom = new JSDOM('<!doctype html><div id="app"></div>');
for (const k of ["window","document","navigator","Node","Element","HTMLElement","SVGElement","Text","Comment"]) { try { globalThis[k] = dom.window[k]; } catch {} }
const { createApp, ref, nextTick } = await import("vue");

// Instrument the DOM operations Vue's renderer performs.
const ops = { create: 0, insert: 0, remove: 0, text: 0, moves: [] };
const P = dom.window.Node.prototype;
const origInsert = P.insertBefore, origRemove = P.removeChild;
const desc = Object.getOwnPropertyDescriptor(P, "textContent");
const origCreate = dom.window.Document.prototype.createElement;
let counting = false;
P.insertBefore = function (n, ref) { if (counting) { ops.insert++; if (n.isConnected) ops.moves.push(n.textContent); } return origInsert.call(this, n, ref); };
P.removeChild = function (n) { if (counting) ops.remove++; return origRemove.call(this, n); };
Object.defineProperty(P, "textContent", { get: desc.get, set(v) { if (counting && this.nodeType === 1 && this.isConnected) ops.text++; desc.set.call(this, v); }, configurable: true });
dom.window.Document.prototype.createElement = function (...a) { if (counting) ops.create++; return origCreate.apply(this, a); };

async function scenario(name, keyMode, from, to) {
  document.body.innerHTML = '<div id="app"></div>';
  const items = ref(from);
  const keyExpr = keyMode === "id" ? ':key="item"' : keyMode === "index" ? ':key="i"' : "";
  const app = createApp({ setup: () => ({ items }), template: `<ul><li v-for="(item, i) in items" ${keyExpr}>{{ item }}</li></ul>` });
  app.mount("#app");
  const before = [...document.querySelectorAll("li")];
  Object.assign(ops, { create: 0, insert: 0, remove: 0, text: 0, moves: [] });
  counting = true; items.value = to; await nextTick(); counting = false;
  const after = [...document.querySelectorAll("li")];
  const reused = after.filter((el) => before.includes(el)).length;
  console.log(`${name.padEnd(34)} key=${keyMode.padEnd(5)} ${from.join("")} -> ${to.join("")}: created ${ops.create}, inserted ${ops.insert} (moves of existing: ${ops.moves.length}${ops.moves.length ? " [" + ops.moves.join(",") + "]" : ""}), removed ${ops.remove}, text rewrites of existing <li> ${ops.text}, reused ${reused}/${after.length}`);
  app.unmount();
}
const L = ["A","B","C","D","E"];
for (const k of ["id", "index"]) {
  await scenario("move last to second", k, L, ["A","E","B","C","D"]);
  await scenario("move B to fourth (LIS keeps C,D)", k, L, ["A","C","D","B","E"]);
  await scenario("reverse", k, L, [...L].reverse());
  await scenario("prepend Z", k, L, ["Z", ...L]);
  await scenario("remove C", k, L, ["A","B","D","E"]);
  await scenario("insert X in the middle", k, L, ["A","B","X","C","D","E"]);
}
await scenario("prepend Z", "none", L, ["Z", ...L]);
await scenario("reverse", "none", L, [...L].reverse());

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 82 of 92 in Vue.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Vue.js questions
Up nextHard

How would you structure state management for a large Vue app?

Continue