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.
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
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 -> 1Auto-Updating DOM Bindings
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
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
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
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 Type | Direction | Use Case | Performance |
|---|---|---|---|
| Element binding | State to DOM | Display values, labels | O(1) per update |
| Attribute binding | State to DOM | disabled, hidden, aria | O(1) per update |
| Class binding | State to DOM | Conditional styling | O(classes) per update |
| Two-way binding | Bidirectional | Form inputs | O(1) per update |
| List rendering | State to DOM | Dynamic collections | O(n) diffing |
| Computed properties | Derived state | Totals, filters | O(deps) per recompute |
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
Frequently Asked Questions
How does this compare to frameworks like React or Vue?
How do I prevent unnecessary DOM updates?
Can I use this with Web Components?
How do I handle async data loading with reactive state?
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.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.