JavaScript in Plain English

New JavaScript and Web Development content every day. Follow to join our 3.5M+ monthly readers.

Follow publication

You're reading for free via Ketan Jakhar's Friend Link. Become a member to access the best of Medium.

Mastering Design Patterns in JavaScript: Part 3 — The Observer Pattern 👀

Ketan Jakhar
JavaScript in Plain English
7 min readOct 21, 2024

--

Generated using AI

Welcome back to our exploration of design patterns in JavaScript! In the previous parts of this series, we examined the Singleton and Factory patterns. Today, we will delve into the Observer Pattern — an essential tool for building scalable and maintainable applications from a backend developer’s perspective.

Understanding the Observer Pattern 🧐

Imagine you’re waiting for a parcel delivery. You don’t stand by the door all day; instead, you rely on notifications — maybe a text or an email — to let you know when it’s arriving. This way, you can go about your day and only respond when there’s something worth your attention.

In the programming world, the Observer Pattern works much the same way. It’s a behavioural design pattern that allows objects (observers) to subscribe to events or changes in another object (the subject). When the subject changes, it notifies all its observers, keeping everything in sync without tight coupling.

Why is this cool? Because it lets different parts of your application communicate without needing to know the inner workings of each other. It’s like having a well-organized group chat where everyone gets updates without unnecessary chatter.

In production environments, this pattern helps decouple components, making your application more modular and easier to scale.

Why Use the Observer Pattern? 🎯

1. Decoupling Components

The Observer Pattern promotes loose coupling. Observers and subjects know as little as possible about each other, which makes your code more modular and easier to maintain.

2. Event Handling Made Easy

It’s perfect for situations where an object needs to notify others about changes. This is common in UI components, real-time data feeds, and any event-driven architecture.

Libraries like React and Vue.js utilize this pattern under the hood to update the UI when the state changes.

3. Scalability

As your application grows, adding new observers doesn’t require altering the subject. This makes scaling features much smoother.

Implementing the Observer Pattern in JavaScript 💻

Let’s roll up our sleeves and see how this works in code.

Simple Implementation

Here’s a basic way to implement the Observer Pattern using JavaScript classes.

class Subject {
constructor() {
this.observers = [];
}

subscribe(observer) {
this.observers.push(observer);
}

unsubscribe(observerToRemove) {
this.observers = this.observers.filter(observer => observer !== observerToRemove);
}

notify(data) {
this.observers.forEach(observer => observer.update(data));
}
}

class Observer {
constructor(name) {
this.name = name;
}

update(data) {
console.log(`${this.name} received: ${data}`);
}

}
// Usage
const subject = new Subject();

const observer1 = new Observer('Observer 1');
const observer2 = new Observer('Observer 2');

subject.subscribe(observer1);
subject.subscribe(observer2);

subject.notify('Hello Observers!');

// Output:
// Observer 1 received: Hello Observers!
// Observer 2 received: Hello Observers!

What’s happening here?

  • Subject Class: Maintains a list of observers and has methods to subscribe, unsubscribe, and notify them.
  • Observer Class: Each observer has an update method that gets called when the subject notifies them.
  • Usage: We create a subject and observers, subscribe to them, and then notify them with some data.

Using Node.js EventEmitter 🛠️

If you’re working with Node.js, you can leverage the built-in EventEmitter class to implement the Observer Pattern more efficiently.

const EventEmitter = require('events');

class Subject extends EventEmitter {}

const subject = new Subject();

subject.on('message', data => {
console.log(`Observer 1 received: ${data}`);
});

subject.on('message', data => {
console.log(`Observer 2 received: ${data}`);
});

subject.emit('message', 'Hello from EventEmitter!');

// Output:
// Observer 1 received: Hello from EventEmitter!
// Observer 2 received: Hello from EventEmitter!

Why use EventEmitter?

  • Built-In Support: Node.js provides this out of the box, making it straightforward to handle events.
  • Cleaner Code: It reduces the need to manually manage lists of observers.
  • Flexibility: You can easily add or remove event listeners as needed.

Implementing with TypeScript 📝

TypeScript adds type safety to our code, which can be a lifesaver in larger projects.

interface Observer {
update(data: any): void;
}

class Subject {
private observers: Observer[] = [];
subscribe(observer: Observer): void {
this.observers.push(observer);
}
unsubscribe(observerToRemove: Observer): void {
this.observers = this.observers.filter(observer => observer !== observerToRemove);
}
notify(data: any): void {
this.observers.forEach(observer => observer.update(data));
}
}

class ConcreteObserver implements Observer {
constructor(private name: string) {}
update(data: any): void {
console.log(`${this.name} received: ${data}`);
}
}

// Usage
const subject = new Subject();

const observer1 = new ConcreteObserver('Observer 1');
const observer2 = new ConcreteObserver('Observer 2');

subject.subscribe(observer1);
subject.subscribe(observer2);

subject.notify('Hello TypeScript Observers!');

Highlights:

  • Interfaces: Define contracts for your classes, ensuring they implement the required methods.
  • Type Safety: Catch errors at compile time, reducing runtime bugs.
  • Readability: Code becomes more self-documenting, which is great for team projects.

Real-World Use Cases 🌐

Building a Simple Event Bus

An event bus allows different parts of your application to communicate without knowing about each other. Here’s how you might implement one:

class EventBus {
constructor() {
this.events = {};
}

subscribe(eventName, listener) {
if (!this.events[eventName]) {
this.events[eventName] = [];
}
this.events[eventName].push(listener);
}

unsubscribe(eventName, listenerToRemove) {
if (!this.events[eventName]) return;

this.events[eventName] = this.events[eventName].filter(listener => listener !== listenerToRemove);
}

publish(eventName, data) {
if (!this.events[eventName]) return;

this.events[eventName].forEach(listener => listener(data));
}
}

