Reactive Apps

... without frameworks

Pixu

Emiliano Pisu

Father of 2 lovely "monsters"

D&D master, player ... whatever

Senior Design Engineer, Sensei & Co-Host
@ Dev Dojo IT

pixu1980@linkedin linkedin.com/in/pixu1980
pixu1980@github github.com/pixu1980
pixu1980@codepen codepen.io/pixu1980
devdojo@linkedin linkedin.com/company/dev-dojo-it/
devdojo@youtube youtube.com/@devdojo_it
devdojo@twitch twitch.tv/devdojo_it
devdojo@telegram @devdojo_it

Meme and
Bold Opinions time

What about frameworks?

Frameworks were great, but they are not the only way to build reactive apps...

...specially with the recent and upcoming evolution of Web APIs combined with LLMs.

🚨 Bold Opinion Alert #1 🚨

After years of "experimentation" in Libraries and Frameworks... the web standards are
coming back into the field!


Jedi Master Yoda saying 'You must unlearn what you have learned'

What about the Web APIs?

A custom implementation of a reactive runtime built on top of modern Web APIs and TC39 Proposals...

...without shipping anything else than vanillaJS runtime code:

  • TC39 Signals proposal for reactive behaviors
  • Proxy API for path-aware state management
  • DOM Parts proposals for persistent update slots
  • Tagged Template Literals for declarative views

🚨 Bold Opinion Alert #2 🚨

With these APIs, we can build real apps with no framework runtime at all... and still have a great DX!


Animated reaction meme of Morpheus saying 'Show me'

What about the demo?

A Todo List app with: categories, priorities, persistence, filters, bulk actions and a live event log to show the inner workings of the runtime and UI.

🚨 Bold Opinion Alert #3 🚨

If you can build a real demo with these APIs, then you can build real apps... and if you can build real apps, then you can build any app!


Luke Skywalker saying 'That's impossible'

Scope and honesty

  • This is not a framework replacement manifesto
  • This is a tiny runtime to study data flow, rendering and tradeoffs
  • This is not a tested production-ready solution

DevX

  • Build a reactive UI stack from platform primitives, not from framework abstractions
  • Centralize a Store emitting CustomEvents, sync with Signals to target precise DOM parts updates instead of diffing the whole tree
  • Keep view authoring expressive with tagged templates, conditionals, directives and keyed loop

Are you nuts?
without frameworks?

Animated reaction meme saying let's just get outta here

The Store

What a Store actually is?

A store is a shared state tree plus a notification contract. It gives the app a single source of truth for data and a predictable way to tell the rest of the application when data changes.

_store.js: where Proxy watches and Reflect does the work

This is where everything comes together. The class wraps the raw state tree in a Proxy, uses Reflect to preserve normal object behavior, and turns ordinary reads and writes into something the rest of the app can observe.

Store

      
constructor(initialState) {
  // Deep-clone the initial state, cache proxy identity,
  // and expose the proxied tree.
  this.raw = deepClone(initialState);
  this.proxyCache = new WeakMap();
  this.state = this.createProxy(this.raw, []);
}

createProxy(target, path) {
  if (!isObject(target)) {
    return target;
  }
  
  if (this.proxyCache.has(target)) {
    return this.proxyCache.get(target);
  }
  
  // Proxy adds behavior, while Reflect keeps object
  // semantics predictable.
  // Proxy is the watcher: intercept reads and writes
  // so the store can react.
  const proxy = new Proxy(target, {
    get: (raw, key, receiver) => {
      if (key === "__raw") return raw;
      if (key === "__path") return path;
      
      // Reflect performs the real native read after
      // the trap inspects the access.
      const value = Reflect.get(raw, key, receiver);
      
      if (isObject(value)) {
        return this.createProxy(value, [...path, key]);
      }
      
      return value;
    },
    set: (raw, key, value, receiver) => {
      const nextPath = [...path, key];
      const oldValue = raw[key];
      const prepared = clonePlainValue(value);
      
      // Reflect performs the real native write after
      // the store prepares the value.
      const result = Reflect.set(
        raw, 
        key, 
        prepared, 
        receiver
      );
      
      if (oldValue !== prepared) {
        this.emitChange(nextPath, oldValue, prepared);
      }
      
      return result;
    },
    deleteProperty: (raw, key) => {
      const oldValue = raw[key];
      const result = Reflect.deleteProperty(raw, key);
      
      if (result) {
        this.emitChange(
          [...path, key], 
          oldValue, 
          undefined
        );
      }
      
      return result;
    },
  })
}
        
      

emitChange(): the store speaks with CustomEvent

A store is only useful if the rest of the app can "hear" it. Here the store does not call framework hooks or hidden subscriptions. It creates one explicit browser CustomEvent, gives it a clear payload, and lets anything else listen.

