DEV Community

Omri Luz
Omri Luz

Posted on

Implementing a Custom Reactive Library in Vanilla JS

Implementing a Custom Reactive Library in Vanilla JS

Introduction

In recent years, the demand for reactive programming paradigms has skyrocketed. Libraries and frameworks like React, Vue, and Svelte dominate the landscape, each tailored to enable developers to create dynamic user interfaces with a reactive data flow. However, the principles underlying these frameworks can be distilled into fundamentally simple concepts that can be implemented in vanilla JavaScript. This article aims to explore the implementation of a custom reactive library, examining its architecture, intricacies, and performance considerations.

Historical Context

The roots of reactive programming can be traced back to the late 1990s and early 2000s. Initially conceptualized for event-driven applications, reactive programming utilizes the Observer pattern that allows components (or observers) to subscribe to changes in data sources (or subjects).

Key Milestones

  • 1997: The Observer design pattern is formalized, providing the theoretical basis for reactive paradigms.
  • 2013: React library by Facebook introduces concepts that change how developers approach UI development.
  • 2014: Vue.js emerges, simplifying reactive binding.
  • 2018: Svelte gains popularity for combining compile-time with runtime features, ensuring excellent performance.

These milestones have led to the establishment of a reactive ecosystem where state management, data binding, and reactivity dominate the development practices.

Defining the Core Concepts of Reactivity

Before diving into library implementation, it is crucial to understand the core components involved in reactivity:

  1. State: The data that drives the application's UI.
  2. Reactivity: The ability of a system to respond to state changes automatically.
  3. Dependency Tracking: Mechanism to keep track of which values affect other values.
  4. Subscriptions/Observers: Functions or objects that 'observe' changes and react accordingly.

Building Blocks of the Reactive Library

To create a reactive library, we will need a robust architecture. Below are the essential components to include:

1. Reactive State Implementation

The heart of the reactive library revolves around a state management system. The key approach is to use Proxies for state management, which allows us to intercept state modifications.

class Reactive {
    constructor(initialState) {
        // Step 1: Setup the initial state
        this.state = new Proxy(initialState, {
            set: (target, key, value) => {
                target[key] = value;
                this.notify(); // Step 4: Notify subscribers
                return true;
            }
        });
        this.subscribers = new Set();
    }

    // Step 3: Subscribe to changes
    subscribe(callback) {
        this.subscribers.add(callback);
    }

