Observer pattern in TypeScript

Sometimes, you need to respond to changes in your application logic. For instance, you may want to be notified when the user's status changes from authorized to unauthorized - for example, when the user clicks the sign out button.

In scenarios like this, one of the most effective approaches to implement such behavior is by using a simple Observer pattern. You pass an object through, attach a function, and receive the unsubscribe function in return.

Remember that design patterns can be implemented in many different ways, just the general idea must be fulfilled - the mechanism of how it works.

Definition

The Observer pattern is a behavioral design pattern categorized under the Gang of Four's design patterns. It establishes a one-to-many dependency between objects, allowing automatic notification and updates to multiple dependents when the state of one object (the "subject") changes.

This pattern promotes loosely coupled relationships between subjects and observers, enhancing system flexibility. It's widely utilized for implementing distributed event handling systems, ensuring consistent and responsive updates throughout the application.

Implementation

// Helper types for Observer pattern implementation.
type Subscription = () => void;
type Unsubscribe = () => void;
// The "data" parameter is our "subject" from definition.
type Next<Data> = (data: Data) => void;

// Interface defining the API of the "Observer" pattern implementation.
interface Observable<Data> {
  // Subscribe to changes with a callback function.
  subscribe(next: Next<Data>): Unsubscribe;
  // Unsubscribe all listeners.
  unsubscribeAll(): void;
  // Get the current data snapshot.
  snapshot(): Data;
}

// Factory function to create an Observer instance.
const Observer = <Data>(initialData: Data): Observable<Data> => {
  // Data to pass through.
  let currentData = initialData;
  // Stores a mapping of unique IDs to callback functions.
  const subscriptions = new Map<string, Next<Data>>();

  return {
    // Subscribe to changes and return an unsubscribe function.
    subscribe: (next) => {
      const id = new Date().toISOString();

      subscriptions.set(id, next);

      // Unsubscribe function.
      const unsubscribe: Unsubscribe = () => {
        subscriptions.delete(id);
      };

      return unsubscribe;
    },
    // Unsubscribe all listeners.
    unsubscribeAll: () => subscriptions.clear(),
    // Update data and notify all subscribed functions.
    next: (data) => {
      // Set new data.
      currentData = data;
      // Notify all subscribed functions with the new data.
      subscriptions.forEach((sub) => {
        sub(currentData);
      });
    },
    // Get the current data snapshot.
    snapshot: () => currentData,
  };
};

Usage

// Create an Observer instance with initial data.
const user = Observer({ id: 1, username: "piotr1994" });

// Subscribe to changes and log data.
const unsubscribe = user.subscribe((data) => {
  // Log the data whenever the "next" function is called.
  console.log(data);
});

// Trigger a data update.
user.next({ id: 2, username: "piotr1994" });

// Log the latest data snapshot.
console.log(user.snapshot());

// Unsubscribe, and the callback inside "subscribe" will no longer be triggered.
unsubscribe();

// Remove all listeners that have been created.
user.unsubscribeAll();

Summary

The Observer pattern empowers you to decouple your application logic, creating a structure akin to an event bus. By passing a function, you ensure it's called each time data changes.

One widely adopted implementation of this pattern is the library RxJs, which refines it and expands capabilities by offering an API to add operators - similar to middleware attached to incoming handlers. However, delving into this aspect deserves its dedicated article.