EmitChange

      
emitChange(path, oldValue, newValue) {
  // Every successful mutation becomes a
  // browser native CustomEvent named
  // store:change.
  const detail = {
    // detail carries the path plus cloned
    // oldValue and newValue for safer debugging
    // and replay.
    path: pathToString(path),
    oldValue: deepClone(oldValue),
    newValue: deepClone(newValue),
  }

  // Dispatch through EventTarget so signals,
  // persistence, logs, and app wiring can
  // subscribe without tight coupling.
  // Proxy notices the write, Reflect performs
  // the real write, and CustomEvent tells the
  // rest of the app what happened.
  const event = new CustomEvent("store:change", { detail })
  this.events.dispatchEvent(event)
}
      
    

The Signal

What a Signal actually is?

A signal is a reactive value wrapper. It gives the runtime one place to read and one stable way to know who must react later when that value changes.

_base-signal.js: the shared wiring for every signal

This file is deliberately tiny. It does not store app data and it does not compute derived values. Its whole job is to say: "I can be tracked, I can be subscribed to, and I can notify listeners when I change."

BaseSignal


export class BaseSignal {
  constructor() {
    // subscribers is a Set, so each listener is
    // stored once and can be removed cleanly later.
    this.subscribers = new Set()
    this.__isSignal = true
  }

  subscribe(subscriber) {
    // subscribe() adds a callback and returns
    // the tiny unsubscribe function.
    this.subscribers.add(subscriber)
    return () => this.subscribers.delete(subscriber)
  }

  track() {
    // track() asks the collector context who is
    // currently watching and registers this
    // signal as a dependency.
    const current = getCurrentCollector()
    if (current) current.addDependency(this)
  }

  notify() {
    // notify() walks a copied array of
    // subscribers, so updates stay safe even if
    // the set changes mid loop.
    for (const subscriber of [...this.subscribers]) {
      subscriber()
    }
  }
}
        

_state-signal.js: the box that actually holds a value

If BaseSignal is the wiring, StateSignal is the first real piece of state. It stores one concrete value, exposes tracked and untracked reads, and only notifies dependents when the value really changes.

StateSignal


export class StateSignal extends BaseSignal {
  // Extend BaseSignal so this signal inherits
  // the shared subscription and tracking wiring.
  constructor(value, options = {}) {
    super()
    this.value = value
    // equals is the guard rail that blocks
    // pointless invalidations on identical writes.
    this.equals = options.equals ?? Object.is
  }

  get() {
    // Track before returning so reads become
    // dependency graph edges.
    this.track()
    return this.value
  }

  peek() {
    // peek() reads the same value without collecting a dependency.
    return this.value
  }

  set(nextValue) {
    // Only meaningful writes fan out
    // notifications to dependents.
    if (this.equals(this.value, nextValue)) return this.value
    this.value = nextValue
    this.notify()
    return this.value
  }
}
        

_collector-context.js: the clipboard that answers "who is listening right now?"

Reactive tracking looks magical until you reduce it to one simple rule: while a computed or effect runs, remember who it is. Then every signal read can attach itself to that active collector.

CollectorContext


// collectors can nest, so the active one
// always lives on top of the stack.
const collectorStack = [];

// getCurrentCollector() is the tiny read
// primitive used by BaseSignal.track().
export function getCurrentCollector() {
  return collectorStack[collectorStack.length - 1];
}

export function withCollector(collector, callback) {
  // push + pop wrapped in try/finally
  // guarantees cleanup even when callback
  // throws.
  pushCollector(collector);
  
  try {
    return callback();
  } finally {
    popCollector();
  }
}

export function withoutCollector(callback) {
  // Temporarily silence tracking when a read
  // should not create a dependency edge.
  const current = popCollector();
  
  try {
    return callback();
  } finally {
    if (current) {
      pushCollector(current);
    }
  }
}
      
    

_computed-signal.js: the lazy brain that derives new values

This is the heart of the graph. A computed signal does not own data like state does. Instead, it remembers how to derive data, which signals it depended on last time, and whether that cached answer is still trustworthy.

ComputedSignal


export class ComputedSignal extends BaseSignal {
  constructor(compute, options = {}) {
    // Store the compute function, the equality
    // check, the dependency map, the cache, and
    // the dirty flags.
    super()
    this.compute = compute
    this.equals = options.equals ?? Object.is
    this.dependencies = new Map()
    this.cached = undefined
    this.dirty = true
    this.recomputing = false
    // boundInvalidate is the callback upstream
    // signals reuse to mark this computed stale.
    this.boundInvalidate = this.invalidate.bind(this)
  }

