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.

8 min read

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:

javascriptjavascript
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:

javascriptjavascript
const subject = createSubject();
 
subject.subscribe((data) => console.log("Observer 1:", data));
subject.subscribe((data) => console.log("Observer 2:", data));
 
subject.notify("state changed");
texttext
Observer 1: state changed
Observer 2: state changed

This is the simplest form. The subject holds a list of observer functions and calls them all when something happens.

Observer pattern: subject notifies registered observers

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:

javascriptjavascript
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:

javascriptjavascript
// 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:

javascriptjavascript
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:

javascriptjavascript
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:

javascriptjavascript
// 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:

ToolHow it uses the pattern
addEventListenerThe DOM element is the subject. Your callback is the observer.
MutationObserverWatches DOM changes and notifies your callback.
IntersectionObserverNotifies you when elements enter or leave the viewport.
Vue reactivityState is wrapped in reactive proxies. Components are observers.
MobXObservables 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

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.
RunePowered by Rune AI

Frequently Asked Questions

How is this different from a pub-sub event bus?

In the observer pattern, the subject knows its observers and notifies them directly. In pub-sub, publishers and subscribers communicate through a message broker and do not know about each other. The observer pattern is tighter coupling for a single data source.

Is this how React works?

React uses a different model (virtual DOM with diffing), but the observer pattern is the foundation of many state management libraries and simpler reactive systems like Vue's reactivity system or Svelte's stores.

When should I use this instead of a framework?

Use this for small widgets, prototypes, or when you want to understand how reactivity works under the hood. For production apps, use a framework that handles edge cases like batching updates and efficient DOM diffing.

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.