It’s Time to Learn Them
Reactivity is essentially about how a system reacts to data changes and there are different types of reactivity. However, in this article, our focus is on reactivity in terms of taking actions in response to data changes.
As a front-end developer, I have to face it every single day. That’s because the browser itself is a fully asynchronous environment. Modern web interfaces must react quickly to user actions, and this includes updating the UI, sending network requests, managing navigation, and various other tasks.
While people often associate reactivity with frameworks, I believe that we can learn a lot by implementing it in pure JS. So, we are going to code patterns ourselves and also study some native browser APIs that are based on reactivity.
Table of Contents
- PubSub or Publish-Subscribe
- Custom Events as a Browser Version Of PubSub
- Custom Event Targets
- Observer
- Reactive Properties With Proxy
- Individual Object Properties and Reactivity
- Reactive HTML Attributes With MutationObserver
- Reactive Scrolling With IntersectionObserver
PubSub or Publish-Subscribe
PubSub is one of the most commonly used and fundamental reactivity patterns. The Publisher is responsible for notifying Subscribers about the updates and the Subscriber receives those updates and can react in response.
class PubSub {
constructor() {
this.subscribers = {};
}
subscribe(event, callback) {
if (!this.subscribers[event]) {
this.subscribers[event] = [];
}
this.subscribers[event].push(callback);
}
// Publish a message to all subscribers of a specific event
publish(event, data) {
if (this.subscribers[event]) {
this.subscribers[event].forEach((callback) => {
callback(data);
});
}
}
}
const pubsub = new PubSub();
pubsub.subscribe('news', (message) => {
console.log(`Subscriber 1 received news: ${message}`);
});
pubsub.subscribe('news', (message) => {
console.log(`Subscriber 2 received news: ${message}`);
});
// Publish a message to the 'news' event
pubsub.publish('news', 'Latest headlines: ...');
// console logs are:
// Subscriber 1 received news: Latest headlines: ...
// Subscriber 2 received news: Latest headlines: ...
One popular example of its usage is Redux. This popular state management library is based on this pattern (or more specifically, the Flux architecture). Things work pretty simple in the context of Redux:
- Publisher: The store acts as the publisher. When an action is dispatched, the store notifies all the subscribed components about the state change.
- Subscriber: UI Components in the application are the subscribers. They subscribe to the Redux store and receive updates whenever the state changes.
Custom Events as a Browser Version Of PubSub
The browser offers an API for triggering and subscribing to custom events through the CustomEvent class and the dispatchEvent method. The latter provides us with the ability not only to trigger an event but also to attach any desired data to it.
const customEvent = new CustomEvent('customEvent', {
detail: 'Custom event data', // Attach desired data to the event
});
const element = document.getElementById('.element-to-trigger-events');
element.addEventListener('customEvent', (event) => {
console.log(`Subscriber 1 received custom event: ${event.detail}`);
});
element.addEventListener('customEvent', (event) => {
console.log(`Subscriber 2 received custom event: ${event.detail}`);
});
// Trigger the custom event
element.dispatchEvent(customEvent);
// console logs are:
// Subscriber 1 received custom event: Custom event data
// Subscriber 2 received custom event: Custom event data
Custom Event Targets
If you prefer not to dispatch events globally on the window object, you can create your own event target.
By extending the native EventTarget class, you can dispatch events to a new instance of it. This ensures that your events are triggered only on the new class itself, avoiding global propagation. Moreover, you have the flexibility to attach handlers directly to this specific instance.
class CustomEventTarget extends EventTarget {
constructor() {
super();
}
// Custom method to trigger events
triggerCustomEvent(eventName, eventData) {
const event = new CustomEvent(eventName, { detail: eventData });
this.dispatchEvent(event);
}
}
const customTarget = new CustomEventTarget();
// Add an event listener to the custom event target
customTarget.addEventListener('customEvent', (event) => {
console.log(`Custom event received with data: ${event.detail}`);
});
// Trigger a custom event
customTarget.triggerCustomEvent('customEvent', 'Hello, custom event!');
// console log is:
// Custom event received with data: Hello, custom event!
Observer
The Observer pattern is really similar to PubSub. You subscribe to the Subject and then it notifies its subscribers (Observers) about changes, allowing them to react accordingly. This pattern plays a significant role in building decoupled and flexible architecture.
class Subject {
constructor() {
this.observers = [];
}
addObserver(observer) {
this.observers.push(observer);
}
// Remove an observer from the list
removeObserver(observer) {
const index = this.observers.indexOf(observer);
if (index !== -1) {
this.observers.splice(index, 1);
}
}
// Notify all observers about changes
notify() {
this.observers.forEach((observer) => {
observer.update();
});
}
}
class Observer {
constructor(name) {
this.name = name;
}
// Update method called when notified
update() {
console.log(`${this.name} received an update.`);
}
}
const subject = new Subject();
const observer1 = new Observer('Observer 1');
const observer2 = new Observer('Observer 2');
// Add observers to the subject
subject.addObserver(observer1);
subject.addObserver(observer2);
// Notify observers about changes
subject.notify();
// console logs are:
// Observer 1 received an update.
// Observer 2 received an update.
Reactive Properties With Proxy
If you want to react to changes in objects, Proxy is the way to go. It lets us achieve reactivity when setting or getting values of object fields.
const person = {
name: 'Pavel',
age: 22,
};
const reactivePerson = new Proxy(person, {
// Intercept set operation
set(target, key, value) {
console.log(`Setting ${key} to ${value}`);
target[key] = value;
// Indicates if setting value was successful
return true;
},
// Intercept get operation
get(target, key) {
console.log(`Getting ${key}`);
return target[key];
},
});
reactivePerson.name = 'Sergei'; // Setting name to Sergei
console.log(reactivePerson.name); // Getting name: Sergei
reactivePerson.age = 23; // Setting age to 23
console.log(reactivePerson.age); // Getting age: 23
Individual Object Properties and Reactivity
If you don’t need to track all the fields in the objects, you can choose the specific one using Object.defineProperty or group of them with Object.defineProperties.
const person = {
_originalName: 'Pavel', // private property
}
Object.defineProperty(person, 'name', {
get() {
console.log('Getting property name')
return this._originalName
},
set(value) {
console.log(`Setting property name to value ${value}`)
this._originalName = value
},
})
console.log(person.name) // 'Getting property name' and 'Pavel'
person.name = 'Sergei' // Setting property name to value Sergei
Reactive HTML Attributes With MutationObserver
One way to achieve reactivity in the DOM is by using MutationObserver. Its API allows us to observe changes in attributes and also in the text content of the target element and its children.
function handleMutations(mutationsList, observer) {
mutationsList.forEach((mutation) => {
// An attribute of the observed element has changed
if (mutation.type === 'attributes') {
console.log(`Attribute '${mutation.attributeName}' changed to '${mutation.target.getAttribute(mutation.attributeName)}'`);
}
});
}
const observer = new MutationObserver(handleMutations);
const targetElement = document.querySelector('.element-to-observe');
// Start observing the target element
observer.observe(targetElement, { attributes: true });
Reactive Scrolling With IntersectionObserver
The IntersectionObserver API enables reacting to the intersection of a target element with another element or the viewport area.
function handleIntersection(entries, observer) {
entries.forEach((entry) => {
// The target element is in the viewport
if (entry.isIntersecting) {
entry.target.classList.add('visible');
} else {
entry.target.classList.remove('visible');
}
});
}
const observer = new IntersectionObserver(handleIntersection);
const targetElement = document.querySelector('.element-to-observe');
// Start observing the target element
observer.observe(targetElement);
Thanks for reading!
I hope you found this article useful. If you have any questions or suggestions, please leave comments. Your feedback helps me to become better.