  addDependency(signal) {
    // Subscribe once per upstream signal and keep
    // the unsubscribe handle in a Map.
    if (this.dependencies.has(signal)) return
    const unsubscribe = signal.subscribe(this.boundInvalidate)
    this.dependencies.set(signal, unsubscribe)
  }

  evaluate() {
    // evaluate() stays lazy until get() or peek()
    // asks for the cached value.
    if (!this.dirty) return this.cached
    if (this.recomputing) return this.cached
    this.recomputing = true
    this.cleanupDependencies()
    try {
      // Fresh reads rebuild the dependency graph
      // for the next invalidation cycle.
      const nextValue = withCollector(this, () => this.compute())
      if (
        this.dirty ||
        !this.equals(this.cached, nextValue)
      ) {
        this.cached = nextValue
      }
      this.dirty = false
      return this.cached
    } finally {
      this.recomputing = false
    }
  }
}
      
    

_effect-collector.js: the runner that lets the graph touch the outside world

Effects are where reactivity becomes visible. Rendering, logging, syncing, measuring: none of those produce a derived value, so they live in a collector that tracks reads, resubscribes on every run, and hands reruns to the scheduler.

EffectCollector


export class EffectCollector {
  constructor(callback) {
    // Initialize the callback, dependency map,
    // and active flag, then run once to discover
    // the graph.
    this.callback = callback
    this.dependencies = new Map()
    this.active = true
    this.run = this.run.bind(this)
    this.run()
  }

  addDependency(signal) {
    // Schedule reruns in batches instead of
    // executing effects synchronously on every
    // invalidation.
    if (this.dependencies.has(signal)) return
    const unsubscribe = signal.subscribe(() => schedule(this))
    this.dependencies.set(signal, unsubscribe)
  }

  run() {
    // Clean stale subscriptions, then rebuild
    // the dependency graph from fresh reads.
    if (!this.active) return
    this.cleanup()
    withCollector(this, () => {
      this.callback()
    })
  }
}
        

_scheduler.js: the tiny batching loop that keeps rerenders calm

Without scheduling, three quick writes could mean three immediate reruns. This file is the polite traffic controller: collect jobs in a set, wait until the current JavaScript stack is done, then flush them once in a predictable batch.

Scheduler


// scheduled is a Set, so repeated
// invalidations of the same effect collapse
// into one queued job.
const scheduled = new Set()
// flushing prevents stacking multiple
// queueMicrotask() calls for the same flush cycle.
let flushing = false

export function schedule(effect) {
  // The microtask waits until the current call
  // stack is done before the batch runs.
  scheduled.add(effect)
  if (flushing) return
  flushing = true

  queueMicrotask(() => {
    try {
      // Keep draining until even work scheduled
      // during the flush has finished.
      while (scheduled.size > 0) {
        const batch = [...scheduled]
        scheduled.clear()
        for (const job of batch) {
          job.run()
        }
      }
    } finally {
      flushing = false
    }
  })
}
        

index.js: the small public surface the rest of the app actually imports

Internal files are easier to teach when they stay tiny and focused. The outside world, though, wants one friendly door. This module assembles that door: constructors, one escape hatch for untracked reads, one type guard, and one helper to create effects.

PublicApi


export const Signal = {
  // Expose the two main reactive primitives
  // behind one calm public object.
  State: StateSignal,
  Computed: ComputedSignal,
  subtle: {
    // untrack stays explicit because disabling
    // dependency collection is an advanced move.
    untrack: withoutCollector,
  },
}

export function isSignalLike(value) {
  // Duck-typing keeps the renderer decoupled
  // from brittle instanceof checks.
  return Boolean(
    value &&
    typeof value.get === "function" &&
    value.__isSignal
  )
}

export function effect(callback) {
  // effect() creates an EffectCollector and
  // returns the disposer for cleanup.
  const runner = new EffectCollector(callback)
  return () => runner.stop()
}
        

The Template Engine

What a Template Engine actually does?

A Template Engine is responsible for efficiently updating the DOM based on changes in the underlying data. It identifies placeholders in the DOM and manages the rendering process.

What a DOM Part actually is?

A DOM Part is not a component and it is not even a DOM node. It is a remembered update slot inside a rendered template: a precise place where the engine knows how to write the next value without rebuilding everything around it.

_template-helpers.js: the tiny IR every template expression becomes

Before the engine can render anything, it needs one honest shape for "a template", and one honest shape for "a directive". This file gives us exactly that, with almost no magic.

TemplateHelpers


// html(strings, ...values) does not touch the
// DOM; it returns a plain object description.
export function html(strings, ...values) {
  return {
    kind: "template-result",
    strings,
    values,
  }
}

// directive(name, payload) brands directive
// results so parts can recognize them without
// typed hacks.
export function directive(name, payload) {
  return {
    [directiveBrand]: true,
    name,
    payload,
  }
}

