Paranormal Reactivity π»
I'm afraid it's a little bit late for Halloween, but I do have a spooky update for you.
Our beloved PubSub-based reactive system has two new features. They're designed to eliminate manual boilerplate, guarantee data integrity (even in asynchronous scenarios) and optimize performance.
The new key functionalities are:
- a method for making reactive properties: for a cleaner syntax and less boilerplate
- a method for batching notifications (and its related decorator): for maximum efficiency and predictability of the PubSub mechanism
Here are the details.
1. Zero boilerplate: makeReactivePropertiesβ
Until now, making a property reactive required using decorators or calling this.notify('propName') every time a value changed. While this is the responsibility of a reactive layer implementing a PubSub pattern, I realize it can be tedious and repetitive.
The new feature makeReactiveProperties eliminates this friction, allowing you to write your class properties naturally, delegating the complex notification setup to the framework's core unless you have very specific needs that require manual calls to notify.
Before (manual notification boilerplate)β
class MyStore extends PubSub {
counter: number = 0;
setCounter(value: number) {
this.counter = value;
this.notify("counter");
}
}
or, using the decorator
class MyStore extends PubSub {
counter: number = 0;
@Notifies("counter")
setCounter(value: number) {
this.counter = value;
}
}
or, equivalently
class MyStore extends PubSub {
private _counter: number = 0;
get counter() {
return this._counter;
}
set counter(value: number) {
this._counter = value;
this.notify("counter");
}
}
Each case has its own pros and cons, but they all require (in a way or another) to write a bit more code than we'd want to, when working with reactive properties. At least in the general case.
New syntaxβ
The GenericPubSub base class now offers a makeReactiveProperties method that leverages the robust Object.defineProperty internally at construction time to transform declared fields into self-notifying setters.
It doesn't replace the existing options (decorator and notify method), but it's a powerful alternative for the most common cases, and it's super-concise.
It's intended to be used inside the constructor, as the last instruction.
class MyStore extends PubSub {
counter: number = 0;
message: string = "Hello";
constructor() {
super();
// Transform the following keys into reactive properties
this.makeReactiveProperties('counter', 'message');
}
// Direct mutations, no explicit notify calls needed
increment() {
// The setter automatically calls this.notify('counter')
this.counter++;
}
}
This change results in a significantly improved Developer Experience (DX), allowing you to focus purely on business logic rather than reactivity plumbing.
2. Maximum efficiency: batchNotificationsβ
A critical issue in highly reactive systems is the "Notification Storm." If a method updates 10 reactive properties in rapid succession, the system sends 10 separate notifications, potentially triggering 10 redundant UI updates and wasting CPU cycles.
The new asynchronous method batchNotifications solves this by introducing a Batching system that is highly resilient to asynchronous operations and nesting.
How batching worksβ
- Reference counting: we use a reference counter to keep track of "open" batches.
- Support for async: every call to
batchNotificationsincrements the reference counter. Any subsequent call tothis.notify()(either manual or via reactive setters) only adds the modified key to apendingNotificationsset, if the reference counter is larger than zero. - Final flush: only when the last batch function completes (and the reference counter returns to zero), a notifications flush is executed. This clears the pending notifications set, sending a single notification for all unique modified keys.
This robust mechanism prevents race conditions caused by asynchronous interleaving (e.g., when an await yields control to the Event Loop).
Usage Exampleβ
async updateAll(data: { x: number, y: number }) {
// open a new batch: all notifications triggered inside will be batched
await this.batchNotifications(async () => {
// first mutation: 'x' is queued
this.x = data.x;
// suspension: the Event Loop can now run other code (e.g., another batch call)
await someAsyncOperation();
// second mutation: 'y' is queued
this.y = data.y;
});
// end of the batch: flush is executed and a single notification is sent for ['x', 'y']
}
Of course, this also supports conditional notifications:
async updateAll(data: { x: number, y: number }) {
await this.batchNotifications(async () => {
this.x = data.x;
if (someCondition) {
this.y = data.y;
}
});
// end of the batch:
// - if someCondition was met, "x" and "y" are notified
// - if someCondition was not met, only "x" is notified
}
@BatchNotifications decoratorβ
You can decorate any PubSub method with @BatchNotificationsΒ to automatically wrap its logics in a batchNotificationsΒ call.
So this:
@BatchNotifications()
updateProfileAndAge(name: string, age: number) {
// These reactive setters are internally queued
this.firstName = name;
this.age = age;
}
is equivalent to this:
async updateProfileAndAge(name: string, age: number) {
await this.batchNotifications(() => {
this.firstName = name;
this.age = age;
});
}
It works both with synchronous & asynchronous methods!
Full tech specs here
Handling async data exchange with batchNotificationsβ
Be mindful how to use batchNotificationsΒ vs @BatchNotifications!
β Wrong example
@BatchNotifications()
async getUserData(userId: string) {
this.isLoading = true;
try {
this.user = await this.user.get(userId);
this.error = null;
} catch (err) {
this.user = null;
this.error = err;
} finally {
this.isLoading = false;
}
// Single notification: ['isLoading', 'error', 'user']
}
Here, isLoading will be notified only once: at the end. So the UI will never be re-rendered to show isLoading = true.
β Correct example
async getUserData(userId: string) {
// first notification: 'isLoading'
this.isLoading = true; // this must stay outside the batch!
this.batchNotifications(() => {
try {
this.user = await this.user.get(userId);
this.error = null;
} catch (err) {
this.user = null;
this.error = err;
} finally {
this.isLoading = false;
}
})
// second notification: ['isLoading', 'user', 'error']
}
Final considerationsβ
use-less-react is rapidly moving from a simple proof-of-concept to a full-fledged architectural layer, aspiring to become a robust and clean solution for the View-Model communication layer.
While still an experimental library, I believe it's already showing its potential. Its core design is specifically focused on reconciling the unique reactivity requirements of the View with the complex data management needs of the Model.
Most importantly, the library is achieving this without creating contamination between the two layers, thereby strictly enforcing the Separation of Concerns (SoC) principle.
Noteβ
The abovesaid features were intended to be available from v0.7.0, but this version was deprecated because @BatchNotifications export was missing - my apologies.
Please update to v0.7.1.
Tech details here.
