Building a Reactive UI with the JS Observer
Build a simple reactive UI system from scratch using the observer pattern. Learn how state changes automatically update the DOM without a framework.
The observer pattern is a way to automatically notify dependent code when something changes. In a UI, that "something" is usually state, and the "dependent code" is the rendering logic that updates the DOM.
Here is the core idea: instead of calling updateUI() every time you change a variable, you set up observers once. They watch state. When state changes, they react. You never write render() manually again.
The Observer Pattern in Plain JavaScript
Start with a simple subject that tracks observers and notifies them:
function createSubject() {
const observers = [];
return {
subscribe(observer) {
observers.push(observer);
},
notify(data) {
observers.forEach(observer => observer(data));
}
};
}An observer is just a function. The subject calls every observer whenever notify is called:
const subject = createSubject();
subject.subscribe((data) => console.log("Observer 1:", data));
subject.subscribe((data) => console.log("Observer 2:", data));
subject.notify("state changed");Observer 1: state changed
Observer 2: state changedThis is the simplest form. The subject holds a list of observer functions and calls them all when something happens.
The subject does not know what each observer does. It only knows they are functions and calls them with the new state. The observers are responsible for reading the state and updating their part of the UI.
Building a Reactive Store
Combine the observer pattern with an actual state object to create a reactive store:
function createStore(initialState) {
let state = { ...initialState };
const observers = [];
return {
getState() {
return state;
},
setState(update) {
const prevState = state;
state = { ...state, ...update };
// Only notify if something actually changed
if (prevState !== state) {
observers.forEach(observer => observer(state, prevState));
}
},
subscribe(observer) {
observers.push(observer);
// Immediately call with current state
observer(state, state);
}
};
}The store holds state, accepts partial updates via setState, and calls every observer with the new state. The immediate call inside subscribe ensures the observer renders the initial state, not just future changes.
Connecting the Store to the DOM
Here is a working counter application. The store holds the count. Observers update the DOM:
// Create the reactive store
const store = createStore({ count: 0 });
// Observer: render the count into the DOM
store.subscribe((state) => {
document.getElementById("count-display").textContent = state.count;
});
// Observer: toggle a CSS class when count is negative
store.subscribe((state) => {
const el = document.getElementById("count-display");
el.classList.toggle("negative", state.count < 0);
});
// Wire up buttons
document.getElementById("increment-btn").addEventListener("click", () => {
store.setState({ count: store.getState().count + 1 });
});
document.getElementById("decrement-btn").addEventListener("click", () => {
store.setState({ count: store.getState().count - 1 });
});
document.getElementById("reset-btn").addEventListener("click", () => {
store.setState({ count: 0 });
});The key benefit: the buttons never touch the DOM. They only call store.setState(). The observers handle all rendering. Adding a new UI element that depends on count is one new store.subscribe() call. Nothing else changes.
A More Complete Example: Todo List
Here is a more realistic reactive store driving a simple todo list UI:
function createTodoStore() {
const store = createStore({
todos: [],
filter: "all" // "all", "active", "completed"
});
return {
...store,
addTodo(text) {
const todo = {
id: Date.now(),
text,
completed: false
};
const todos = [...store.getState().todos, todo];
store.setState({ todos });
},
toggleTodo(id) {
const todos = store.getState().todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
);
store.setState({ todos });
},
removeTodo(id) {
const todos = store.getState().todos.filter(todo => todo.id !== id);
store.setState({ todos });
},
setFilter(filter) {
store.setState({ filter });
},
getVisibleTodos() {
const { todos, filter } = store.getState();
if (filter === "active") return todos.filter(t => !t.completed);
if (filter === "completed") return todos.filter(t => t.completed);
return todos;
}
};
}
const todoStore = createTodoStore();
// Observer: render the todo list
todoStore.subscribe(() => {
const listEl = document.getElementById("todo-list");
const todos = todoStore.getVisibleTodos();
listEl.innerHTML = todos.map(todo => `
<li class="${todo.completed ? 'completed' : ''}">
<span>${todo.text}</span>
<button data-action="toggle" data-id="${todo.id}">
${todo.completed ? 'Undo' : 'Done'}
</button>
<button data-action="remove" data-id="${todo.id}">Delete</button>
</li>
`).join("");
});
// Observer: update the filter buttons
todoStore.subscribe(() => {
const { filter } = todoStore.getState();
document.querySelectorAll("[data-filter]").forEach(btn => {
btn.classList.toggle("active", btn.dataset.filter === filter);
});
});Every state change triggers a re-render. The rendering functions are pure: they read state and produce DOM. The store methods are the only place that changes state. This separation makes the app predictable and easy to debug.
Performance: Avoiding Unnecessary Re-renders
The simple version re-renders every observer on every state change. For a todo list, that is fine. For larger apps, you can make observers selective:
function createStore(initialState) {
let state = { ...initialState };
const observers = [];
return {
getState() {
return state;
},
setState(update) {
const prevState = state;
state = { ...state, ...update };
if (prevState !== state) {
observers.forEach(({ keys, fn }) => {
// Only notify if one of the watched keys changed
const changed = keys.some(key => prevState[key] !== state[key]);
if (changed) {
fn(state, prevState);
}
});
}
},
subscribe(observerFn, watchedKeys = null) {
const entry = { fn: observerFn, keys: watchedKeys || Object.keys(state) };
observers.push(entry);
observerFn(state, state);
}
};
}Now an observer can declare which state keys it cares about:
// This observer only runs when "count" changes
store.subscribe(renderCount, ["count"]);
// This observer only runs when "todos" changes
store.subscribe(renderTodos, ["todos"]);This is the same idea that modern frameworks use under the hood: track which state a component reads, and only re-render when that specific state changes.
The Pattern in Practice
The observer pattern powers many real tools:
| Tool | How it uses the pattern |
|---|---|
addEventListener | The DOM element is the subject. Your callback is the observer. |
MutationObserver | Watches DOM changes and notifies your callback. |
IntersectionObserver | Notifies you when elements enter or leave the viewport. |
| Vue reactivity | State is wrapped in reactive proxies. Components are observers. |
| MobX | Observables are subjects. autorun and observer wrap your components. |
The browser's built-in observers are good examples to study. See how the Mutation Observer API uses the same subscribe-and-notify pattern.
Common Mistakes
Mutating state directly instead of through setState. If you do store.getState().count = 5, no observer fires. Always use setState so the store knows to notify.
Creating infinite loops. If an observer calls setState, which triggers observers, which calls setState again, you get an infinite loop. Keep observers pure: read state, render DOM, do not call setState.
Forgetting to clean up. The simple version has no unsubscribe. In production code, always return an unsubscribe function from subscribe, similar to how event listeners clean up.
Rune AI
Key Insights
- The observer pattern lets objects (observers) subscribe to state changes on a subject.
- A reactive store holds state, notifies observers on change, and exposes a setState API.
- Each observer is a function that renders a piece of UI when state changes.
- The pattern eliminates manual DOM update calls scattered across your code.
- This is the foundation of how Vue, Svelte, and MobX handle reactivity.
Frequently Asked Questions
How is this different from a pub-sub event bus?
Is this how React works?
When should I use this instead of a framework?
Conclusion
The observer pattern makes state changes automatically update the UI. Build a simple reactive store, register observers that render to the DOM, and let state setters notify all observers. It is the foundation that every reactive framework is built on.The observer pattern is the foundation of reactive UI programming. Create a subject that holds a list of observer functions. Notify all observers when state changes. Let each observer handle its own DOM updates.
A reactive store built on this pattern gives you a single source of truth for state and automatic UI updates when that state changes. This eliminates scattered updateUI() calls and keeps your rendering logic in one place per UI element.
The jump from this pattern to a full state management system is small. Add selective re-rendering, batching, and computed values, and you have the core of what frameworks provide.
More in this topic
Using Reflect and Proxy Together in JavaScript
Proxy traps intercept operations, but Reflect is what makes them behave correctly. Learn why Reflect belongs inside every trap and what breaks when you skip it.
Top JS Array Methods Interview Questions to Know
The array method questions that come up most often in JavaScript interviews, answered directly with short examples: map vs forEach, mutating vs non-mutating methods, reduce, and more.
JavaScript Reflect API: Advanced Architecture
The Reflect object exposes JavaScript's own internal operations as plain functions. Learn every Reflect method, what it returns, and why it exists as its own API.