Where Dynamic DOM Updates Get Complicated

Markup can be split into three broad categories: content that never changes, content set once when a component is created, and content that must be updated while the page is live. The first two are straightforward. The third is where client-side rendering gets genuinely difficult, and it's worth understanding exactly why before reaching for a framework — or dismissing one.

Consider a simple color browser widget: a searchable list of named CSS colors, each paired with a swatch and a value, plus a toggle between hex codes and RGB triplets. The static skeleton is easy enough to build with plain HTML and CSS. The interesting part arrives when we add search and toggling, because now the DOM must reflect state changes at runtime.

The Vanilla Path

A natural first attempt with vanilla JavaScript uses the browser's built-in DOM APIs to generate the palette. A small utility function for element creation keeps the code concise, and a renderPalette function walks through the color data, creating list items and appending them to a container. Add a custom element wrapper, <color-browser>, and the whole interface expands in place wherever the tag appears.

This composition approach is reasonably declarative in that each markup piece is defined once in a clearly delineated generator function. Event handlers come next. A change handler on the search field and the representation toggle updates instance variables — the component's explicit state. But nothing in the UI reflects those changes yet. The state lives only in JavaScript.

The Rerender Trap

The obvious fix: rerun the markup generators whenever state changes, feeding them the current state. Move the rendering logic into a dedicated method and call it both on startup and after every state mutation. Make the color list a getter that filters based on the search query, and the data-driven approach seems coherent.

Then the input field loses focus after each keystroke. Entering characters becomes impossible, and the field appears empty even as the color list visibly narrows. The problem is wholesale DOM replacement. Erasing and recreating every node resets form field state: value, focus, scroll position. The component's explicit state is clean, but the browser's implicit state — the focused element, the caret position, the scroll offset — is destroyed on every render.

One could try folding that implicit state into the data model, tracking field values and checked properties explicitly. But that path quickly leads to also tracking focus, scroll position, and a long tail of details that are easy to overlook, particularly around accessibility. Before long, the application is effectively recreating the browser's own state management.

Why Libraries Exist

The alternative is incremental reconciliation: figure out which parts of the DOM actually need to change and leave the rest untouched. That problem is genuinely hard, which is precisely what React and similar libraries addressed over a decade ago. They provided a declarative surface for defining DOM structure while handling granular updates underneath, both to avoid clobbering implicit browser state and to keep performance acceptable.

The lesson is not that frameworks are always the answer, but that they solve a real problem. If you want to define markup once in encapsulated components and derive the UI from a mutable data model, you need some reconciliation layer between state changes and DOM updates. Doing that by hand means either full rerenders that destroy user state, or carefully targeted mutations that spread your application logic thin.

The Perils of Surgical Updates

The other extreme is imperative, surgical DOM manipulation: when the query changes, find the non-matching list entries and hide them, swap color representations in place, perhaps replace the whole list with an empty-state message. The code works, but it amounts to having application-level knowledge of rendering internals that were supposed to be encapsulated.

In the color browser, color entries belong to renderPalette, not to the top-level component. Piercing that boundary dissolves separation of concerns. Add form fields, and the problem compounds: updating validation state means reaching through renderControls, down into the generic createField utility, from the outermost component. Interrelated logic ends up scattered across the application, coupled to element structure that may change under any future refactor.

Even this minimal example becomes tangled. Larger applications with more layers and indirections devolve into a situation where nobody dares touch anything for fear of breaking an invisible dependency. The systems literature calls this distributed state and tight coupling; practitioners call it a big ball of mud by another name. The kernel of it all is that dynamic markup updates sit between two unattractive poles: state-lossy mass rerenders requiring a reconciliation library, or state-free manual mutations that betray encapsulation at every turn.

Why vanilla DOM updates get messy

Browser-native DOM APIs are powerful, but they were designed for imperative, surgical changes, not for rendering a view from state and then reconciling that view when state changes. If you want a declarative model — write your UI as a function of state, then let the framework figure out what changed — the platform gives you very little help.

The core problem is destructive updates. The most direct vanilla approach is to rebuild the DOM subtree on every state change, usually via innerHTML or replaceChildren(). That works, but it wipes out any existing node references, event listeners attached imperatively, focus, scroll position, and input state. For any non-trivial UI, that's a deal-breaker.

The alternative is manual diffing: walk the old and new trees yourself, compare node by node, and update only what changed. That's a textbook exercise in complexity — and it's precisely the problem that libraries like lit-html and Preact were built to solve, with algorithms like the keyed diff that handles list reordering correctly.

The missing standard API

It's telling that no standard browser API exists for "update this DOM subtree to match this new state, without destroying what's there." The platform gives you low-level tools: createTreeWalker(), MutationObserver, Node.cloneNode(), comment nodes as anchors. You can assemble something functional from those parts, as many have done, but it's neither concise nor obviously correct.

When you want all of the following at once — a declarative description of the view, true encapsulation of a component's internals, and non-destructive updates — vanilla JavaScript falls short. That combination is a core tenet of what many would call modern software engineering: explicit data flow, isolated components, and no hidden global mutation.

A pragmatic middle ground

That doesn't mean you must adopt a full framework. The missing piece is narrow: a small rendering layer that handles the diffing and patching. Libraries like lit-html and Preact fill exactly that gap. They are lightweight, learnable, and — critically — designed to be replaceable. If a standard browser API ever arrives, swapping out the rendering layer is a contained change, not an architectural upheaval.

  • Replaceability matters: choose libraries that don't leak their abstractions into your app code, so you can migrate when the platform improves. A rendering library is a stronger candidate for replacement than an entire framework.
  • Footprint is reasonable: well-regarded libraries in this space are small; they add marginal cost to end users, especially when paired with progressive enhancement strategies that render critical content on the server first.
  • Progressive enhancement still applies: nothing about using a small renderer forbids starting from server-rendered HTML and enhancing client-side behavior.

When vanilla is genuinely enough

For many pages, the honest answer is that declarative stateful rendering isn't needed at all. Static content, simple toggle interactions, or updates that genuinely replace a whole panel and all its listeners are all cases where vanilla JavaScript with innerHTML or replaceChildren() is adequate and arguably the clearest option. The pain appears when you have persistent interactive components — a form with focus, a list with per-item listeners, an expanded/collapsed state — that must survive an update.

Making it mostly work

To close the gap, here's a trick we've applied to our vanilla implementation to make it largely behave like a proper library, without importing one.

See the Pen [Color Browser [forked]](https://codepen.io/smashingmag/pen/vYPwBro) by FND.

See the Pen Color Browser [forked] by FND.
Smashing Editorial