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:
- State: The data that drives the application's UI.
- Reactivity: The ability of a system to respond to state changes automatically.
- Dependency Tracking: Mechanism to keep track of which values affect other values.
- 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));
}
}
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));
}
}
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;
}
}
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);
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();
}
}
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());
}
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 = [];
});
}
}
}
Debugging Techniques and Pitfalls
- Over-Subscription: Prevent memory leaks from improper handling of subscriptions. Always unsubscribe when appropriate.
- Deep vs Shallow Reactivity: Always assess if shallow reactivity suffices. Deep proxies come with performance overhead.
- 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);
}
}
Real-World Use Cases
Application Scenarios
- Dashboard Widgets: Real-time updates based on user interactions or external data feeds.
- Form Validation: Immediate feedback based on form field state changes.
- 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)
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 theref/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..
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.
True, that what svelte has done better!
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?
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?