InterviewsVector
advancedRare5 min read · Updated Jul 18, 2026

Virtual DOM I: Serialize the DOM to a Plain-Object Tree

Serialize real DOM into a virtual DOM tree in JavaScript — node-type dispatch, attributes vs properties, and what React's element objects actually contain.


The problem

Write serialize(element): convert a real DOM subtree into a tree of plain objects — { type, props, children } — that fully describes it.

This is part one of a two-part question (part two rebuilds real DOM from your objects; the round-trip must be lossless). Interviewers like the pair because it is the core representation trick of React and Vue, stripped of framework machinery: UI as data. A React element — what JSX compiles to — is almost exactly the object you're about to produce, and saying so up front reframes the exercise from "DOM trivia" to "reimplementing the industry's core idea."

The algorithmic content is a DFS over a tree with node-type dispatch — the same traversal as deep clone, pointed at a different tree.

Implementation

function serialize(node) {
  // Text nodes are leaves: no tag, no attributes, just characters
  if (node.nodeType === Node.TEXT_NODE) {
    return {
      type: "text",
      props: { nodeValue: node.nodeValue },
      children: [],
    };
  }
 
  // Skip comments, processing instructions, etc.
  if (node.nodeType !== Node.ELEMENT_NODE) {
    return null;
  }
 
  const vNode = {
    type: node.tagName.toLowerCase(), // normalize: DOM reports "DIV"
    props: {},
    children: [],
  };
 
  // Attributes as written in markup — id, class, data-*, aria-*
  for (const attr of node.attributes) {
    vNode.props[attr.name] = attr.value;
  }
 
  // childNodes, NOT children — we need text nodes too
  for (const child of node.childNodes) {
    const serialized = serialize(child);
    if (serialized !== null) vNode.children.push(serialized);
  }
 
  return vNode;
}

Round-trip check

const doc = new DOMParser().parseFromString(
  `<div id="app"><h1 class="title">Hello!</h1><p>Welcome.</p></div>`,
  "text/html"
);
 
serialize(doc.getElementById("app"));
// {
//   type: "div", props: { id: "app" }, children: [
//     { type: "h1", props: { class: "title" }, children: [
//       { type: "text", props: { nodeValue: "Hello!" }, children: [] } ] },
//     { type: "p", props: {}, children: [
//       { type: "text", props: { nodeValue: "Welcome." }, children: [] } ] } ]
// }

The details that separate answers

  • childNodes vs childrenchildren yields only elements; text lives in text nodes, and using children silently produces a tree with no words in it. This is the planted bug, and it's brutal because the structure looks right in the console.
  • tagName is uppercase ("DIV") for HTML — normalize on the way in or compare case-insensitively forever after.
  • Whitespace text nodes are real — pretty-printed HTML is full of "\n " text nodes; your serializer faithfully keeps them. Filtering them is a policy (React's JSX compiler trims some whitespace at compile time) — name the choice rather than being surprised by the noise.
  • Attributes ≠ propertiesattr.value reads the markup attribute; live DOM state can differ (input.value after typing vs its value attribute; checked likewise). A serializer of markup uses attributes; a serializer of current UI state would need property reads for form elements. Knowing the two layers exist is the senior distinction here — it returns with force in part two.
  • What can't serialize: event listeners. Listeners attached via addEventListener are invisible to any DOM traversal — there is no API to enumerate them. So a serialized tree describes structure, not behavior — precisely why React keeps handlers in its element objects (onClick: fn as a prop, never in the DOM) and why "serialize the page and send it elsewhere" schemes always lose interactivity.

How this maps to React

// Your vNode:                          // React's element (roughly):
{ type: "h1",                           { type: "h1",
  props: { class: "title" },              props: { className: "title",
  children: [ ... ] }                              children: [ ... ] },
                                          key: null, ref: null }

Differences worth reciting: React puts children inside props; uses className/htmlFor (property names, since class was a reserved word); adds key (identity for list diffing) and allows type to be a function — a component — which is the exact point where a template language becomes a component model. Your serializer goes DOM→object; React's render goes object→DOM — you're building the return leg in part two.

Edge cases interviewers probe

  • Comment nodes — skipped here (returns null, filtered by the caller); some VDOMs keep them as a comment type (Vue does, for placeholder anchors). Policy, stated.
  • SVG — serializes fine, but deserializing needs createElementNS — flag it now, handle it in part two.
  • Boolean attributes<input disabled> yields disabled: "" (empty string, present = true); faithfully round-trippable, semantically weird — know why.
  • Deep documents — recursion depth = DOM depth; real pages are shallow (~dozens), so the explicit-stack rewrite is rarely needed but is the standard follow-up (same conversion as deep-flatten).
  • iframe/shadowRoot boundaries — traversal stops at document/shadow boundaries; a serializer that claims "the whole page" without mentioning them is overclaiming.

Common mistakes

  • children instead of childNodes (the wordless tree).
  • Reading properties (node.className) while claiming to serialize attributes — mixing the two layers without knowing it.
  • Dropping text nodes' whitespace accidentally (trim-happy code) — changes rendering (<pre>, inline spacing).
  • Special-casing data-*/aria-* — they're ordinary attributes; the special-case code is a tell of pattern-matching over understanding.
  • Presenting the VDOM as "faster than the DOM" — it's a diffing substrate, not a speedup by itself; the honest pitch is "cheap to create and compare, so you can compute minimal DOM mutations." That sentence is the whole reason this data structure exists.

Follow-up questions

  • "Now go the other way."part two: deserialize; the pair is the question.
  • "And part three?"diff & patch: compare two virtual trees and apply minimal DOM mutations — the reconciliation step that makes the round-trip useful, with the O(n) heuristics (same type → recurse, different type → replace, keys for lists) that define React's algorithm.
  • "Serialize to HTML string instead?" — that's outerHTML / server-side rendering; discuss escaping (&lt;) and why string-building for the browser lost to object-building (JSON.stringify's escaping lesson rhymes).
  • "How big is the serialized tree?" — roughly proportional to DOM size; the VDOM memory trade — two trees in memory during diff — is the cost side React accepted; signals-based frameworks (Solid, Svelte 5) are the "skip the tree entirely" rebuttal, worth one sentence in 2026.
  • "Test the round-trip?"serialize → deserialize → serialize and deep-equal the two vNode trees — property-based round-trip testing, the same oracle trick as in curry-with-placeholders.

  • Browser History

    intermediate

    Model back/forward/push navigation with an index and a stack — the core of every router.

  • Diff two virtual trees and apply minimal DOM mutations — the reconciliation step that makes the virtual DOM worth having.

  • Event Delegation

    intermediate

    Implement delegate(root, selector, handler) with closest() — one listener for a thousand rows, and why frameworks did exactly this.