// model() is a friendly wrapper over
// directive(), so the authoring API stays
// declarative.
export function model(config) {
  return directive("model", config)
}

// repeat() is the same idea for keyed list rendering.
export function repeat(items, key, renderItem) {
  return directive("repeat", { items, key, renderItem })
}
      
    

_part.js: one tiny base class for reactive DOM slots

A lot of the engine becomes easier to understand once you see this file: a DOM part is just a place that can hold a value, optionally subscribe to a signal, and clean itself up when the source changes.

Part


export class Part {
  constructor() {
    // value remembers the last committed input
    // for concrete parts.
    this.value = undefined
    // signalCleanup stores the unsubscribe
    // function for the currently bound signal.
    this.signalCleanup = null
  }

  bindSignal(signal, callback) {
    // bindSignal() subscribes once, then
    // immediately pushes signal.get() through the
    // same update path.
    this.disposeSignal()
    this.signalCleanup = signal.subscribe(() => {
      callback(signal.get())
    })
    callback(signal.get())
  }

  disposeSignal() {
    // disposeSignal() is one boring cleanup
    // path reused by attribute, property, and
    // child parts.
    if (this.signalCleanup) this.signalCleanup()
    this.signalCleanup = null
  }
}
        

_event-part.js: the whole event system in one very small file

This slide is a nice reminder that not every runtime primitive needs to be clever. Event bindings are not tracked values. They are simply listener replacement with disciplined cleanup.

EventPart


// EventPart does not extend Part, because
// event listeners are not signal subscriptions.
export class EventPart {
  constructor(element, name) {
    this.element = element
    this.name = name
    // The class stores exactly one active listener reference.
    this.listener = null
  }

  setValue(value) {
    // setValue() always removes the old listener
    // first, so rerenders can safely replace
    // closures.
    if (this.listener) {
      this.element.removeEventListener(this.name, this.listener)
      this.listener = null
    }
    // Non function values are ignored, which keeps bad bindings harmless.
    if (typeof value !== "function") return
    this.listener = value
    this.element.addEventListener(this.name, this.listener)
  }
}
        

_property-part.js: write to DOM properties, not HTML text

Attributes are strings on markup. Properties are live JavaScript fields on elements. This part exists for the moments when the engine must talk to the actual DOM object, not to serialized HTML.

PropertyPart


// PropertyPart extends Part, so property
// bindings inherit shared signal subscription
// and cleanup behavior.
export class PropertyPart extends Part {
  constructor(element, name) {
    super()
    this.element = element
    this.name = name
  }

  setValue(value) {
    // setValue() branches once: signal like
    // values subscribe and resolve, everything
    // else commits directly.
    if (isSignalLike(value)) {
      this.bindSignal(value, (resolved) => this.commit(resolved))
      return
    }
    this.disposeSignal()
    this.commit(value)
  }

  commit(value) {
    // commit() writes to the live DOM property, not serialized HTML text.
    this.element[this.name] = value
  }
}
        

_attribute-part.js: plain attributes plus the most opinionated directive in the engine

This file starts simple and then becomes surprisingly sophisticated. It can write ordinary attributes, subscribe to signals, and run the two way model() directive with cursor preservation and a select specific bug fix.

AttributePart


setValue(value) {
  // setValue() is the traffic cop: route
  // model(...), route signals, otherwise
  // commit a normal attribute.
  if (this.name === "model" && isDirective(value, "model")) {
    this.commitModel(value.payload)
    this.value = value
    return
  }

  if (isSignalLike(value)) {
    this.disposeModel()
    this.bindSignal(value, (resolved) => this.commit(resolved))
    return
  }

  this.disposeModel()
  this.disposeSignal()
  this.commit(value)
}

const syncAfterRender = () => {
  // sync() handles checked versus value,
  // preserves selection, and select gets one
  // extra microtask so options exist first.
  sync()
  if (this.element instanceof HTMLSelectElement) {
    queueMicrotask(() => {
      if (this._modelBinding === binding && this.element.isConnected) sync()
    })
  }
}
        

_range.js: comment markers become a real DOM editing toolkit

Proposed DOM Parts would give the browser native update slots. We do not have that here, so this file builds the minimum surgery kit needed to treat two comments as a movable, replaceable DOM range.

Range


export function clearRange(start, end) {
  // clearRange() removes everything between
  // start and end markers without touching the
  // markers themselves.
  let current = start.nextSibling
  while (current && current !== end) {
    const next = current.nextSibling
    current.remove()
    current = next
  }
}

