Turning Off the Framework: Patterns from the Platform

Frameworks bring more than code to a project—they impose a shared vocabulary and structure that helps teams collaborate. But their core features—declarative UI, data-binding, reactivity, and lists—can often be expressed directly with HTML, CSS, and the Forms API. A handful of platform-native patterns can cover many of the same use cases without a build step.

Stable DOM, Reactive CSS

The web's own declarative mechanism is HTML and CSS. Unlike framework-managed virtual DOMs, keeping elements in place and using CSS to toggle their state avoids the overhead of repeated append and remove operations. A hidden error label, for example, can react to a state change simply by having its class altered—the browser's native rendering engine handles propagation and decides whether to paint it.

This "stable DOM" approach has engineering benefits beyond zero-cost use: it means zero bundle additions, no build tooling, and selector references you can hold in JavaScript without worrying they'll go stale. Animations attach directly to these elements without transition-group helpers. And when debugging, the style panel shows the entire cascade—the complete chain of rules that put the element into its current state.

Even if you stay with a framework elsewhere, freezing your critical UI nodes in place while letting CSS react to state changes is worth applying anywhere you'd otherwise tear down and rebuild markup.

Forms as the Data-Binding Layer

Long before SPAs, forms were the platform's interface for user input. Because of that history, they come with practical resources for problems that look nothing like traditional form submission:

Form elements are accessible by name via document.forms and form.elements, and any control can reference its owning form through its form property. That includes not just input, but also output, textarea, and fieldset, so a nested tree of controls is always reachable without walking document or keeping manual references.

Once the DOM is stable, you can update text through direct assignment without framework-rendering loops. Verbose? Slightly. Stable, direct, and fast? Very much so.

More useful is the fact that a filled-in form maps cleanly to a JavaScript object. Instead of assembling a payload piece by piece in code, you can do this:

const data = Object.fromEntries(new FormData(formElement));

Hidden inputs behave identically to visible controls, so your data model can live inside the markup. Combined with CSS reactivity, that enables UI logic that reads directly from form state without juggling classes manually.

Using forms in this way comes with several dividends:

  • Native validation—pattern checks, required and optional markings, plus CSS hooks for valid and invalid states—works no matter how you visually compose your controls.
  • The submit event reliably catches Enter-key presses even when no visible submit button exists, and multiple submit buttons are distinguished by the submitter attribute.
  • Controls associate with the form in which they're nested, or explicitly with a separate form via the form attribute—meaning your interactive layout isn't tied to its DOM position.
  • Accessibility comes along for free: keyboard navigation and screen-reader behavior follow from proper use of form primitives, so specialized ARIA work is needed in far fewer places.

The result is a stable interactive skeleton—form followed by fieldsets, then elements—that both user interactions and automated UI tests can address by name and hierarchy rather than by fragile CSS selectors.

ChaCha: A Channel for Change

Observable lists are a common source of framework lock-in. The standard remedy—pulling in a library like MobX—brings the cost of general-purpose abstraction: performance overhead and indirect debugging. A narrower approach exists in the form of a bidirectional stream, called here the ChaCha, or Changes Channel, with a clearly defined pair of directions:

  • The intent direction carries user intentions from the UI to the model.
  • The observe direction carries model updates from the model back to the UI for display.

That pattern is not new architecture; it simply applies a standard port-based messaging model to state synchronization. Its value is that its interface can be written from the application's spec alone, before any UI code exists. In a typical contact manager, its shape would be natural:

source.addContact({contact}); source.removeContact({contactId});

All methods return void and send plain objects, so the same ChaCha contract works over an EventSource, an HTML MessageChannel, a service worker, or any other transport. Testing is likewise direct: send actions in and assert on the observer's calls in return.

Declaring Lists with the Template Element

For list rendering, the hidden helper is the template element—markup that exists in the document but never renders on its own, ready to be cloned into the flow.

Instead of relying on JSX or generators to construct list items, you write a single example item as a template and clone it for each entry. That keeps all of the app's HTML in one place, visible in the file, and removes code that constructs elements from JavaScript logic.

const tpl = document.querySelector('[data-tpl="name"]');
const clone = tpl.content.cloneNode(true);
clone.querySelector('.name').textContent = name;
list.appendChild(clone);

Because templates require no framework-specific language, your final document contains every part of the UI—the static elements render normally, while the dynamic blocks sit parked in template tags until they're needed. That, in turn, reduces the need for framework-induced build steps to interpret markup that the platform could just display.

