Understanding the Observer Pattern by Building One

Afifudin
·
21 July 2026

Ever wondered how frontend global state managers work under the hood? One of the core ideas behind them is the Observer Pattern.

In this article, we’ll build an Observer Pattern from scratch. Rather than jumping straight to the final implementation, we’ll introduce one challenge at a time and gradually improve our design to solve each one.

The Observer Pattern isn’t only used for frontend state management. That’s just one of its many use cases. It’s also widely used in backend systems and many other areas of software development.

Let’s get started!

Building the Foundation

We’ll begin with a regular JavaScript variable.
We can read its value and update it like this:

let x = 1;
// Read
console.log(x); // 1
// Update
x = 2;
console.log(x); // 2

Now let’s say we have a new requirement:
We want to run some code whenever x changes.

Easy!
First, instead of directly reassigning the value, we’ll use a setX function.
Then, we simply call the callback from there.

let x = 1;
function setX(newValue) {
if (newValue !== x) {
x = newValue;
doSomething();
}
}

Notice that I also added the newValue !== x check to make sure the function is called only when the value actually changes.

Then another challenge comes up.
What if there are multiple things that should happen whenever x changes?

We can simply call more functions.

let x = 1;
function setX(newValue) {
if (newValue !== x) {
x = newValue;
doSomething();
foo();
bar();
baz();
}
}

Soon you’ll encounter another requirement.
What if the callbacks need to be dynamic?

Sometimes you want to run foo(), bar(), and baz().
Sometimes only foo() and baz().
Sometimes only baz().
And sometimes… nothing at all.

A simple solution is to keep the callbacks in an array or, even better, a Set.

let x = 1;
const callbacks = new Set();
function setX(newValue) {
if (newValue !== x) {
x = newValue;
callbacks.forEach(fn => fn());
}
}
setX(2);
// Nothing happens
callbacks.add(foo);
callbacks.add(bar);
setX(3);
// foo called
// bar called
callbacks.delete(foo);
callbacks.add(baz);
setX(4);
// bar called
// baz called

Great! We’ve found a pattern.

So far, this only works for the x variable.
Let’s make it reusable by extracting the logic into a factory function.

Building a Reusable Store

store.js
function createStore(initialValue) {
let state = initialValue;
const callbacks = new Set();
function getState() {
return state;
}
function setState(newValue) {
if (newValue !== state) {
state = newValue;
callbacks.forEach(fn => fn());
}
}
function addCallback(fn) {
callbacks.add(fn);
}
function removeCallback(fn) {
callbacks.delete(fn);
}
return {
getState,
setState,
addCallback,
removeCallback,
};
}
const xStore = createStore(1);
xStore.addCallback(foo);
const yStore = createStore(true);
yStore.addCallback(bar);
if (something) {
yStore.addCallback(baz);
}
xStore.setState(2);
// foo called
yStore.setState(false);
// bar called
// baz called (if `something` is truthy)
xStore.removeCallback(foo);
xStore.setState(3);
// No callbacks are called

This is already pretty good, but there’s still room for improvement.

Notice that we can’t conveniently use anonymous functions like this:

xStore.addCallback(() => console.log('Lorem ipsum'));
xStore.removeCallback(() => console.log('Lorem ipsum'));

This won’t work because those are two different function instances.
Instead, we have to store the function in a variable first.

const theFn = () => console.log('Yay');
xStore.addCallback(theFn);
xStore.removeCallback(theFn);

That’s a bit inconvenient.
A cleaner API is to have addCallback() return a function that removes the callback.

function createStore(initialValue) {
let state = initialValue;
const callbacks = new Set();
function getState() {
return state;
}
function setState(newValue) {
if (newValue !== state) {
state = newValue;
callbacks.forEach(fn => fn());
}
}
function addCallback(fn) {
callbacks.add(fn);
return () => callbacks.delete(fn);
}
return {
getState,
setState,
addCallback,
};
}

Nice!
We’ve successfully built an Observer Pattern for our state.

Let’s make one more improvement by using more common terminology.
Instead of calling them callbacks, we’ll call them subscribers, and also rename addCallback to subscribe.

function createStore(initialValue) {
let state = initialValue;
const subscribers = new Set();
// ...
function subscribe(fn) {
subscribers.add(fn);
return () => subscribers.delete(fn);
}
return {
getState,
setState,
subscribe,
};
}

Let’s make it even better.

In some cases, subscribers need to know what changed. Rather than simply notifying them that something happened, we can pass both the new value and the previous value.

Also, there’s one more edge case to consider.
Right now we’re comparing values with newValue !== state. This breaks when both values are NaN, because NaN !== NaN is always true.

To make our implementation more robust, it’s better to use Object.is(), which correctly handles NaN, -0, and other JavaScript edge cases.