// Usage
const eventBus = new EventBus();

function logData(data) {
console.log(`Received data: ${data}`);
}

eventBus.subscribe('dataEvent', logData);
eventBus.publish('dataEvent', 'This is a test event.');

// Output:
// Received data: This is a test event.

Why use an event bus?

  • Decoupling: Components don’t need to know about each other.
  • Scalability: Easily add new events and listeners.
  • Maintainability: Centralizes event management.

User Interface Updates

In frontend, frameworks like React or Angular, the Observer Pattern is at the heart of state management and UI updates. When the state changes, components observing that state re-render automatically. Using the Observer Pattern, we could have multiple components react to the real-time updates as they happened, keeping the UI responsive and accurate.

Best Practices 🌟

Clean Up Subscriptions

Neglecting to unsubscribe observers can lead to memory leaks, especially in long-running applications. This happens because the subject holds strong references to its observers, preventing them from being garbage collected — a problem known as the lapsed listener issue.

To prevent this, always unsubscribe observers when they’re no longer needed.

Example in a React component:

useEffect(() => {
subject.subscribe(observer);

return () => {
subject.unsubscribe(observer);
};
}, []);

Alternatively, consider using weak references if your environment supports them. By holding weak references to observers, the subject doesn’t prevent them from being garbage collected when they’re no longer in use.

Example using WeakRef:

class Subject {
constructor() {
this.observers = [];
}

subscribe(observer) {
this.observers.push(new WeakRef(observer));
}

notify(data) {
this.observers = this.observers.filter(ref => {
const observer = ref.deref();
if (observer) {
observer.update(data);
return true;
}
return false;
});
}
}

Handle Errors Gracefully

Ensure that a failing observer doesn’t break the notification process for others. Wrap the update calls in try-catch blocks.

notify(data) {
this.observers.forEach(observer => {
try {
observer.update(data);
} catch (error) {
console.error(`Observer failed: ${error}`);
}
});
}

Avoid Circular Dependencies

Be cautious not to create situations where subjects and observers depend on each other in a way that could cause infinite loops. This can happen if an observer’s update method triggers another notification.

To prevent this:

  • Implement checks to avoid recursive calls.
  • Use flags to indicate when an update is already in progress.

Common Pitfalls 🚫

1. Overcomplicating the Design

Keep your observer implementation as straightforward as possible to meet your application’s needs.

2. Ignoring Performance

Notifying a large number of observers can be resource-intensive. If performance becomes an issue:

  • Throttle Updates: Limit the frequency of notifications.
  • Batch Notifications: Combine multiple updates into a single notification.

3. The Lapsed Listener Problem

When the subject holds strong references to observers, it can prevent them from being garbage collected, leading to memory leaks. This is especially problematic in applications where observers are frequently created and destroyed.

Solution:

  • Use Weak References: If possible, have the subject hold weak references to observers.
  • Explicit Unsubscription: Ensure observers unsubscribe themselves when no longer needed.

4. Forgetting to Unsubscribe

In the hustle of development, it’s easy to overlook unsubscribing observers. This oversight can cause unexpected behaviour and memory issues.

Best Practice:

  • Implement a consistent strategy for managing subscriptions throughout your codebase.
  • Use lifecycle hooks (like componentWillUnmount in React) to handle unsubscriptions automatically.

When Should You Use the Observer Pattern? ❓

  • Event-Driven Architectures: When your application relies heavily on events and you need a clean way to manage them.
  • Real-Time Systems: When components need to react immediately to state changes.
  • Asynchronous Programming: When dealing with data that changes over time, like streams or WebSockets.
  • Modular Systems: When building applications with interchangeable components that need to stay in sync.

Alternatives to Consider 🛣️

Promises and Async/Await

While not a direct replacement, promises can handle asynchronous operations where you have a single result or error.

Reactive Programming Libraries

Libraries like RxJS offer powerful tools for handling streams of data and events, taking the Observer Pattern to the next level.

Message Queues

In distributed systems, services can subscribe to events from a message broker like RabbitMQ or Kafka, reacting to changes elsewhere in the system.

Related Patterns 🔄

  • Publish-Subscribe Pattern: An advanced version of the Observer Pattern that decouples the sender and receiver via a message broker.
  • Mediator Pattern: Introduces a mediator object to handle communication between multiple objects, promoting loose coupling.

Conclusion 🎉

The Observer Pattern is a powerful tool for building responsive and scalable applications. By understanding its implementation and best practices, you can create systems that are maintainable and efficient in production environments.

Whether you’re a backend developer handling microservices or a frontend developer managing UI state, the Observer Pattern has a place in your toolkit.

I trust that this deep dive has offered valuable insights into leveraging the Observer Pattern in your projects. If you have any experiences or questions about using this pattern, I encourage you to share them.

In the next instalment of this series, I will explore the Strategy Pattern — a design pattern that enables you to define a family of algorithms, encapsulate each one, and make them interchangeable at runtime. It’s a powerful tool for writing flexible and extensible code.

References 📖

Until next time, happy coding! 👩‍💻👨‍💻

Peace out!✌️

In Plain English 🚀

Thank you for being a part of the In Plain English community! Before you go:

--

--

Published in JavaScript in Plain English

New JavaScript and Web Development content every day. Follow to join our 3.5M+ monthly readers.

Written by Ketan Jakhar

everything backend | NodeJS | TypeScript | Blockchain

No responses yet

Write a response