Suggest an editImprove this articleRefine the answer for “How does virtual DOM work in Vue.js?”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**Virtual DOM in Vue.js** - a JavaScript object tree that mirrors the real DOM, letting Vue calculate exact mutations instead of rewriting everything on each data change. ```javascript // VNode structure { type: 'p', props: {}, children: ['Count: 5'] } // Vue diffs old vs new VNode, patches only the text node ``` Vue's compiler turns `<template>` into a `render()` function returning VNodes. On reactive change, `patch(oldVNode, newVNode)` walks both trees and calls browser APIs only for the diffs. Compiler-injected `patchFlags` let the runtime skip unchanged subtrees entirely. **Key point:** batching via `nextTick()` means 10 state mutations produce 1 patch pass, not 10.Shown above the full answer for quick recall.Answer (EN)Image**Virtual DOM in Vue.js** - a JavaScript object tree that mirrors the real DOM structure, letting Vue calculate the minimum set of actual DOM mutations needed after each data change. ## Theory ### TL;DR - Think of sketching on paper before painting walls: you only repaint what changed. - Vue compiles `<template>` into a `render()` function that returns VNodes - plain JS objects with `type`, `props`, `children`, `key`. - On reactive change, Vue runs `patch(oldVNode, newVNode)` and calls browser APIs only for the diffs. - Virtual DOM uses more memory (~2x DOM size) and gives a slower initial render, but makes frequent updates much cheaper. - Decision rule: interactive UIs with frequent state changes - use virtual DOM. Static pages - skip it entirely. ### Quick example ```vue <script setup> import { ref } from 'vue' const count = ref(0) </script> <template> <p>Count: {{ count }}</p> <!-- VNode created here --> <button @click="count++">+1</button> </template> ``` Click the button: Vue creates a new VNode tree, diffs it against the previous one, and patches only the text node inside `<p>`. The `<button>` VNode is identical to the previous one and gets skipped completely. ### Virtual DOM vs direct DOM Direct DOM writes (`element.innerHTML = ...`) trigger full reflows and repaints on every assignment. Vue batches all reactive changes into a single diff pass, computes the exact mutations needed (e.g., a `textContent` swap), and applies them in one go via `patch()`. For a page updating ten times per second, that difference is visible. ### How Vue builds the virtual tree Vue's compiler transforms `<template>` into a `render()` function. That function returns a tree of VNodes - plain JS objects with fields `type`, `props`, `children`, and `key`. When reactive state changes (a `ref()` setter fires), the scheduler queues the component's render effect. On the next tick it re-runs `render()`, gets a new VNode tree, and hands both trees to `patch()`. V8 optimizes traversal of these objects through hidden classes, so the diff itself is fast. The browser's `createElement()` and `setAttribute()` get called only where the two trees differ. ### The patch algorithm `patch(oldVNode, newVNode)` walks both trees recursively with a few heuristics. Same tag and same `key`? Update props and recurse into children. Different tag? Unmount old, mount new. For sibling lists, Vue uses a longest-increasing-subsequence algorithm to find the minimum number of moves. That is why `:key` matters so much on `v-for`: without it, Vue falls back to index-based comparison, which produces wrong results on insertions and deletions. `shapeFlag` bitmasks (e.g., `1=Element`, `4=Text`, `6=Fragment`) let Vue branch in O(1) instead of checking types with `instanceof`. `patchFlags` go further: the compiler injects flags like `TEXT=1` or `PROPS=16` so the runtime skips full child recursion when only text changed. In production builds that gives roughly 90% faster updates compared to a naive full diff. ### Batching and nextTick Vue does not patch after every single reactive write. It queues jobs and flushes them in a microtask via `Promise.resolve().then()`. That is what `nextTick()` hooks into. Group ten state mutations together and you get one patch pass, not ten. I have seen code that called `nextTick` inside a `for` loop expecting sequential DOM reads - it does not work that way. One flush per tick. ### Common mistakes **Mistake: using `:key` on non-list nodes** ```vue <!-- Wrong: forces full unmount/remount on every count++ --> <div :key="count">Static text</div> <!-- Right: omit key for stable nodes --> <div>Static text</div> ``` Every `count++` destroys and recreates the element, losing focus state and breaking CSS transitions. **Mistake: mutating props directly in a child component** ```vue <script setup> const props = defineProps(['item']) props.item.done = true // Wrong: bypasses reactivity </script> ``` Vue diffs the parent's array shallowly. Direct mutation does not fire a reactive setter, so no new VNode is created and no patch runs. Emit an event up instead: `emit('update:item', ...)`. **Mistake: using array index as key** ```vue <!-- Wrong: index shifts on insert/delete --> <li v-for="(todo, i) in todos" :key="i"> <!-- Right: stable unique id --> <li v-for="todo in todos" :key="todo.id"> ``` Insert at position 0 and every index shifts by one. Vue sees all keys changed and re-renders the whole list, not just the new item. **Mistake: wrong expectations from Teleport** `<Teleport to="body">` moves VNodes to a different part of the real DOM, but the diff still runs in the component tree. If the target element does not exist when the component mounts, Vue 3.2+ logs a warning and the teleport silently fails. Always verify the target is mounted first. ### Real-world usage - Vue/Nuxt TodoMVC: `v-for :key` diffs 1000+ items at 60fps without visible lag. - Quasar dynamic forms: conditional VNodes via `v-if` skip rendering unused fields entirely. - Element Plus tables: keyed rows let Vue reorder columns in O(n) instead of rebuilding the whole table. - Pinia-driven lists: store batch mutations flush as one patch pass, not one per mutation. ### Follow-up questions **Q:** How does Vue diff sibling VNodes without keys? **A:** It assumes same order and patches by index. Insert at position 0 and every node after it gets patched unnecessarily. This is exactly why keyed diffing exists. **Q:** What are `patchFlags` and why do they matter? **A:** The compiler injects integer flags on VNodes at build time. The runtime checks the flag before deciding what to diff. `TEXT=1` means only text changed, so Vue skips props comparison entirely. In real apps this cuts diff time by around 90% versus a full recursive walk. **Q:** Why does `nextTick()` return a Promise? **A:** Vue flushes queued patch jobs in a microtask. `nextTick()` returns a Promise that resolves after that flush, so you can `await nextTick()` to read updated DOM values without race conditions. **Q:** How does SSR handle the virtual DOM? **A:** `renderToString()` serializes VNodes to an HTML string. No diff runs on the server. The client receives that HTML and hydrates it by patching the server-rendered nodes in place rather than replacing them. **Q:** How does Vue's diff compare to React's Fiber reconciler? **A:** Vue runs a synchronous single-pass diff. React's Fiber is resumable - it can pause mid-reconciliation and yield to higher-priority work. Vue's approach is simpler and faster for most cases; React's handles very large trees where scheduling matters. ## Examples ### Basic: counter with a single reactive node ```vue <script setup> import { ref } from 'vue' const count = ref(0) </script> <template> <p>Count: {{ count }}</p> <button @click="count++">+1</button> </template> ``` After each click Vue creates a new VNode for `<p>`. The diff finds only the text node changed and calls `node.textContent = newCount`. The button VNode is identical to the previous and gets skipped completely. ### Intermediate: todo list with key-based diffing ```vue <script setup> import { ref, computed } from 'vue' const todos = ref([ { id: 1, text: 'Buy milk', done: false }, { id: 2, text: 'Write code', done: true } ]) const activeCount = computed(() => todos.value.filter(t => !t.done).length) const toggle = (id) => { const todo = todos.value.find(t => t.id === id) todo.done = !todo.done // Triggers diff on the list } </script> <template> <ul> <li v-for="todo in todos" :key="todo.id" @click="toggle(todo.id)"> {{ todo.text }} <span v-if="todo.done">(done)</span> </li> </ul> <p>{{ activeCount }} active</p> </template> ``` Toggle an item: Vue diffs the list by `key`, patches only the clicked `<li>` (adds or removes the `<span>`), and recomputes the `activeCount` VNode. Every other list item is untouched. Without `:key="todo.id"`, Vue would re-render all `<li>` elements on any array change. ### Advanced: Teleport and cross-boundary patching ```vue <script setup> import { ref } from 'vue' const showModal = ref(false) </script> <template> <button @click="showModal = true">Open</button> <Teleport to="body"> <div v-if="showModal" class="modal" @click="showModal = false"> Click to close </div> </Teleport> </template> ``` When `showModal` flips to `true`, Vue patches `<body>` directly via the Teleport VNode flag, not the component's root. The diff still runs in component tree order, but the DOM mutation lands on a completely different node. If `body` is not available when the component mounts (e.g., during SSR), Vue 3.2+ logs a warning. This is the clearest example that virtual DOM positions and real DOM positions do not have to match.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.