Building a Reactive UI with the JS Observer

Learn to build a reactive UI using the JavaScript observer pattern. Covers reactive state containers, auto-updating DOM bindings, computed properties, two-way data binding, component-level reactivity, and reactive lists with efficient DOM diffing.

JavaScriptadvanced
17 min read

Reactive UIs update automatically when underlying data changes. By combining the observer pattern with DOM manipulation, you can build lightweight reactive systems without a framework. This guide builds progressively from simple reactivity to full component-level binding.

For the observer pattern foundations, see JavaScript Observer Pattern: Complete Guide.

Reactive State Container

javascriptjavascript
function createReactiveState(initialState = {}) {
  const listeners = new Map();
  let state = { ...initialState };
  let batchQueue = null;
 
  function notify(key, newValue, oldValue) {
    if (batchQueue) {
      batchQueue.set(key, { newValue, oldValue });
      return;
    }
 
    const keyListeners = listeners.get(key) || [];
    keyListeners.forEach((fn) => fn(newValue, oldValue, key));
 
    const wildcardListeners = listeners.get("*") || [];
    wildcardListeners.forEach((fn) => fn(state, key));
  }
 
  return {
    get(key) {
      return key ? state[key] : { ...state };
    },
 
    set(key, value) {
      const oldValue = state[key];
      if (Object.is(oldValue, value)) return;
      state = { ...state, [key]: value };
      notify(key, value, oldValue);
    },
 
    update(updates) {
      batchQueue = new Map();
 
      for (const [key, value] of Object.entries(updates)) {
        const oldValue = state[key];
        if (!Object.is(oldValue, value)) {
          state = { ...state, [key]: value };
          batchQueue.set(key, { newValue: value, oldValue });
        }
      }
 
      const queued = new Map(batchQueue);
      batchQueue = null;
 
      for (const [key, { newValue, oldValue }] of queued) {
        notify(key, newValue, oldValue);
      }
    },
 
    subscribe(key, handler) {
      if (!listeners.has(key)) listeners.set(key, []);
      listeners.get(key).push(handler);
      return () => {
        const arr = listeners.get(key);
        const idx = arr.indexOf(handler);
        if (idx > -1) arr.splice(idx, 1);
      };
    },
  };
}
 
// Usage
const store = createReactiveState({ count: 0, name: "World" });
store.subscribe("count", (newVal, oldVal) => {
  console.log(`Count changed: ${oldVal} -> ${newVal}`);
});
store.set("count", 1); // Logs: Count changed: 0 -> 1

Auto-Updating DOM Bindings

javascriptjavascript
function bindElement(store, key, element, formatter = (v) => v) {
  function updateDOM(value) {
    const formatted = formatter(value);
    if (element.tagName === "INPUT" || element.tagName === "TEXTAREA") {
      element.value = formatted;
    } else {
      element.textContent = formatted;
    }
  }
 
  // Initial render
  updateDOM(store.get(key));
 
  // Subscribe to changes
  return store.subscribe(key, (newValue) => updateDOM(newValue));
}
 
function bindAttribute(store, key, element, attribute, transform = (v) => v) {
  function updateAttr(value) {
    const result = transform(value);
    if (typeof result === "boolean") {
      result ? element.setAttribute(attribute, "") : element.removeAttribute(attribute);
    } else {
      element.setAttribute(attribute, result);
    }
  }
 
  updateAttr(store.get(key));
  return store.subscribe(key, (newValue) => updateAttr(newValue));
}
 
function bindClass(store, key, element, classMap) {
  function updateClasses(value) {
    for (const [className, condition] of Object.entries(classMap)) {
      element.classList.toggle(className, condition(value));
    }
  }
 
  updateClasses(store.get(key));
  return store.subscribe(key, (newValue) => updateClasses(newValue));
}
 
// Usage
const state = createReactiveState({ count: 0, status: "idle" });
 
const heading = document.querySelector("#counter");
const statusBadge = document.querySelector("#status");
const submitBtn = document.querySelector("#submit");
 
const cleanups = [
  bindElement(state, "count", heading, (v) => `Count: ${v}`),
  bindElement(state, "status", statusBadge),
  bindAttribute(state, "status", submitBtn, "disabled", (v) => v === "loading"),
  bindClass(state, "status", statusBadge, {
    "badge-success": (v) => v === "success",
    "badge-error": (v) => v === "error",
    "badge-loading": (v) => v === "loading",
  }),
];
 