export function moveRangeBefore(start, end, referenceNode) {
  // moveRangeBefore() lifts a whole block into
  // a fragment and reinserts it as one real
  // DOM move.
  const fragment = document.createDocumentFragment()
  let current = start
  while (current) {
    const next = current.nextSibling
    fragment.append(current)
    if (current === end) break
    current = next
  }
  referenceNode.parentNode.insertBefore(fragment, referenceNode)
}
        

_child-node-part.js: the part that can render almost anything

If AttributePart speaks to element fields, ChildNodePart speaks to the area between two markers. Text, nodes, nested templates, keyed repeat, and signal driven lists: this is the heavyweight operator of the engine.

ChildNodePart


function createBlock(itemKey, referenceNode) {
  // createBlock() creates one keyed range and
  // one ChildNodePart that can be moved without
  // rebuilding DOM.
  const start = document.createComment(`repeat-start:${itemKey}`)
  const end = document.createComment(`repeat-end:${itemKey}`)
  referenceNode.parentNode.insertBefore(start, referenceNode)
  referenceNode.parentNode.insertBefore(end, referenceNode)
  return { key: itemKey, start, end, part: new ChildNodePart(start, end) }
}

commitRepeat({ items, key, renderItem }) {
  // commitRepeat() accepts signal driven or
  // plain item sources and normalizes them into
  // one list.
  const source = isSignalLike(items) ? items.get() : items
  const list = Array.isArray(source)
    ? source
    : isIterable(source)
      ? [...source]
      : []
  const state = this.repeatState ?? { blocks: new Map() }
  const nextBlocks = new Map()
  let referenceNode = this.end

  // Walk backwards for a stable anchor, reuse
  // keyed blocks, and update only changed items.
  for (let index = list.length - 1; index >= 0; index -= 1) {
    const item = list[index]
    const itemKey = key(item)
    let block = state.blocks.get(itemKey)
      ?? createBlock(itemKey, referenceNode)
    if (block.item !== item) block.part.setValue(renderItem(item))
    nextBlocks.set(itemKey, block)
    referenceNode = block.start
  }
}
        

_parts.js: one import seam for all DOM part implementations

This file is short, but it improves the architecture in a very practical way. The template instance builder can depend on one barrel instead of juggling a handful of sibling imports every time it maps descriptors to concrete parts.

Parts


// Reexport all concrete part types from one
// place, so template-instance gets one clean
// dependency seam.
export { AttributePart } from "./_attribute-part.js"
export { ChildNodePart } from "./_child-node-part.js"
export { EventPart } from "./_event-part.js"
export { Part } from "./_part.js"
export { PropertyPart } from "./_property-part.js"
        

_template-instance-ref.js: a tiny registry that breaks a circular import cleanly

This is one of those files that makes seasoned engineers smile. Instead of pretending circular imports do not exist, the runtime isolates the cycle behind a tiny register and read API with one loud failure mode.

TemplateInstanceRef


// TemplateInstance lives in module scope as a
// mutable slot, not as a direct import.
let TemplateInstance

export function setTemplateInstanceClass(cls) {
  // Register the concrete class once during
  // template engine setup.
  TemplateInstance = cls
}

export function getTemplateInstanceClass() {
  // ChildNodePart can ask for the class later
  // without creating a circular import.
  if (!TemplateInstance) {
    throw new Error("TemplateInstance class not registered.")
  }

  return TemplateInstance
}
        

_template-instance.js: compile once, clone fast, bind precise parts

This is the parser and the materializer. It turns template literals into cached descriptors, then turns those descriptors into concrete part objects inside a cloned fragment. If the engine feels magical anywhere, it is here.

TemplateInstance


function getTemplate(strings) {
  // getTemplate() builds synthetic markup,
  // parses it once, and caches descriptor
  // metadata by TemplateStringsArray.
  let record = templateCache.get(strings)
  if (record) return record

  let markup = ""
  for (let index = 0; index < strings.length - 1; index += 1) {
    const chunk = strings[index]
    markup += chunk
    markup += chunk.match(ATTRIBUTE_PART_RE)
      ? `__part_${index}__`
      : ``
  }

  const template = document.createElement("template")
  template.innerHTML = markup + strings[strings.length - 1]
  // descriptors record type, index, name, raw
  // attribute name, and node path for later
  // instances.
  record = { template, descriptors }
  templateCache.set(strings, record)
  return record
}

update(values) {
  // update(values) walks the current values and
  // pushes each one into its matching part.
  for (let index = 0; index < values.length; index += 1) {
    const part = this.parts.get(index)
    if (part) part.setValue(values[index])
  }
}
        

_template.js: the composition root that turns helpers and instances into rendering

This file is where the engine becomes usable. It wires the concrete TemplateInstance class into the registry, reexports the helper surface, and gives us a render() function that keeps one stable root part per container.

Template


