Object communication and state management patterns with JavaScript
Introduction
New resources / coming back to it
things to reference == https://www.dhiwise.com/post/how-to-implement-signals-in-javascript-for-event-handling
Common patterns over the years
-
JavaScript
Event listener pattern
let count = 0; // Get references to DOM elements const counterElement = document.getElementById('counter'); const incrementButton = document.getElementById('increment-btn'); // Update the counter in the DOM function updateCounter() { count++; counterElement.textContent = count; } // Add event listener to the button (Observer Pattern) incrementButton.addEventListener('click', updateCounter);
Calling basic event listeners a “pattern” in the formal design pattern sense might be an overreach. While they resemble the mechanics of patterns like the Observer or Pub/Sub, event listeners are a fundamental feature of the browser’s API rather than a structured design pattern.
Pros:
- Simple and direct: Easy to implement and understand, especially for small-scale applications or single-page interactions.
- Built-in to the browser: No need for external libraries or complex setups.
- Immediate reactivity: Reacts to user input (like clicks) directly, making it easy to tie user interactions to UI updates.
Cons:
- Tight coupling: The logic is directly tied to the event and element, which can make it harder to scale or maintain if the project grows.
- No central state management: As applications get more complex, managing state across different components using only event listeners can lead to a lack of organization and consistency.
- Limited control: For more complex scenarios, such as orchestrating multiple events or handling shared state across components, event listeners can quickly become insufficient.
This would work as a basic setup for simple apps or interactions but starts to show limitations as the app becomes more sophisticated, especially when compared to other patterns like Pub/Sub or centralized state management solutions.
We suggest you take this as far as possible, and if you run into trouble, you’ll have a good (real) reason to explore other patterns. Maybe we should add a note about event delegation here.
Sandbox -
JavaScript
Observer Pattern
function Observer() { this.observers = []; } Observer.prototype.subscribe = function(fn) { this.observers.push(fn); }; Observer.prototype.notify = function(data) { for (var i = 0; i < this.observers.length; i++) { this.observers[i](data); } }; var counterObserver = new Observer(); // Use a regular function instead of an arrow function counterObserver.subscribe( function(newCount) { console.log('Count is now: ' + newCount); }); // notify all observers when the count changes counterObserver.notify(1); // Count is now: 1 counterObserver.notify(2); // Count is now: 2 // ------------- // just for history ^ we didn't have forEach or classes in JS until (ECMAScript 2015) // ------------- class Counter { constructor() { this.count = 0; this.observers = []; } subscribe(observer) { this.observers.push(observer); } increment() { this.count++; this.notify(); } notify() { this.observers.forEach( (observer)=> observer(this.count)); } } const counter = new Counter(); counter.subscribe(newCount => console.log(`Counter: ${newCount}`)); counter.increment(); // counter: 1 counter.increment(); // counter: 2
The Observer pattern directly ties a piece of state (the counter) to an action (rendering the counter), which reacts when the state changes. (1990s – Early 2000s)
Tightly couples state and observers.
Pros:
- Simple and easy to understand.
- Directly links state changes to behavior.
- Works well for small-scale applications or components (e.g., UI elements, event handling).
Cons:
- Tight coupling: The observer pattern can lead to highly coupled code, where observers and subjects are dependent on each other, making it difficult to scale or change.
- Hard to debug: With many observers, it can be hard to trace where and why a particular update is happening.
Historical Relevance:
- Early JavaScript days: This was widely used for DOM event handling (
addEventListener
), but as web applications became more complex, tight coupling became a problem. - Fading favor: As complexity increased, developers sought decoupling, flexibility, and better separation of concerns, leading to the rise of Pub/Sub and state management patterns like Redux.
Sandbox -
JavaScript
Pub/Sub Pattern
const pubSub = { events: {}, subscribe(event, callback) { if (!this.events[event]) this.events[event] = []; this.events[event].push(callback); }, publish(event, data) { if (this.events[event]) { this.events[event].forEach( (callback)=> callback(data)); } } }; let count = 0; pubSub.subscribe('increment', (newCount)=> console.log(`Counter: ${newCount}`)); function incrementCounter() { count++; pubSub.publish('increment', count); } incrementCounter(); // Counter: 1 incrementCounter(); // Counter: 2
In the Pub/Sub pattern, publishers (counter updates) broadcast messages (state changes), and subscribers listen for these events. (Mid-2000s)
Decouples event dispatching and listening, creating more flexibility.
Pros:
- Loose coupling: Publishers and subscribers don’t need to know about each other, which leads to greater flexibility.
- Scales well: Good for large systems where components might need to react to a variety of events, or when different parts of the system need to be loosely connected.
Cons:
- Can become chaotic: With too many publishers and subscribers, the system can become difficult to manage. It can lead to a scenario where events fire unexpectedly, making the system hard to debug.
- Hidden dependencies: It may be hard to know which events trigger which pieces of code without extensive documentation or tooling.
Historical Relevance:
- Popular in mid-2000s: As web applications grew in complexity, Pub/Sub was popular for decoupling components (e.g., in Backbone.js).
- Still useful, but: With more sophisticated state management tools (like Redux or signals), Pub/Sub is often seen as too unstructured for modern apps with complex UIs.
Sandbox -
JavaScript
Reducer pattern
// define initial state const initialState = { count: 0 }; // define a reducer function function reducer(state, action) { switch (action.type) { case 'INCREMENT': return { count: state.count + 1 }; case 'DECREMENT': return { count: state.count - 1 }; default: return state; } } // example usage without Redux let state = initialState; console.log("Initial State:", state); // dispatch actions manually state = reducer(state, { type: 'INCREMENT' }); console.log("After INCREMENT:", state); state = reducer(state, { type: 'DECREMENT' }); console.log("After DECREMENT:", state);
The reducer function is pure, meaning it returns a new state based solely on its inputs, without side effects. This purity ensures predictable state transitions:
INCREMENT
increases thecount
by one.DECREMENT
decreases thecount
by one.
The standalone reducer concept is foundational for applications that need predictable state changes and separation of logic from UI updates.
Pros:
- Predictability: Reducers keep state transitions consistent and predictable by maintaining pure functions.
- Simplicity: For small applications, a standalone reducer can manage state without needing complex libraries.
Cons:
- Limited Scope: Standalone reducers lack advanced capabilities like middleware, centralized state, or time-travel debugging, which can make scaling difficult in large apps.
Historical Relevance:
While reducers became prominent with Redux in the 2010s, the concept has its roots in functional programming. Today, reducers remain a valuable pattern, providing an organized, functional approach to state management even outside of Redux.
-
JavaScript
State Machines
const counterStateMachine = { state: 0, transitions: { increment() { this.state++; this.display(); } }, display() { console.log(`Counter: ${this.state}`); } }; counterStateMachine.transitions.increment(); // Counter: 1 counterStateMachine.transitions.increment(); // Counter: 2
A State Machine manages transitions between states. In this case, we transition from one count to the next in a controlled manner. (2000s – 2010s)
Enforces state transitions in a structured way.
Pros:
- Predictable and structured: You explicitly define the valid states and transitions, which prevents many bugs related to invalid state changes.
- Useful in complex workflows: Great for applications where the state changes follow strict rules (e.g., form wizards, multi-step processes, animation sequences).
Cons:
- Overhead for simple cases: For small apps or less structured use cases, state machines can add unnecessary complexity.
- Can be rigid: While structured, it may be difficult to adapt state machines for applications with more dynamic or fluid state changes.
Historical Relevance:
- Used in UI logic: State machines have always had a niche in managing complex UI flows and transitions (e.g., XState).
- Increasing usage: Recently, state machines have seen a resurgence, especially in the context of managing complex component states in modern UIs (e.g., forms, media players).
Sandbox -
JavaScript
Reactive Programming (RxJS)
import { BehaviorSubject } from 'rxjs'; const count$ = new BehaviorSubject(0); count$.subscribe( (newCount)=> console.log(`Counter: ${newCount}`)); function incrementCounter() { count$.next(count$.value + 1); } incrementCounter(); // Counter: 1 incrementCounter(); // Counter: 2
In reactive programming, you work with streams of data. RxJS’s
BehaviorSubject
lets you create a stream that reacts to changes over time. (2010s)It’s using a library… so, that part is hidden away!
RxJS uses observables to manage asynchronous data streams. In the example with BehaviorSubject, it creates an observable (a data source) that holds a value, and subscribers are notified whenever that value changes—similar to the Observer pattern. However, RxJS goes further by offering powerful tools for manipulating streams, such as mapping, filtering, and combining data.
Behind the scenes, RxJS treats data as a continuous stream of events, making it ideal for handling complex asynchronous operations like user interactions or API responses. Its suite of operators makes reactive programming more declarative and flexible than the plain Observer pattern.
Pros:
- Asynchronous mastery: Handles asynchronous data (e.g., streams of events, promises) in a clear, declarative way.
- Powerful for complex interactions: Perfect for applications with many dynamic, real-time updates (e.g., user inputs, WebSockets, streams).
Cons:
- Steep learning curve: Understanding observables, operators, and streams can be challenging, especially for developers new to reactive programming.
- Overkill for simple apps: For simple applications or state management, using RxJS can introduce unnecessary complexity.
Historical Relevance:
- 2010s boom: As applications became more complex and asynchronous (e.g., SPAs with lots of user interaction), RxJS gained popularity, especially with frameworks like Angular.
- Still relevant, but niche: While powerful, RxJS is often seen as overcomplicated for most use cases unless you’re dealing with advanced asynchronous data streams.
-
JavaScript
Flux / Redux Pattern
const initialState = { count: 0 }; function reducer(state = initialState, action) { switch (action.type) { case 'INCREMENT': return { count: state.count + 1 }; default: return state; } } function createStore(reducer) { let state = reducer(undefined, {}); const listeners = []; return { getState() { return state; }, dispatch(action) { state = reducer(state, action); listeners.forEach( (listener)=> listener()); }, subscribe(listener) { listeners.push(listener); } }; } const store = createStore(reducer); store.subscribe(() => console.log(`Counter: ${store.getState().count}`)); store.dispatch({ type: 'INCREMENT' }); // Counter: 1 store.dispatch({ type: 'INCREMENT' }); // Counter: 2
In Redux, state is centralized, and actions are dispatched to change that state. The reducer listens to those actions and modifies the state accordingly. (2014 – Present)
Centralizes state and uses reducers and actions for predictable state changes.
Pros:
- Centralized state management: All state is stored in a single object, making the app’s state predictable and easier to debug.
- Time-travel debugging: Redux’s strict state transition model allows for easy debugging and state rollbacks.
- Good for large apps: Scales well for applications with complex state management needs, especially in React ecosystems.
Cons:
- Boilerplate: Requires writing a lot of boilerplate code (actions, reducers, dispatchers) even for simple state changes.
- Rigid architecture: Can be too structured and verbose for small applications or dynamic, real-time features.
Historical Relevance:
- 2015–2019 peak: Redux became the go-to state management library in React apps, praised for making complex apps easier to maintain.
-
Modern Alternatives: React’s Context API combined with the useReducer hook offers a simpler way to manage state without the heavy boilerplate of Redux.
- Legacy Use: Redux remains popular in large-scale or enterprise applications, especially those with strict or complex state management requirements.
Redux builds upon the standalone reducer pattern by adding a centralized store for state, middleware for handling side effects, and time-travel debugging for development. Redux formalizes the reducer pattern, which is useful in large applications with complex state requirements, particularly in the React ecosystem. However, for simple applications or cases where minimal state management is needed, a standalone reducer often suffices.
-
JavaScript
Signals (Fine-Grained Reactivity)
function createSignal(initialValue) { let value = initialValue; let observers = []; function signal(newValue) { if (arguments.length === 0) return value; // Getter value = newValue; // Setter observers.forEach( (observer)=> observer(value)); } signal.observe = function(observer) { observers.push(observer); observer(value); // Notify with the initial value }; return signal; } const count = createSignal(0); count.observe( (newCount)=> console.log(`Counter: ${newCount}`)); count(count() + 1); // Counter: 1 count(count() + 1); // Counter: 2
Signals encapsulate state and automatically notify subscribers when the state changes without full app re-rendering. (Emerging in Late 2010s – 2020s)
Fine-grained reactivity, automatically managing state changes and notifying observers.
Pros:
- Minimal overhead: Signals are lightweight and automatically notify subscribers when state changes.
- Fine-grained reactivity: Only the specific parts of the UI that depend on a signal are updated, leading to more efficient renders.
- Easy to understand: Signals offer a simple mental model—state changes trigger reactions without the need for event systems or complex state management libraries.
Cons:
- Early adoption stage: Signals are still relatively new and not yet standardized, so there may be fewer learning resources and tools available compared to older patterns like Redux.
Historical Relevance:
- Emerging: Signals are increasingly popular, particularly in newer frameworks like Solid.js, and could become part of the JavaScript standard.
- Growing favor: As more developers move away from the boilerplate of Redux and the complexity of RxJS, signals are being embraced for their simplicity and efficiency.
Sandbox
Who cares! What does it allll mean!?
Vue’s Reactivity System: Similar to Signals
In Vue.js, reactivity is at the core of how state changes are managed. Vue uses ref()
, reactive()
, and computed properties to create reactive state and track dependencies, ensuring that only the parts of the UI that depend on the changed state are updated. This is conceptually similar to signals because both systems are designed to handle fine-grained reactivity—Vue automatically updates the DOM based on state changes, much like how signals notify subscribers of state updates.
Native Signals: A Possible Future in JavaScript
There is growing interest in standardizing native signals in the JavaScript language, which would make reactivity a built-in browser feature. Signals would allow developers to track changes in state and automatically update only the necessary parts of the UI without relying on large state management tools or frameworks. This would bring performance improvements, simplify the code required for reactive programming, and create a unified reactivity model across all JavaScript applications.
What Native Signals Would Mean for Vue
If native signals are adopted into JavaScript, Vue could seamlessly integrate them into its existing system. While the API would likely remain the same for developers—keeping ref()
, reactive()
, and computed()
as the core tools—Vue could switch its internal reactivity engine to use these native signals. This could lead to better performance and a lighter framework, as Vue would no longer need to rely on its custom proxy-based reactivity system. Essentially, Vue would become even more efficient while maintaining the same developer-friendly experience.
Up for a challenge?
It might be fun to try all of them. It also – might be as waste of time.
This Simple Store App challenge demonstrates how to manage shared state across components using patterns like Event Listeners, Observer, Pub/Sub, State Machines, RxJS, Redux, Signals, and Vue’s Reactivity System. The task involves syncing a product list, cart, and header with cart counts while persisting state using localStorage.
See the full challenge → /simple-store-using-javascript-patterns