A look at reactive patterns in plain JavaScript. Observables, subscriptions, and push-based updates when many parts of the UI care about the same changing values. You do not need RxJS to understand the idea; we build a small, practical version first.
Pull vs push
A store with subscribe is already reactive in a loose sense: when state changes, subscribers get pushed the new value. Observables generalise that pattern to streams of values over time – user input, timers, WebSocket messages, not just application state.
Pull: ask for data when you need it (getState(), fetch). Push: register once, receive updates as they happen.
A tiny observable
export function createObservable(subscribeFn) {
return {
subscribe(observer) {
const wrapped =
typeof observer === 'function'
? { next: observer }
: observer;
const unsubscribe = subscribeFn(wrapped);
return typeof unsubscribe === 'function' ? unsubscribe : () => {};
},
};
}
const clicks = createObservable(({ next }) => {
const handler = (event) => next(event);
document.addEventListener('click', handler);
return () => document.removeEventListener('click', handler);
});
const unsub = clicks.subscribe({
next(event) {
console.log('clicked', event.target);
},
});
The teardown function returned from the producer is critical. Without it, every subscription leaks listeners.
From DOM events to streams
export function fromEvent(target, eventName) {
return createObservable(({ next }) => {
const handler = (event) => next(event);
target.addEventListener(eventName, handler);
return () => target.removeEventListener(eventName, handler);
});
}
export function map(observable, fn) {
return createObservable(({ next, error, complete }) =>
observable.subscribe({
next: (value) => next(fn(value)),
error,
complete,
})
);
}
const searchInput = document.querySelector('#search');
const input$ = fromEvent(searchInput, 'input');
const query$ = map(input$, (event) => event.target.value.trim());
query$.subscribe((query) => {
filterList(query);
});
Debouncing with operators
Search-as-you-type should not hit the server on every keypress. Debounce waits for a pause before emitting.
export function debounce(observable, ms) {
return createObservable(({ next, error, complete }) => {
let timer;
return observable.subscribe({
next(value) {
clearTimeout(timer);
timer = setTimeout(() => next(value), ms);
},
error,
complete,
});
});
}
debounce(query$, 300).subscribe((query) => {
fetchResults(query);
});
Bridging stores and observables
Your store can expose an observable side for reactive UI while keeping a simple getState for one-off reads.
export function storeToObservable(store) {
return createObservable(({ next }) => {
return store.subscribe(next);
});
}
storeToObservable(taskStore).subscribe((state) => {
document.title = `${state.tasks.length} tasks`;
});
Signals (lightweight reactivity)
Signals are a newer primitive: a value plus subscribers, often with automatic dependency tracking in computed values. The platform may add signals natively; you can approximate them today.
export function signal(initial) {
let value = initial;
const subs = new Set();
return {
get() {
return value;
},
set(next) {
if (Object.is(value, next)) return;
value = next;
subs.forEach((fn) => fn(value));
},
subscribe(fn) {
subs.add(fn);
return () => subs.delete(fn);
},
};
}
const count = signal(0);
count.subscribe((n) => {
document.querySelector('#count').textContent = String(n);
});
When observables earn their keep
- Many event sources merged (clicks, keys, WebSocket, worker messages).
- Time-based logic: debounce, throttle, timeouts, animation frames.
- Cancellation chains where unsubscribing must abort in-flight work.
- Simple store + render is enough for CRUD panels – do not add observables for fashion.
The next tutorial moves heavy work off the main thread with Web Workers.