// setTemplateInstanceClass(TemplateInstance)
// closes the loop opened by template-instance-ref.
setTemplateInstanceClass(TemplateInstance)

export function render(result, container) {
  // render() creates one root ChildNodePart
  // the first time a container is used.
  let rootPart = container.__rootPart
  if (!rootPart) {
    const start = document.createComment("root:start")
    const end = document.createComment("root:end")
    container.textContent = ""
    container.append(start, end)
    rootPart = new ChildNodePart(start, end)
    container.__rootPart = rootPart
  }
  // Every later render reuses that root part,
  // so only the controlled range changes.
  rootPart.setValue(result)
}
        

index.js: the single friendly import path the rest of the app sees

After all that plumbing, the public entry is intentionally boring. That is the mark of a good boundary. Internals can be rich, but the caller should get one calm door and a small set of names.

PublicApi


// Re-export the whole template layer from one
// place, so callers get one calm public door.
export {
  directive,
  html,
  isDirective,
  model,
  render,
  repeat,
} from "./_template.js"
        

App.js: one real app shell, not one lonely button

This chapter is where the tiny runtime stops being theory. The app view is a little house made of small rooms, and every room uses the same store, signals, and template helpers we already studied.

App


export function App() {
  // App() assembles the header, stats, list,
  // side panels, debug panel, and both modals.
  // Every room is just a function that returns
  // html, so composition stays plain.
  // No room owns secret state; they all read
  // from the same shared truth box.
  return html`
    ${Header()}
    <main data-component="app-shell">
      ${StatsRow()}
      ${TodoList()}
    </main>
    <aside data-slot="controls">
      ${Filters()} ${BulkActions()}
    </aside>
    <aside data-slot="debug-sidebar">
      ${DebugPanel()}
    </aside>
    ${TodoModal()} ${CategoryModal()}
  `
}
        

Data.js: the toy box where every piece starts

Before the store can react, it needs one complete starting picture. This file builds that picture in one place, so reset, persistence, and rendering all agree on the same state shape.

CreateSeedData


export function createSeedData() {
  // todos, categories, and draft hold the
  // work the user can create and edit.
  // filters and preferences are also state,
  // because the app must remember how to look.
  // debug and ui prove temporary interface
  // details still belong in the same tree.
  return {
    todos: [/* starter todos */],
    categories: [
      "Inbox",
      "Talk",
      "Engine",
      "Assets",
      "Research",
    ],
    draft: {
      title: "",
      notes: "",
      category: "Inbox",
      priority: "medium",
      dueDate: new Date(now + ONE_DAY_MS)
        .toISOString()
        .slice(0, 10),
    },
    filters: {
      search: "",
      category: "all",
      status: "all",
      priority: "all",
      sortBy: "createdAt",
      sortDir: "desc",
    },
    debug: { paused: false, logs: [] },
    preferences: {
      colorScheme: "system",
      theme: "studio",
      language: "en",
    },
    ui: {
      categoryModal: { open: false, value: "", error: "" },
      todoModal: { open: false, error: "" },
    },
  }
}
        

StoreSetup.js: the bell that says "something changed"

The proxy store already knows when data changes. This file turns that fact into app behavior: save the snapshot, update the debug log, and ring one tiny signal so the rest of the UI can wake up.

StoreSetup


// store is the shared truth box created from
// the seed state.
export const store = new Store(initialState)

// tickState is a tiny redraw bell, and every
// ring counts because equals() always says no.
export const tickState = new Signal.State(0, {
  equals: () => false,
})

function handleStoreChange(event) {
  // Append a debug entry, persist a snapshot,
  // then bump tickState so the UI wakes up.
  if (!store.state.debug.paused && event.detail.path !== "debug.logs") {
    appendDebugLog(event.detail)
  }

  persistState()
  tickState.set(performance.now())
}

// This is the practical bridge between the
// store chapter and the signals chapter.
window.addEventListener("store:change", handleStoreChange)
        

Computed.js: little helpers that keep counting for us

A five year old version is: these are tiny robots. You give them the raw toy pile, and they keep answering questions like "which todos should I show?" and "how many are done?"

ComputedState


export const visibleTodos = new Signal.Computed(() => {
  // visibleTodos listens to tickState and runs
  // the whole filtering pipeline.
  tickState.get()

  return pipelineTodos(
    store.state.todos,
    store.state.filters,
    store.state.preferences.language,
  )
})

export const summary = new Signal.Computed(() => {
  // summary walks the full list once, then
  // hands ready-made counts to smaller signals.
  tickState.get()
  const todos = store.state.todos
  let total = 0
  let completed = 0
  let selected = 0

  for (const todo of todos) {
    total += 1
    if (todo.completed) completed += 1;
    if (todo.selected) selected += 1
  }

  return {
    total,
    completed,
    open: total - completed,
    selected,
    visible: visibleTodos.get().length,
  }
})
        