Building TodoMVC Without a Framework: The Implementation

TodoMVC provides a standard TODO list specification that has been used to showcase frameworks. Its template comes with ready-made HTML and CSS so you can focus on the framework code itself. In this second part, we walk through a complete implementation using the ChaCha pattern, CSS-driven reactivity, and form-oriented HTML. A live demo is available, and the full source is in the GitHub repository.

Modeling the Specification as ChaCha

We start with the official specification and use it to define the ChaCha interface. The model functions are derived directly from the spec and what a user can do: clear completed items, mark all as active or complete, and get the active and completed counts.

interface Task {
   title: string;
   completed: boolean;
}

interface TaskModelObserver {
   onAdd(key: number, value: Task);
   onUpdate(key: number, value: Task);
   onRemove(key: number);
   onCountChange(count: {active: number, completed: number});
}

interface TaskModel {
   constructor(observer: TaskModelObserver);
   createTask(task: Task): void;
   updateTask(key: number, task: Task): void;
   deleteTask(key: number): void;
   clearCompleted(): void;
   markAll(completed: boolean): void;
}

This definition follows ChaCha guidelines: two interfaces — one for actions, one for observations — all parameters are primitives or plain objects (JSON-serializable), and all functions return void.

The model implementation uses localStorage as the back end. It saves to localStorage when needed and fires change callbacks to observers when something changes — either from a user action or when the model is first loaded.

Form-Oriented HTML

The HTML is then modified from the TodoMVC template to be form-oriented: a hierarchy of forms, with input and output elements representing data that can change with JavaScript. As a rule of thumb, if something binds to data from the model, it should be a form element. The full HTML file is available; here is its core part:

<section class="todoapp">
   <header class="header">
       <h1>todos</h1>
       <form name="newTask">
           <input name="title" type="text" placeholder="What needs to be done?" autofocus>
       </form>
   </header>

   <main>
       <form id="main"></form>
       <input type="hidden" name="filter" form="main" />
       <input type="hidden" name="completedCount" form="main" />
       <input type="hidden" name="totalCount" form="main" />
       <input name="toggleAll" type="checkbox" form="main" />

       <ul class="todo-list">
           <template>
               <form class="task">
                   <li>
                       <input name="completed" type="checkbox" checked>
                       <input name="title" readonly />
                       <input type="submit" hidden name="save" />
                       <button name="destroy">X</button>
                   </li>
               </form>
           </template>
       </ul>
   </main>

   <footer>
       <output form="main" name="activeCount">0</output>
       <nav>
           <a name="/" href="#/">All</a>
           <a name="/active" href="#/active">Active</a>
           <a name="/completed" href="#/completed">Completed</a>
       </nav>
       <input form="main" type="button" name="clearCompleted" value="Clear completed" />
   </footer>
</section>

Notable structure decisions:

  • The main form holds all global inputs and buttons, with a separate new form to create a task. Elements are associated using the form attribute to avoid nesting them inside the form.
  • The template element defines a list item; its root is another form representing that task's interactive data. This form is cloned and repeated when tasks are added.
  • Hidden inputs represent non-visual state used for styling and selection.

The DOM is concise — no classes sprinkled across elements — yet includes everything the app needs in a sensible hierarchy. The hidden inputs make it easy to anticipate what may change in the document. The HTML does not know how it will be styled or what data it is bound to; CSS and JavaScript work for the HTML rather than having the HTML serve a particular styling mechanism. This approach makes redesigns easier over time.

The 40-Line Controller

With most reactivity in CSS and list handling in the model, the remaining controller code is the glue that binds everything. In this small app, the controller JavaScript is about 40 lines. Here is the version with explanations:

import TaskListModel from './model.js';

