Make reactive variables and react to their changes.
Any reactive variables used inside an autorun function are registered (or
"tracked") as "dependencies" by autorun
, then any time those dependencies
change, the autorun re-runs.
We call this "dependency-tracking reactivity".
An autorun with multiple variables accessed inside of it will re-run any time
any of the accessed variables change:
Learn how dependency-tracking reactivity makes your code cleaner and more
concise compared to another more common pattern.
Click to expand.
Reactive computations (autoruns) are nice because it doesn't matter how we
group our variables (dependencies) within computations. What matters is we
write what we care about (expressions using our variables) without having
to think about how to wire reactivity up.
With an event-based pattern, in contrast, our code would be more verbose and less
convenient.
Looking back at our simple autorun for logging several variables,
autorun(() => {
console.log(firstName(), lastName(), age(), hairColor())
})
we will see that writing the same thing with some sort of event pattern is more verbose:
function log() {
console.log(firstName.value, lastName.value, age.value, hairColor.value)
}
firstName.on('change', log)
lastName.on('change', log)
age.on('change', log)
hairColor.on('change', log)
With this hypothetical event pattern, we had to share our logging function with
each event emitter in order to wire up the reactivity, having us write more
code. Using autorun
was simpler and less verbose.
Now let's say we want to add one more item to the console.log
statement.
Here is what that looks like with an autorun:
autorun(() => {
console.log(firstName(), lastName(), age(), hairColor(), favoriteFood())
})
With an event emitter pattern, there is more to do:
function log() {
console.log(firstName.value, lastName.value, age.value, hairColor.value, favoriteFood.value)
}
firstName.on('change', log)
lastName.on('change', log)
age.on('change', log)
hairColor.on('change', log)
favoriteFood.on('change', log)
Not only is the event pattern more verbose, but it is more error prone because
we can forget to register the event handler: we had to modify the code in two
places in order to add logging of the favoriteFood
value.
Here's where it gets interesting!
Reactive computations allow us to decouple the reactivity implementation from
places where we need reactivity, and to focus on the code we want to write.
Let's say we want to make a class with properties, and abserve any of them when
they change.
First, let's use a familiar event pattern to show the less-than-ideal scenario first:
class EventEmitter {
addEventHandler(eventName, fn) {
}
removeEventHandler(eventName, fn) {
}
emit(eventName, data) {
}
}
Now let's use EventEmitter
to make a class whose poperties we can observe the
changes of. In the following class, we'll make getter/setter pairs so that any
time a setter is used to set a value, it will emit a "change" event.
import {EventEmitter} from 'events'
class Martian extends EventEmitter {
_firstName = ''
get firstName() {
return this._firstName
}
set firstName(v) {
this._firstName = v
this.emit('change', 'firstName')
}
_lastName = ''
get lastName() {
return this._lastName
}
set lastName(v) {
this._lastName = v
this.emit('change', 'lastName')
}
_age = 0
get age() {
return this._age
}
set age(v) {
this._age = v
this.emit('change', 'age')
}
_hairColor = ''
get hairColor() {
return this._hairColor
}
set hairColor(v) {
this._hairColor = v
this.emit('change', 'hairColor')
}
_favoriteFood = ''
get favoriteFood() {
return this._favoriteFood
}
set favoriteFood(v) {
this._favoriteFood = v
this.emit('change', 'favoriteFood')
}
}
const martian = new Martian()
The following shows how we would react to changes in three of the five properties of a Martian
:
martian.addEventHandler('change', property => {
if (['firstName', 'hairColor', 'favoriteFood'].includes(property)) {
console.log(martian.firstName, martian.hairColor, martian.favoriteFood)
}
})
It works, but we can still make this better while still using the same event
pattern.
Let's say we want to make it more efficient: instead of all event handlers
being subscribed to a single change
event (because Martians probably have
lots and lots of properties) and filtering for the properties we care to
observe, we can choose specific event names for each property and subscribe
handlers to specific property events:
import {EventEmitter} from 'events'
class Martian extends EventEmitter {
_firstName = ''
get firstName() {
return this._firstName
}
set firstName(v) {
this._firstName = v
this.emit('change:firstName')
}
_lastName = ''
get lastName() {
return this._lastName
}
set lastName(v) {
this._lastName = v
this.emit('change:lastName')
}
_age = 0
get age() {
return this._age
}
set age(v) {
this._age = v
this.emit('change:age')
}
_hairColor = ''
get hairColor() {
return this._hairColor
}
set hairColor(v) {
this._hairColor = v
this.emit('change:hairColor')
}
_favoriteFood = ''
get favoriteFood() {
return this._favoriteFood
}
set favoriteFood(v) {
this._favoriteFood = v
this.emit('change:favoriteFood')
}
}
We can now avoid the overhead of the array filtering we previously had with the .includes
check:
const martian = new Martian()
const onChange = () => {
console.log(martian.firstName, martian.hairColor, martian.favoriteFood)
}
martian.addEventHandler('change:firstName', onChange)
martian.addEventHandler('change:hairColor', onChange)
martian.addEventHandler('change:favoriteFood', onChange)
This is better than before because now if other properties besides the ones
we've subscribed to change, the event pattern won't be calling our function
needlessly and we won't be doing property name checks every time.
We can still do better with the event pattern! (Spoiler: it won't get as clean
as with autorun
below, which we'll get to next.)
We can come up with an automatic event-wiring mechanism. It could look
something like the following:
import {EventEmitter, WithEventProps} from 'events'
const Martian = WithEventProps(
class Martian extends EventEmitter {
static eventProps = ['firstName', 'lastName', 'age', 'hairColor', 'favoriteFood']
firstName = ''
lastName = ''
age = 0
hairColor = ''
favoriteFood = ''
},
)
const martian = new Martian()
const onChange = () => {
console.log(martian.firstName, martian.hairColor, martian.favoriteFood)
}
martian.addEventHandler('change:firstName', onChange)
martian.addEventHandler('change:hairColor', onChange)
martian.addEventHandler('change:favoriteFood', onChange)
That is a lot shorter already, but we can still do better! (It still won't be
as simple as with dependency-tracking reactivity, which is coming up.)
We can make the event pattern more
DRY ("Don't Repeat
Yourself") using decorators to allow us to be less repetitive:
import {EventEmitter, emits} from 'events'
@emits
class Martian extends EventEmitter {
@emits firstName = ''
@emits lastName = ''
@emits age = 0
@emits hairColor = ''
@emits favoriteFood = ''
}
const martian = new Martian()
const onChange = () => {
console.log(martian.firstName, martian.hairColor, martian.favoriteFood)
}
martian.addEventHandler('change:firstName', onChange)
martian.addEventHandler('change:hairColor', onChange)
martian.addEventHandler('change:favoriteFood', onChange)
This is better than before because now we didn't have to repeat the property
names twice, reducing the chance of errors from mismatched names. Instead we
labeled them all with a decorator.
We can still do better! 🤯
With LUME's reactive variables we can further decouple a class's implementation from
the reactivity mechanism and make things cleaner.
We can re-write the previous non-decorator example (and still not using
decorators) so that our class does not need to extend from a particular base
class to inherit a reactivity implementation:
import {variable, autorun} from '@lume/variable'
class Martian {
firstName = variable('')
lastName = variable('')
age = variable(0)
hairColor = variable('')
favoriteFood = variable('')
}
const martian = new Martian()
autorun(() => {
console.log(martian.firstName(), martian.hairColor(), martian.favoriteFood())
})
This is better than before because the reactivity is not an inherent part of
our class hierarchy, instead being a feature of the reactive variables. We can
use this form of reactivity in our Matrian
class or in any other class
without having class inheritance requirements, and other developers do not have
to make subclasses of our classes just to have reactivity.
Plus, we did not need to subscribe an event listener to specific
events like we did earlier with the addEventHandler
calls. Instead, we
wrapped our function with autorun
and it became a "reactive computation" with
the ability to re-run when its dependencies (the reactive variables used within
it) change.
...We can still do better! 🤯...
Using LUME's decorators, the experience is as good as it gets:
import {variable, autorun, reactive} from '@lume/variable'
@reactive
class Martian {
@reactive firstName = ''
@reactive lastName = ''
@reactive age = 0
@reactive hairColor = ''
@reactive favoriteFood = ''
cryogenesis = false
}
const martian = new Martian()
autorun(() => {
console.log(martian.firstName, martian.hairColor, martian.favoriteFood, martian.cryogenesis)
})
This is better than before because now we can use the properties like regular
properties instead of having to call them as functions to read their values
like we had to in the prior example. We can write this.age
instead of
this.age()
for reading a value, and this.age = 10
instead of this.age(10)
for writing a value.
Dependency-tracking reactivity makes things nice and concise.
A decorator that makes properties in a class reactive. Besides decorating
properties with the decorator, also be sure to decorate the class that shall
have reactive variable with the same decorator as well.