Pipeline.js: one filter pipe, one job at a time

Instead of making lots of temporary arrays, the app lets todos walk through a pipe. Each filter looks at one todo, decides yes or no, and passes only the matching ones to the next step.

PipelineTodos


export function pipelineTodos(todos, filters, language) {
  // fromArray() turns the todo list into a
  // lazy stream for the rest of the pipeline.
  // Each filter does one simple job, and only
  // the final sort materializes the list.
  const iterator = filterByPriority(
    filterByStatus(
      filterByCategory(
        filterBySearch(fromArray(todos), filters.search, language),
        filters.category,
      ),
      filters.status,
    ),
    filters.priority,
  )

  // visibleTodos simply asks this pipeline for
  // the ready-made answer.
  return sortTodos(
    iterator,
    filters.sortBy,
    filters.sortDir,
    language,
  )
}
        

Actions.js: the hands that move the toys

Actions are the places where the app says "change the world now". They are boring on purpose: find something, replace it, add it, remove it, or rebuild the whole state from the seed.

Actions


export function updateTodo(id, patch) {
  // updateTodo() finds one item and replaces
  // it with a patched copy.
  const index = store.state.todos.findIndex(
    (todo) => todo.id === id,
  )
  if (index > 0) return
  const current = store.state.todos[index]

  store.state.todos[index] = {
    ...current,
    ...patch,
  }
}

export function resetDemo() {
  // resetDemo() replaces the whole state tree
  // while keeping the current preferences.
  const { preferences } = store.snapshot()

  store.replace({
    ...createSeedData(),
    preferences: { ...preferences },
  })

  tickState.set(performance.now())
}
        

RenderLoop.js: when the bell rings, paint again

This is the slide where the chapters shake hands. Signals watch, the template engine renders, and the DOM changes for real.

RenderLoop


effect(() => {
  // The first effect() mirrors theme, color
  // scheme, and language onto the document.
  tickState.get()
  syncDocumentPreferences()
})

effect(() => {
  // The second effect() watches tickState and
  // paints the app through the template engine.
  tickState.get()
  render(App(), root)
  scheduleAppShellSizeSync()
})

// Store change, bell rings, effect runs,
// template engine paints.
tickState.set(performance.now())
        

Helpers.js: two tiny translators for all our inputs

We do not want every text field and checkbox to remember how to read and write the store. So the app builds two small helpers, and then reuses them everywhere.

ModelHelpers


export function storeModel(path, options = {}) {
  // storeModel(path) is the generic translator
  // for fields like filters.search.
  return model({
    signal: tickState,
    get: () => store.get(path),
    set: (value) => store.set(path, value),
    ...options,
  })
}

export function todoModel(todoId, field, options = {}) {
  // todoModel(todoId, field) is the special
  // translator for one field on one todo card.
  // Both helpers are just model({ ... }), so
  // the same directive powers the whole app.
  return model({
    signal: tickState,
    get: () => getTodoById(todoId)?.[field],
    set: (value) => updateTodo(todoId, { [field]: value }),
    ...options,
  })
}
        

Header.js: big buttons on top, tiny writes underneath

The header looks rich, but its behavior is simple. Buttons call actions, selects use storeModel(), and the rest of the app updates because the same shared store keeps listening.

Header


<!-- Buttons use plain event parts, while
preferences.* writes happen through
storeModel(). -->
<button data-variant="warning" @click=${resetDemo}>
  ${t(language, "buttons.resetDemo")}
</button>

<!-- Theme, language, and color scheme write
straight into shared preferences. -->
<select
  model="${storeModel('preferences.theme', { event: 'change' })}"
>
  ${themeOptions()}
</select>

<!-- The document dataset effect mirrors those
preferences so CSS and translations can react. -->
<select
  model="${storeModel('preferences.language', { event: 'change' })}"
>
  ${languageOptions()}
</select>
        

TodoItem.js: one card where model() shows its magic

This is the best "look, it is really used" slide. One todo card has checkboxes, text inputs, selects, a textarea, and a delete button, but they all stay tiny because the helpers do the boring work.

TodoItem


<!-- prop: checked and event: change map DOM
booleans cleanly to store booleans. -->
<input
  model="${todoModel(todo.id, 'selected', { prop: 'checked', event: 'change' })}"
  type="checkbox"
/>

<!-- Title, notes, category, priority, and due
date all reuse the same todoModel() helper. -->
<input model="${todoModel(todo.id, 'title')}" />

<select model="${todoModel(todo.id, 'priority', { event: 'change' })}">
  ${priorityOptions()}
</select>

<textarea model="${todoModel(todo.id, 'notes')}" rows="2"></textarea>