// Update state and DOM updates automatically
state.set("count", 5);
state.set("status", "loading");
 
// Cleanup all bindings
cleanups.forEach((unsub) => unsub());

Computed Properties

javascriptjavascript
function createComputed(store, name, dependencies, computeFn) {
  function recompute() {
    const values = dependencies.map((dep) => store.get(dep));
    const result = computeFn(...values);
    store.set(name, result);
  }
 
  // Initial computation
  recompute();
 
  // Recompute when any dependency changes
  const unsubscribers = dependencies.map((dep) =>
    store.subscribe(dep, () => recompute())
  );
 
  return () => unsubscribers.forEach((unsub) => unsub());
}
 
// Usage
const appState = createReactiveState({
  items: [],
  taxRate: 0.08,
  searchQuery: "",
});
 
// Computed: filtered items
createComputed(
  appState,
  "filteredItems",
  ["items", "searchQuery"],
  (items, query) => {
    if (!query) return items;
    const lower = query.toLowerCase();
    return items.filter((item) =>
      item.name.toLowerCase().includes(lower)
    );
  }
);
 
// Computed: cart total with tax
createComputed(
  appState,
  "total",
  ["items", "taxRate"],
  (items, taxRate) => {
    const subtotal = items.reduce((sum, item) => sum + item.price * item.qty, 0);
    return {
      subtotal: subtotal.toFixed(2),
      tax: (subtotal * taxRate).toFixed(2),
      total: (subtotal * (1 + taxRate)).toFixed(2),
    };
  }
);
 
// UI binding
appState.subscribe("total", (totals) => {
  document.querySelector("#subtotal").textContent = `$${totals.subtotal}`;
  document.querySelector("#tax").textContent = `$${totals.tax}`;
  document.querySelector("#total").textContent = `$${totals.total}`;
});
 
// Adding an item triggers computed recalculation and DOM update
appState.set("items", [
  { name: "Widget", price: 29.99, qty: 2 },
  { name: "Gadget", price: 49.99, qty: 1 },
]);

Two-Way Data Binding

javascriptjavascript
function twoWayBind(store, key, input, options = {}) {
  const { debounceMs = 0, transform = (v) => v, parse = (v) => v } = options;
  let debounceTimer = null;
 
  // Model to view
  function updateInput(value) {
    const formatted = transform(value);
    if (document.activeElement !== input) {
      input.value = formatted;
    }
  }
 
  // View to model
  function handleInput(event) {
    const rawValue = event.target.value;
    const parsed = parse(rawValue);
 
    if (debounceMs > 0) {
      clearTimeout(debounceTimer);
      debounceTimer = setTimeout(() => store.set(key, parsed), debounceMs);
    } else {
      store.set(key, parsed);
    }
  }
 
  updateInput(store.get(key));
  const unsub = store.subscribe(key, updateInput);
  input.addEventListener("input", handleInput);
 
  return () => {
    unsub();
    input.removeEventListener("input", handleInput);
    clearTimeout(debounceTimer);
  };
}
 
// Usage
const formState = createReactiveState({
  username: "",
  email: "",
  age: 0,
});
 
const usernameInput = document.querySelector("#username");
const emailInput = document.querySelector("#email");
const ageInput = document.querySelector("#age");
 
const cleanups2 = [
  twoWayBind(formState, "username", usernameInput, {
    transform: (v) => v,
    parse: (v) => v.trim().toLowerCase(),
  }),
  twoWayBind(formState, "email", emailInput, {
    debounceMs: 300,
  }),
  twoWayBind(formState, "age", ageInput, {
    parse: (v) => parseInt(v) || 0,
    transform: (v) => String(v),
  }),
];
 
// Subscribe to all form changes
formState.subscribe("*", (state) => {
  console.log("Form state:", state);
});

Reactive List Rendering

javascriptjavascript
function createReactiveList(store, key, container, renderItem) {
  const itemElements = new Map();
 
  function getItemKey(item, index) {
    return item.id || item.key || `__index_${index}`;
  }
 
  function render(items) {
    const currentKeys = new Set();
 
    items.forEach((item, index) => {
      const key = getItemKey(item, index);
      currentKeys.add(key);
 
      if (itemElements.has(key)) {
        // Update existing element
        const existing = itemElements.get(key);
        const updated = renderItem(item, index);
        if (existing.outerHTML !== updated.outerHTML) {
          existing.replaceWith(updated);
          itemElements.set(key, updated);
        }
      } else {
        // Create new element
        const el = renderItem(item, index);
        container.appendChild(el);
        itemElements.set(key, el);
      }
    });
 
    // Remove elements that are no longer in the list
    for (const [key, el] of itemElements) {
      if (!currentKeys.has(key)) {
        el.remove();
        itemElements.delete(key);
      }
    }
  }
 
  // Initial render
  render(store.get(key) || []);
 
  // Subscribe to changes
  return store.subscribe(key, (newItems) => render(newItems || []));
}
 