Here’s the improved implementation:

function createStore(initialValue) {
let state = initialValue;
const subscribers = new Set();
function getState() {
return state;
}
function setState(newValue) {
if (Object.is(newValue, state)) return;
const previousValue = state;
state = newValue;
subscribers.forEach(fn => fn(newValue, previousValue));
}
function subscribe(fn) {
subscribers.add(fn);
return () => subscribers.delete(fn);
}
return {
getState,
setState,
subscribe,
};
}

At this point, our createStore is already robust enough for production use.

Finally, let’s add TypeScript types.

store.ts
type Subscriber<T> = (newValue: T, previousValue: T) => void;
export function createStore<T>(initialValue: T) {
let state = initialValue;
const subscribers = new Set<Subscriber<T>>();
function getState() {
return state;
}
function setState(newValue: T) {
if (Object.is(newValue, state)) return;
const previousValue = state;
state = newValue;
subscribers.forEach(fn => fn(newValue, previousValue));
}
function subscribe(fn: Subscriber<T>) {
subscribers.add(fn);
return () => subscribers.delete(fn);
}
return {
getState,
setState,
subscribe,
};
}

Real-World Enhancement

Our createStore is already in good shape, but there are still plenty of ways to extend it depending on your use case.

Custom State Comparison

Right now, we’re using Object.is() to determine whether the state has changed. That’s a sensible default, but it isn’t the only option. Depending on your application, you might want to use shallow comparison, deep comparison, or even provide your own comparison function.

For example, we could make the comparison strategy configurable:

createStore(initialValue, {
compareFn: (previousValue, newValue) => shallowEqual(previousValue, newValue),
});

Then our implementation becomes:

export function createStore<T>(
initialValue: T,
options: {
compareFn?: (previousValue: T, newValue: T) => boolean;
} = {},
) {
// ...
function setState(newValue: T) {
const compareFn = options.compareFn || Object.is;
if (compareFn(state, newValue)) return;
const previousValue = state;
state = newValue;
subscribers.forEach(fn => fn(newValue, previousValue));
}
// ...
}

This allows each store to choose the comparison strategy that best fits its data.

Store Lifecycle Hooks

Another useful enhancement is exposing lifecycle hooks.

Sometimes a store owns an external resource that shouldn’t stay alive forever. For example, it might maintain a WebSocket connection, listen for browser events, or periodically poll an API. Ideally, those resources should only exist while the store actually has subscribers.

Imagine a store that synchronizes its state through a WebSocket connection. If nobody is subscribed to the store, keeping the connection open is just wasting resources.
Instead, we can:

createStore(initialValue, {
onFirstSubscribe: () => connectWebSocket(),
onLastUnsubscribe: () => disconnectWebSocket(),
});

Which can be implemented like this:

store.ts
type Subscriber<T> = (newValue: T, previousValue: T) => void;
export function createStore<T>(
initialValue: T,
options: {
compareFn?: (previousValue: T, newValue: T) => boolean;
onFirstSubscribe?: (state: T) => void;
onLastUnsubscribe?: (state: T) => void;
} = {},
) {
let state = initialValue;
const subscribers = new Set<Subscriber<T>>();
function getState() {
return state;
}
function setState(newValue: T) {
const compareFn = options.compareFn || Object.is;
if (compareFn(state, newValue)) return;
const previousValue = state;
state = newValue;
subscribers.forEach(fn => fn(newValue, previousValue));
}
function subscribe(fn: Subscriber<T>) {
if (subscribers.size === 0) {
options.onFirstSubscribe?.(state);
}
subscribers.add(fn);
return () => {
subscribers.delete(fn);
if (subscribers.size === 0) {
options.onLastUnsubscribe?.(state);
}
};
}
return {
getState,
setState,
subscribe,
};
}

These enhancements don’t change the Observer Pattern itself. They simply build on top of the same foundation, making the store more flexible and practical for real-world applications.

Closing

What’s interesting is that we never set out to implement the Observer Pattern.

We simply kept solving one problem after another:

Along the way, we naturally arrived at the Observer Pattern. Once we had that solid foundation, extending it with practical features became straightforward.

If our final API looks familiar, that’s because you’ve probably seen similar APIs before. State management libraries like Redux, Zustand, and TanStack Store expose a similar subscribe API. React’s useSyncExternalStore is also designed to work with external stores that provide subscribe and getState. Even Node.js’s EventEmitter follows the same core idea, although it publishes named events instead of state changes.

Those libraries provide some additional features and optimizations, but underneath, they’re all built around the same fundamental concept: when something changes, notify everyone who’s interested.

Sometimes the best way to learn a design pattern is not by memorizing its definition, but by discovering why it exists in the first place.