const model = new TaskListModel(new class {

The code creates a new model.

onAdd(key, value) {
   const newItem = document.querySelector('.todo-list template').content.cloneNode(true).firstElementChild;
   newItem.name = `task-${key}`;
   const save = () => model.updateTask(key,  Object.fromEntries(new FormData(newItem)));
   newItem.elements.completed.addEventListener('change', save);
   newItem.addEventListener('submit', save);
   newItem.elements.title.addEventListener('dblclick', ({target}) => target.removeAttribute('readonly'));
   newItem.elements.title.addEventListener('blur', ({target}) => target.setAttribute('readonly', ''));
   newItem.elements.destroy.addEventListener('click', () => model.deleteTask(key));
   this.onUpdate(key, value, newItem);
   document.querySelector('.todo-list').appendChild(newItem);
}

When an item is added to the model, a corresponding list item appears in the UI. This clones the template contents, assigns per-item event listeners, and appends the new item to the list.

This function, along with onUpdate, onRemove, and onCountChange, are callbacks invoked by the model.

onUpdate(key, {title, completed}, form = document.forms[`task-${key}`]) {
   form.elements.completed.checked = !!completed;
   form.elements.title.value = title;
   form.elements.title.blur();
}

When an item is updated, its completed and title values are set, and the editor is blurred to exit editing mode.

onRemove(key) { document.forms[`task-${key}`].remove(); }

When a task is removed from the model, its list item is removed from the view.

onCountChange({active, completed}) {
   document.forms.main.elements.completedCount.value = completed;
   document.forms.main.elements.toggleAll.checked = active === 0;
   document.forms.main.elements.totalCount.value = active + completed;
   document.forms.main.elements.activeCount.innerHTML = `<strong>${active}</strong> item${active === 1 ? '' : 's'} left`;
}

When active or completed counts change, the appropriate inputs are set to trigger CSS reactions, and the count display is formatted.

const updateFilter = () => filter.value = location.hash.substr(2);
window.addEventListener('hashchange', updateFilter);
window.addEventListener('load', updateFilter);

The filter is updated from the URL hash fragment at startup and on changes. Setting a form element's value is all that is required — CSS handles the rest.

document.querySelector('.todoapp').addEventListener('submit', e => e.preventDefault(), {capture: true});

This handler prevents page reloads on form submissions, turning the app into a single-page application.

document.forms.newTask.addEventListener('submit', ({target: {elements: {title}}}) =>   
    model.createTask({title: title.value}));
document.forms.main.elements.toggleAll.addEventListener('change', ({target: {checked}})=>
    model.markAll(checked));
document.forms.main.elements.clearCompleted.addEventListener('click', () =>
    model.clearCompleted());

Main actions — creating, marking all complete, clearing completed — are handled here.

Reactivity via CSS

The full CSS file demonstrably handles many specification requirements, with accessibility amendments. The "X" (destroy) button, for example, only appears on hover, but also becomes visible on keyboard focus:

.task:not(:hover, :focus-within) button[name="destroy"] { opacity: 0 }

The active filter link gets a red border via a partial attribute selector on its href, with no JavaScript checking the current filter and toggling a selected class:

.todoapp input[name="filter"][value=""] ~ footer a[href$="#/"],
nav a:target {
   border-color: #CE4646;
}

The :target selector removes the need to explicitly manage filter additions.

The title input changes between view and edit styles based on its read-only state:

.task input[name="title"]:read-only {
…
}

.task input[name="title"]:not(:read-only) {
…
}

Filtering — showing only active or completed items — happens through selectors:

input[name="filter"][value="active"] ~ * .task
      :is(input[name="completed"]:checked, input[name="completed"]:checked ~ *),
input[name="filter"][value="completed"] ~ * .task
     :is(input[name="completed"]:not(:checked), input[name="completed"]:not(:checked) ~ *) {
   display: none;
}

The selector is verbose and might be easier to maintain with a preprocessor like Sass, but its behavior is straightforward: if the filter is active and the completed checkbox is checked—or vice versa—the checkbox and its siblings are hidden. Implementing this simple filter in CSS shows how far CSS can go; if it becomes unwieldy, moving the filtering logic into the model would be a sensible alternative.

Final Takeaways

Frameworks deliver convenient solutions to complex problems and align teams around consistent patterns. They bring benefits beyond the technical, such as a shared style and structure, and the elegance of declarative programming and components has real value — componentization itself was not covered in this article.

Yet alternative patterns exist, often at lower cost and without demanding less developer experience. Curiosity about these alternatives is worthwhile, even when parts of them are adopted while working within a framework.

Pattern Recap

  • Keep the DOM tree stable — this starts a chain reaction that simplifies everything else.
  • Lean on CSS for reactivity rather than JavaScript, where possible.
  • Use form elements as the primary representation for interactive data.
  • Clone the HTML template element instead of generating markup in JavaScript.
  • Model state as a bidirectional stream of changes.

Technical reviews: Yehonatan Daniv, Tom Bigelajzen, Benjamin Greenbaum, Nick Ribal, Louis Lazaris.