// Usage
const todoState = createReactiveState({
  todos: [],
  filter: "all",
});
 
const todoList = document.querySelector("#todo-list");
 
createReactiveList(todoState, "todos", todoList, (todo) => {
  const li = document.createElement("li");
  li.className = `todo-item ${todo.completed ? "completed" : ""}`;
  li.innerHTML = `
    <input type="checkbox" ${todo.completed ? "checked" : ""}>
    <span>${todo.text}</span>
    <button class="delete-btn" aria-label="Delete">x</button>
  `;
 
  li.querySelector("input").addEventListener("change", () => {
    const todos = todoState.get("todos").map((t) =>
      t.id === todo.id ? { ...t, completed: !t.completed } : t
    );
    todoState.set("todos", todos);
  });
 
  li.querySelector(".delete-btn").addEventListener("click", () => {
    const todos = todoState.get("todos").filter((t) => t.id !== todo.id);
    todoState.set("todos", todos);
  });
 
  return li;
});
 
// Adding a todo triggers automatic list re-render
todoState.set("todos", [
  { id: 1, text: "Learn observer pattern", completed: true },
  { id: 2, text: "Build reactive UI", completed: false },
]);
Binding TypeDirectionUse CasePerformance
Element bindingState to DOMDisplay values, labelsO(1) per update
Attribute bindingState to DOMdisabled, hidden, ariaO(1) per update
Class bindingState to DOMConditional stylingO(classes) per update
Two-way bindingBidirectionalForm inputsO(1) per update
List renderingState to DOMDynamic collectionsO(n) diffing
Computed propertiesDerived stateTotals, filtersO(deps) per recompute
Rune AI

Rune AI

Key Insights

  • Reactive state containers notify subscribers on every state change: Using Object.is() comparison prevents redundant notifications for unchanged values
  • Auto-updating DOM bindings eliminate manual querySelector calls: Bind elements to state keys once and let the observer system handle all future updates
  • Computed properties automatically derive values from dependencies: When any dependency changes, the computed value recalculates and notifies its own subscribers
  • Two-way binding synchronizes form inputs with state bidirectionally: Input events update the store while store changes update the input, with debouncing for performance
  • Keyed list rendering efficiently handles dynamic collections: Comparing item keys rather than index positions enables proper element reuse and minimal DOM mutations
RunePowered by Rune AI

Frequently Asked Questions

How does this compare to frameworks like React or Vue?

This pattern implements the same core concept (reactive state driving UI updates) that powers React's useState/useEffect and Vue's ref/reactive. Frameworks add virtual DOM diffing, component lifecycle management, server-side rendering, and optimized batching. The patterns shown here work well for small applications or understanding how reactivity works under the hood.

How do I prevent unnecessary DOM updates?

Use Object.is() comparison before notifying observers, which prevents updates when the value has not actually changed. For complex objects, implement shallow or deep equality checks. Batch multiple state changes with the update() method to consolidate notifications. For list rendering, use keyed items so the diff algorithm can identify moved vs changed elements.

Can I use this with Web Components?

Yes. Create a base class that initializes a reactive store in the constructor, binds properties in connectedCallback, and cleans up subscriptions in disconnectedCallback. Each custom element gets its own reactive scope while sharing a global store for cross-component communication through the observer pattern.

How do I handle async data loading with reactive state?

Set a loading state before the fetch, update with the result on success, or set an error state on failure. Subscribe to the loading key to show spinners, and subscribe to the data key to render results. Use the batch update method to set both loading=false and data=result atomically, preventing intermediate renders.

Conclusion

Reactive UIs built on the observer pattern provide automatic DOM synchronization without framework overhead. Reactive state containers with subscriptions form the foundation. Computed properties derive values automatically. Two-way binding synchronizes forms. List rendering with key-based diffing handles collections. For the observer pattern implementation details, see JavaScript Observer Pattern: Complete Guide. For understanding the factory pattern used in createReactiveState, review The JavaScript Factory Pattern: Complete Guide.