DEV Community

Omri Luz
Omri Luz

Posted on

Implementing a Custom Reactive Library in Vanilla JS

Implementing a Custom Reactive Library in Vanilla JS

In the modern web development landscape, reactive programming has gained significant traction as a means of managing asynchronous data flows and UI updates. Frameworks like React, Vue.js, and Svelte have popularized the idea of reactivity, allowing developers to create applications that efficiently respond to data changes. However, understanding the principles underlying these frameworks can provide valuable insights for creating your own. In this article, we will embark on an in-depth exploration of building a custom reactive library using vanilla JavaScript.

Historical and Technical Context

Reactive programming is not a new concept. Influences can be traced back to the 1960s with the advent of functional reactive programming (FRP) and has since evolved into the various paradigms we see today. The pivotal moment for the mainstream adoption of reactivity in JavaScript was the development of libraries and frameworks that abstracted the complexities of DOM manipulation and state management.

In particular, JavaScript frameworks utilize a virtual DOM, dependency graphs, and one-way data binding to create efficient, dynamic user interfaces. While libraries like React provide a rich set of features, they also come with a level of abstraction that may obfuscate underlying principles. By constructing our own reactive library from scratch, we will lay bare the foundational concepts and mechanics of reactivity.

Why Vanilla JS?

Building with vanilla JavaScript allows you to:

  • Gain a deep understanding of reactivity.
  • Avoid overhead associated with larger frameworks.
  • Create a lightweight solution tailored to specific needs.

Core Concepts of a Reactive System

Before delving into implementation, let's outline the core components of a reactive library:

  1. Observable: Represents the data source which can be subscribed to.
  2. Observer: A function or object that responds to data changes.
  3. Dependency Tracking: Mechanism to manage which observers depend on which data sources.
  4. Reactivity: The process of automatically updating observers when data changes.

Building Blocks of a Custom Reactive Library

1. Observable

The observable should store its value and maintain a list of observers.

class Observable {
    constructor(value) {
        this._value = value;
        this._observers = new Set();
    }

    get value() {
        return this._value;
    }

    set value(newValue) {
        this._value = newValue;
        this.notifyObservers();
    }

    addObserver(observer) {
        this._observers.add(observer);
    }

    removeObserver(observer) {
        this._observers.delete(observer);
    }