    // Step 4: Notify all subscribers of a change
    notify() {
        this.subscribers.forEach(callback => callback(this.state));
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Creating Reactive Properties

Reactive properties allow the definition of getters and setters that auto-trigger updates without manually managing subscriptions.

class ReactiveProperty {
    constructor(initialValue) {
        this.value = initialValue;
        this.subscribers = new Set();
    }

    // Get the current value
    get() {
        return this.value;
    }

    // Set a new value
    set(newValue) {
        this.value = newValue;
        this.notify();
    }

    subscribe(callback) {
        this.subscribers.add(callback);
    }

    notify() {
        this.subscribers.forEach(callback => callback(this.value));
    }
}
Enter fullscreen mode Exit fullscreen mode

3. Combining Reactive State and Properties

The powerful aspect of our library enables nested reactivity and computed values. Here's how you can combine both classes:

class ReactiveStore {
    constructor(data) {
        this.reactiveProps = {};

        Object.keys(data).forEach(key => {
            this.reactiveProps[key] = new ReactiveProperty(data[key]);
        });
    }

    get state() {
        const state = {};
        for (const key in this.reactiveProps) {
            state[key] = this.reactiveProps[key].get();
        }
        return state;
    }
}
Enter fullscreen mode Exit fullscreen mode

Advanced Implementation Techniques

Now that the basics have been established, we can delve into advanced techniques that enhance the library's flexibility and performance.

Handling Nested Reactivity

class ReactiveArray {
    constructor(array) {
        this.items = observable(array);
    }

    push(value) {
        this.items.push(value);
        notify();
    }

    // More array methods (pop, shift, etc.) can be implemented similarly.
}

// Usage
const reactiveArray = new ReactiveArray([1, 2, 3]);
reactiveArray.push(4);
Enter fullscreen mode Exit fullscreen mode

Computed Properties

In reactive libraries, computed properties provide a way to derive values from the state rather than duplicating state.

class Computed {
    constructor(computeFn, dependencies) {
        this.computeFn = computeFn;
        this.dependencies = dependencies;
        this.value = this.compute();

        // Subscribe to related properties
        dependencies.forEach(dep => {
            dep.subscribe(() => {
                this.value = this.compute();
            });
        });
    }

    compute() {
        return this.computeFn();
    }
}
Enter fullscreen mode Exit fullscreen mode

Observing the Dependencies

To facilitate an efficient dependency model:

const dependenciesMap = new WeakMap();

function observe(property, callback) {
    if (!dependenciesMap.has(property)) {
        dependenciesMap.set(property, new Set());
    }
    dependenciesMap.get(property).add(callback);
}

// Updating notify method in ReactiveProperty
notify() {
    const callbacks = dependenciesMap.get(this);
    callbacks.forEach(cb => cb());
}
Enter fullscreen mode Exit fullscreen mode

Performance Considerations and Optimization Strategies

Batch Updates

Reducing the number of updates can significantly optimize performance. Implementing a batch update mechanism ensures that all state modifications during a single event loop iteration are processed at once.

class BatchReactive {
    constructor() {
        this.queue = [];
        this.flushing = false;
    }

    updateQueue(callback) {
        this.queue.push(callback);
        if (!this.flushing) {
            this.flushing = true;
            requestAnimationFrame(() => {
                this.flushing = false;
                this.queue.forEach(cb => cb());
                this.queue = [];
            });
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Debugging Techniques and Pitfalls

  1. Over-Subscription: Prevent memory leaks from improper handling of subscriptions. Always unsubscribe when appropriate.
  2. Deep vs Shallow Reactivity: Always assess if shallow reactivity suffices. Deep proxies come with performance overhead.
  3. Cyclic Dependencies: Avoid creating loops where observers notify each other causing infinite loops.

Advanced Debugging Techniques

  • Use console.log to observe state changes.
  • Implement middleware allowing side-effect actions upon state changes.
class Middleware {
    constructor(middlewareFunc) {
        this.middlewareFunc = middlewareFunc;
    }

    apply(state) {
        this.middlewareFunc(state);
    }
}
Enter fullscreen mode Exit fullscreen mode

Real-World Use Cases

Application Scenarios

  1. Dashboard Widgets: Real-time updates based on user interactions or external data feeds.
  2. Form Validation: Immediate feedback based on form field state changes.
  3. Data Visualization: Auto-updating graphs based on data points from the user.

Comparison with Other Techniques

  • Immutable State Libraries (e.g., Redux) require frequent object copying leading to potential performance overhead compared to mutable state management in reactivity.
  • Frameworks like Angular leverage a change detection strategy that can lead to performance hits when not optimally structured, whereas our library focuses on minimal updates through granular dependencies.

Conclusion

Building a custom reactive library in vanilla JavaScript armed with the knowledge of Vue, React, and Svelte provides a profound understanding of the reactive paradigm. From handling state changes to nested reactivity and computed properties, this comprehensive guide serves as both a starting point and a reference for senior developers looking to deepen their knowledge of the subject.

Further Reading

Implementing a robust reactive system requires a fine balance between flexibility, performance, and simplicity. This guide serves as your handbook for creating an efficient reactive library tailored for modern web development scenarios.

Top comments (5)

Collapse
 
wormss profile image
WORMSS

Just to note, you know you can use vue reactivity outside of vue right? It's completely independent of all of the templating and stuff.
Rather than importing from vue it's something like @vue/reactivity, so you can use all the ref/reactive/watcher/watchEffect/computed without having to try and handle all the .subscribe() stuff yourself.

Just use it in your own regular javascript projects with no build stage..

Collapse
 
dariomannu profile image
Dario Mannu

Always unsubscribe when appropriate.

Angular went this route but it didn't go well. A well-designed reactive library should really take care about subscriptions and unsubscriptions. Leaving that with developers is evil.

Collapse
 
biomathcode profile image
Pratik sharma

True, that what svelte has done better!

Collapse
 
dotallio profile image
Dotallio

Love how you broke down dependency tracking and batch updates - demystifies a lot of what happens under the hood in bigger frameworks.

I'm curious, do you see any big downsides to this approach if you wanted to scale up to a much larger app?

Collapse
 
nevodavid profile image
Nevo David

pretty cool digging into the guts like this tbh- gotta ask though, you ever think doing it yourself makes you see big frameworks way different after?