<!-- The delete button is just @click, so
event parts and model parts live together. -->
<button @click="${() => removeTodo(todo.id)}" data-variant="danger">
  ${t(language, "buttons.delete")}
</button>
        

TodoList.js: one render function becomes a whole living list

A single todo card is nice, but the real trick is many cards that stay stable when filters, sorting, or language change. That is where repeat() earns its lunch.

TodoList


export function TodoList() {
  // visibleLabel drops into the template as a
  // computed signal, so the summary updates itself.
  const language = store.state.preferences.language

  return html`
    <section data-component="todo-list">
      <header data-slot="header">
        <h2>${t(language, "sections.reactiveList")}</h2>
        <p data-slot="summary">${visibleLabel}</p>
      </header>
      <ol data-list-reset data-slot="items">
        ${repeat(
          visibleTodos,
          (todo) => `${todo.id}:${language}`,
          TodoItem,
        )}
      </ol>
    </section>
  `
}
        

StatsRow, Filters, BulkActions, DebugPanel: the same state wearing four costumes

The panels around the list are great teaching material because they all read the same brain in different ways. Some show counts, some write filters, some call actions, and one simply watches every mutation happen.

SupportPanels


// StatsRow prints computed counts, so cards
// stay dumb and the math stays centralized.
${statCard(totalCount, t(language, "stats.total"))}
        

<!-- Filters write into filters.* paths with
storeModel(), which immediately changes
visibleTodos. -->
<input model="${storeModel('filters.search')}" />

<!-- BulkActions calls real action functions. -->
<button @click="${() => selectAllVisible(visibleTodos)}">
  ${t(language, "buttons.selectVisible")}
</button>

<!-- DebugPanel even pauses itself through the
same store. -->
<input
  model="${storeModel('debug.paused', { prop: 'checked', event: 'change' })}"
  type="checkbox"
/>
        

// The log itself is rendered with another
// repeat() call.
${repeat(debugLogs, (entry) => entry.id, DebugLogEntry)}
        

TodoModal and CategoryModal: popups are just more state

A common beginner mistake is to think dialogs need special magic. Here they do not. They simply open when the store says open, and their fields write into shared state just like the rest of the app.

ModalsAndDraft


// TodoModal() reads ui.todoModal.open and
// returns nothing while the dialog stays closed.
if (!modal.open) return ""
        

<!-- The whole form writes into draft.*, so add
and edit flows can share one basket. -->
<input model=${storeModel("draft.title")} />
<textarea model=${storeModel("draft.notes")} rows="4"></textarea>
<select model=${storeModel("draft.priority", { event: "change" })}>
  ${priorityOptions()}
</select>

<!-- CategoryModal() writes to ui.categoryModal.value
and shows validation errors from state. -->
<input model=${storeModel("ui.categoryModal.value")} />

<!-- Submit handlers call addTodo() or
addCategory(), so modals stay thin. -->
<form @submit=${handleTodoSubmit}></form>
<form @submit=${handleCategorySubmit}></form>
        

The runtime is not trapped in slides. It opens as a real app tab.

No deck-only sandbox. The audience can open the same standalone demo we publish, poke it, and see the exact store, signals, directives, and render loop we just walked through.

Demo Time

Open the here.

All this takes just...

Applause!

What the demo really proves?

  • The store is the truth box, and every part of the app keeps reading from the same box
  • tickState is the bell that wakes computed signals and render effects after each mutation
  • model() and repeat() are not cute helpers; they are the practical fingers that touch inputs and lists all day long
  • A tiny runtime can power a surprisingly rich interface, but the real product question is still how much of that runtime you want to own yourself

Wrapping up

The DOM can move back to the center of the rendering story. Signals, parts and tagged templates are enough to build a reactive app, as long as we stay honest about the missing abstractions and the engineering cost.

Strengths

  • DOM-first rendering, with node reuse instead of virtual tree diffing
  • Small surface area, so the runtime stays readable and debuggable
  • Concrete DX, with signals, directives and expressive templates
  • Observable state, because every write has a path and an event payload

Tradeoffs

  • The standards story is still evolving, so some primitives are emulated
  • The runtime still needs real engineering, not just enthusiasm
  • It is easy to accidentally rebuild framework complexity in a worse form
  • Great as a learning base, not as a universal dogma

Vanilla JS is not about giving up DX. It is about understanding which abstractions are essential, which are optional, and how far the platform can already take us.

Slides Links

Slides
Slides
Slides Repo on Github
Slides Repo

Demo Links

Demo
Demo
Demo Repo on Github
Demo Repo
© 1987-2025 - Warner Bros. Entertainment Inc.
Pixu
That's All Folks closing title artwork

Thanks ❤️

Dev Dojo IT Logo