    notifyObservers() {
        this._observers.forEach(observer => observer(this._value));
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Observer Function

Observers are simply functions that will be executed whenever the observable's value changes.

const observer = (value) => {
    console.log('Value changed to:', value);
};
Enter fullscreen mode Exit fullscreen mode

3. Putting It Together

Now let’s create an observable instance and attach an observer to it.

const observableData = new Observable(0);
observableData.addObserver(observer);

// Trigger an update
observableData.value = 42; // Logs "Value changed to: 42"
observableData.value = 100; // Logs "Value changed to: 100"
Enter fullscreen mode Exit fullscreen mode

4. Advanced Implementation with Dependency Tracking

In a more complex scenario, we need to handle nested observables and ensure that observers are executed only when their dependencies change.

class Reactive {
    constructor(data) {
        this.data = data;
        this.observers = new Map();
        this.activeObserver = null;
    }

    registerObserver(key, observer) {
        if (!this.observers.has(key)) {
            this.observers.set(key, new Set());
        }
        this.observers.get(key).add(observer);
    }

    notifyObservers(key) {
        if (this.observers.has(key)) {
            this.observers.get(key).forEach(observer => observer(this.data[key]));
        }
    }

    get(key) {
        if (this.activeObserver) {
            this.registerObserver(key, this.activeObserver);
        }
        return this.data[key];
    }

    set(key, value) {
        this.data[key] = value;
        this.notifyObservers(key);
    }
}
Enter fullscreen mode Exit fullscreen mode

Example Usage

const reactiveData = new Reactive({ count: 0 });

const logCount = (newCount) => console.log('Count is:', newCount);
reactiveData.activeObserver = logCount;

reactiveData.set('count', 1); // Logs "Count is: 1"
reactiveData.set('count', 2); // Logs "Count is: 2"
reactiveData.activeObserver = null; // Clear active observer
Enter fullscreen mode Exit fullscreen mode

Edge Cases and Advanced Implementation Techniques

When implementing a reactive library, we must consider edge cases and advanced techniques.

1. Deleting Properties

Managing observers for dynamically added or deleted properties can be complex. Here’s how you might enhance the Reactive class:

set(key, value) {
    if (key in this.data) {
        this.data[key] = value;
        this.notifyObservers(key);
    } else {
        console.warn(`Property ${key} does not exist. Creating a new observable.`);
        this.data[key] = value;
        this.registerObserver(key, this.activeObserver);
        this.notifyObservers(key);
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Handling Array Observables

Reactively managing arrays requires careful consideration of operations like push, pop, etc. You can wrap arrays in a custom class:

class ObservableArray {
    constructor(array) {
        this.list = array;
        this.observers = new Set();
    }

    push(item) {
        this.list.push(item);
        this.notifyObservers();
    }

    notifyObservers() {
        this.observers.forEach(observer => observer(this.list));
    }

    addObserver(observer) {
        this.observers.add(observer);
    }
}
Enter fullscreen mode Exit fullscreen mode

3. Avoiding Recursive Updates

To avoid potential infinite loops while setting values, it's prudent to track if an update originated from an observer's notify call.

Performance Considerations and Optimization Strategies

Reactivity systems can become complex quickly, leading to performance bottlenecks. Below are several strategies to improve performance:

  1. Batch Updates: Group multiple changes and notify observers only once. This reduces the number of notifications and can greatly improve performance during rapid updates.
    batchNotify(observables) {
        const keys = [...observables.keys()];
        keys.forEach(key => this.notifyObservers(key));
    }
Enter fullscreen mode Exit fullscreen mode
  1. Throttling and Debouncing: When observers initiate actions that result in multiple updates, implement throttling or debouncing to limit the number of updates.

  2. Weak References: Consider using WeakMap for internal observer references, allowing for garbage collection of unused observers and freeing up memory.

Real-World Use Cases from Industry

  1. User Input Forms: Reactive libraries simplify the synchronization between input fields and their associated data models, leading to a more dynamic user experience with validations and updates.

  2. Data Visualization Libraries: Libraries like D3.js benefit from reactive models to refresh visualizations based on live data streams, easily reacting to new incoming data.

  3. Complex State Management in SPAs: Applications like e-commerce platforms often rely on reactive patterns to seamlessly manage cart state, inventory updates, and user interactions, enhancing user experience.

Debugging Techniques

Ensuring robustness in your custom library is critical. Here are techniques to improve debugging and error handling:

  1. Error Handling: Implement generic error-handling mechanisms within your methods to catch unforeseen issues.

  2. Verbose Logging: Introduce a logging mechanism that provides insights into data flow, mutations, and performance metrics.

  3. Stack Traces: Utilize Error().stack to track the origin of changes for smoother debugging.

  4. Unit Testing: Implement comprehensive unit tests focused on edge cases. Utilize libraries such as Mocha or Jest to ensure functional correctness.

Conclusion

In this article, we’ve delved deeply into the mechanics of creating a custom reactive library in vanilla JavaScript, exploring observables, observers, and various complexities of reactivity. From understanding historical development to implementation and performance strategies, the intricate layering of concepts provides a robust foundation for senior developers.

By studying existing frameworks and attempting to replicate their functionality, you gain invaluable insights into how they operate, allowing you to create efficient and succinct reactive solutions using vanilla JavaScript. This exploration empowers you to build applications tailored to your specific requirements without the overhead of external libraries, offering a clearer understanding of JavaScript's capabilities.

References

By anchoring your understanding of these concepts, you can build and optimize for more complex and efficient reactive systems. Welcome to the endless possibilities of reactive programming in JavaScript!

Top comments (0)