Skip to content

Vanilla JS Patterns

React’s state tools didn’t appear out of thin air. Every approach — from useState to XState to Signals — is built on patterns that existed in vanilla JavaScript long before React. Understanding those patterns means you stop learning APIs by rote and start recognizing the same idea in different clothes.

The Observer pattern is the foundation. One object (the subject) maintains a list of dependents (observers) and notifies them when it changes.

class Subject {
constructor() {
this.observers = []
}
subscribe(fn) {
this.observers.push(fn)
return () => {
this.observers = this.observers.filter(f => f !== fn)
}
}
notify(data) {
this.observers.forEach(fn => fn(data))
}
}

This is essentially what Zustand does. What React Query does. What useSyncExternalStore lets you build. A store holds data, components subscribe, data changes trigger re-renders.

const store = new Subject()
// React 18's useSyncExternalStore connects any observer-style store to React
function useStore(store) {
return useSyncExternalStore(
store.subscribe,
() => store.getState()
)
}

The Observer pattern is the simplest form of reactive state. One source of truth, many listeners.

Pub/Sub (Publish/Subscribe) adds a message broker between the publisher and the subscriber. The publisher doesn’t know who’s listening. The subscriber doesn’t know who published. They communicate through channels.

const bus = {
handlers: {},
on(event, fn) {
(this.handlers[event] ??= []).push(fn)
},
emit(event, data) {
this.handlers[event]?.forEach(fn => fn(data))
},
off(event, fn) {
this.handlers[event] = this.handlers[event]?.filter(f => f !== fn)
}
}

This is the idea behind Redux middleware, custom events, and cross-component communication without prop drilling. Components emit actions; reducers or handlers process them somewhere else.

// Any component can fire this
bus.emit('item:added', { id: 1, name: 'widget' })
// Any component can listen
bus.on('item:added', (item) => {
updateUI()
})

Pub/Sub is useful when the relationship between publishers and subscribers is dynamic or one-to-many. But it comes with a cost: the flow is harder to trace. An event fires, but where does it go? You have to search all the .on() calls.

Model-View-Controller divides an application into three roles:

  • Model — data and business logic. Notifies views when it changes.
  • View — what the user sees. Subscribes to the model.
  • Controller — handles user input, updates the model.
// Model
class Todos {
constructor() { this.items = []; this.listeners = [] }
add(text) { this.items.push({ text, done: false }); this.notify() }
subscribe(fn) { this.listeners.push(fn) }
notify() { this.listeners.forEach(fn => fn(this.items)) }
}
// View
function render(todos) {
list.innerHTML = todos.map(t => `<li>${t.text}</li>`).join('')
}
// Controller
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
model.add(input.value)
input.value = ''
}
})
model.subscribe(render)

React borrows heavily from MVC but collapses it. Components are both view and controller. State hooks are the model layer. The pattern is similar but the boundaries are different — React components handle rendering, event handling, and local state in one place.

Vanilla patternReact equivalent
Observer (subject + listeners)useSyncExternalStore, Zustand, React Query
Pub/Sub (event bus)Redux dispatch, custom events, mitt
MVC (model updates view)Unidirectional data flow — state changes trigger re-render
Singleton storeZustand store, Redux store, Jotai atom
Command patternuseReducer, Redux reducer
Factory patternCustom hooks

Each React state library picks a different combination. Zustand is Observer + singleton. Jotai is Observer with atomic granularity. XState is a formalized state machine — its own pattern entirely, but still depends on observers to notify React of transitions.

The point isn’t to use these patterns directly. It’s to recognize that React’s state tools are specific implementations of general ideas. When you understand the underlying pattern, picking the right tool becomes a matter of fit, not fashion.