Skip to content
This repository has been archived by the owner on Nov 22, 2023. It is now read-only.
/ variable Public archive

DEPRECATED, use https://solidjs.com and https://github.com/lume/classy-solid directly instead. Create reactive variables and observe their changes in a simple and concise way with less code and less coupling.

License

Notifications You must be signed in to change notification settings

lume/variable

Repository files navigation

DEPRECATED, use solid-js and classy-solid directly instead

@lume/variable

Make reactive variables and react to their changes.

npm install @lume/variable --save

React to changes in reactive variables

In the following we make a reactive variable count, increment its value every second, and re-run a piece of code that logs the value to the console on each change:

import {variable, autorun} from '@lume/variable'

const count = variable(0)

setInterval(() => count(count() + 1), 1000)

autorun(() => {
	// Log the count variable any time it changes.
	console.log(count())
})

The function passed into autorun (sometimes referred to as "an autorun" or "a computation") automatically re-runs every second due to count being incremented every second.

Calling count() gets the current value, while calling count(123) with an arg sets the value. Thus count(count() + 1) increments the value.

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:

autorun(() => {
	// Log these variables every time any of them change.
	console.log(firstName(), lastName(), age(), hairColor())
})

autoruns can be grouped in any way we like:

autorun(() => {
	// This re-runs only when firstName or lastName have changed.
	console.log(firstName(), lastName())
})

autorun(() => {
	// This re-runs only when age or hairColor have changed.
	console.log(age(), hairColor())
})

If we wish to stop an autorun from re-running, we can call its returned stop function (note, this is not necessary if we or the JS engine no longer have references to any of the reactive variables that are dependencies of the autorun, and in that case everything will be garbage collected and will no longer re-run):

import {variable, autorun} from '@lume/variable'

const count = variable(0)

setInterval(() => count(count() + 1), 1000)

const stop = autorun(() => {
	// Log the count variable any time it changes.
	console.log(count())
})

// Stop the autorun (and therefore no more logging will happen) after 5 seconds:
setTimeout(stop, 5000)

Power and Simplicity

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(() => {
	// Log these variables every time any of them change.
	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() {
	// Log these variables every time any of them change.
	console.log(firstName.value, lastName.value, age.value, hairColor.value)
}

// We need to also register an event handler for each value we care to react to:
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(() => {
	// Log these variables every time any of them change.
	console.log(firstName(), lastName(), age(), hairColor(), favoriteFood())
})

With an event emitter pattern, there is more to do:

function log() {
	// Log these variables every time any of them change.
	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) // <-------- Don't forget to add this line too!

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:

// Let's say this is in a lib called 'events'.
class EventEmitter {
	addEventHandler(eventName, fn) {
		/*...use imagination here...*/
	}
	removeEventHandler(eventName, fn) {
		/*...use imagination here...*/
	}
	emit(eventName, data) {
		/*...use imagination here...*/
	}
}

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'

// We need to extend from EventEmitter (or compose it inside the class, but the amount
// of code would be similar).
class Martian extends EventEmitter {
	_firstName = ''
	get firstName() {
		return this._firstName
	}
	set firstName(v) {
		this._firstName = v
		this.emit('change', 'firstName') // Emit any time the property is set.
	}

	_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)) {
		// Log these three variables every time any of the three change.
		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') // Emit a specific event for the firstName property.
	}

	_lastName = ''
	get lastName() {
		return this._lastName
	}
	set lastName(v) {
		this._lastName = v
		this.emit('change:lastName') // Similar for the lastName property.
	}

	_age = 0
	get age() {
		return this._age
	}
	set age(v) {
		this._age = v
		this.emit('change:age') // And so on.
	}

	_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 = () => {
	// Log these three variables every time any of the three change.
	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'

// Imagine `WithEventProps` wires up events for any properties specified in a
// static `eventProps` field:
const Martian = WithEventProps(
	class Martian extends EventEmitter {
		static eventProps = ['firstName', 'lastName', 'age', 'hairColor', 'favoriteFood']

		firstName = ''
		lastName = ''
		age = 0
		hairColor = ''
		favoriteFood = ''
	},
)

// Listen to events as before:

const martian = new Martian()

const onChange = () => {
	// Log these three variables every time any of the three change.
	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'

// Imagine this `@emits` decorator wires up an event for each decorated property.
@emits
class Martian extends EventEmitter {
	@emits firstName = ''
	@emits lastName = ''
	@emits age = 0
	@emits hairColor = ''
	@emits favoriteFood = ''
}

// Listen to events as before:

const martian = new Martian()

const onChange = () => {
	// Log these three variables every time any of the three change.
	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'

// This class does not extend from any base class. Instead, reactive variables
// are defined inside the class.
class Martian {
	firstName = variable('')
	lastName = variable('')
	age = variable(0)
	hairColor = variable('')
	favoriteFood = variable('')
}

const martian = new Martian()

autorun(() => {
	// Log these three variables every time any of the three change.
	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'

// Now we mark the class and properties as reactive with the `@reactive` decorator.
@reactive
class Martian {
	@reactive firstName = ''
	@reactive lastName = ''
	@reactive age = 0
	@reactive hairColor = ''
	@reactive favoriteFood = ''

	// This property is not reactive, as it is not marked with `@reactive`.
	cryogenesis = false
}

const martian = new Martian()

autorun(() => {
	// Log these four variables every time any of the first three change. Note
	// that this will not automatically rerun when cryogenesis changes because cryogenesis
	// is not reactive.
	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.

API

const myVar = variable(value)

Creates a reactive variable with an initial value. The return value is a function that

  • when called with no argument, returns the reactive variable's value, f.e. myVar().
  • when called with an argument, sets the reactive variable's value, f.e. myVar(newValue).
const foo = variable(false)
const bar = variable(123)

const stop = autorun(fn)

Takes a function fn and immediately runs it, while tracking any reactive variables that were used inside of it as dependencies. Any time those variables change, fn is executed again. Each time fn re-runs, dependencies are re-tracked, which means that conditional branching within fn can change which dependencies will re-run fn next time.

autorun returns a stop function that when called causes fn never to be automatically executed again. This is useful when you no longer care about some variables.

autorun(() => {
	if (foo()) doSomethingWith(bar())
})

@reactive

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.

@reactive
class Car {
	@reactive engineOn = false
	@reactive sound = 'vroom'
}

const car = new Car()

autorun(() => {
	// Any time the car turns on, it makes a sound.
	if (car.engineOn) console.log(car.sound)
})