Reactive objects, computed properties and watchers inspired by Vue.js Composition API.
We are working hard to bring you a production-ready library as soon as possible ⛏️
The reason behind Reaction.js is to provide a way to use reactive models, computed properties and watchers in non-Vue/React/Angular environments.
The scope of this library has nothing to do with UI. It doesn't provide a way to bind your model to de UI. However, you can achieve some kind of binding using watchers (see watch() method) and event listeners.
Install the latest stable version:
npm install --save @nestorrente/reactionjs
Then you can import Reaction.js methods in your modules:
import {ref, reactive, computed, watch, nextTick} from '@nestorrente/reactionjs';
// ...or import all within an object
import * as Reaction from '@nestorrente/reactionjs';
You can download the latest version from here. Then, you can use it as any other JavaScript file:
<script src="reaction.bundle.js"></script>
Or, if you prefer, you can use any of the following CDN repositories:
<!-- Unpkg -->
<script src="https://unpkg.com/@nestorrente/[email protected]"></script>
<!-- JsDelivr -->
<script src="https://cdn.jsdelivr.net/npm/@nestorrente/[email protected]"></script>
The script will create a global Reaction
object, which contains all the exported methods.
import {ref, reactive, computed, watch, nextTick} from '@nestorrente/reactionjs';
const trainersName = ref('Ash');
const pokemon = reactive({
name: 'Pikachu',
level: 5
});
const nextLevel = computed(() => pokemon.level + 1);
You can invoke any method just by doing Reaction.methodName()
:
const trainersName = Reaction.ref('Ash');
const pokemon = Reaction.reactive({
name: 'Pikachu',
level: 5
});
const nextLevel = Reaction.computed(() => pokemon.level + 1);
You can also use ES6 destructuring assignment in order to imitate module imports:
const {ref, reactive, computed, watch, nextTick} = Reaction;
const trainersName = ref('Ash');
const pokemon = reactive({
name: 'Pikachu',
level: 5
});
const nextLevel = computed(() => pokemon.level + 1);
function ref<T>(value: T): Ref<T>
Creates a reactive object representing a single value:
const name = ref('Pikachu');
console.log(name.value); // prints "Pikachu"
name.value = 'Charizard';
console.log(name.value); // prints "Charizad"
function reactive<T>(object: T): T
Creates a reactive object with multiple properties:
const pokemon = reactive({
name: 'Pikachu',
level: 5,
stats: {
attack: 13,
defense: 8,
speed: 17,
special: 11
},
moves: [
'Thunder Shock',
'Growl'
]
});
console.log(pokemon.level); // prints 5
console.log(pokemon.stats.attack); // prints 13
pokemon.level += 1;
console.log(pokemon.level); // prints 6
It's also possible to use a reference as the value of a property. When getting the value of a reactive object's property, references are automatically unwrapped (as Vue.js does):
const name = ref('Pikachu');
const pokemon = reactive({
name, // <-- the reference
level: 5
});
// Access using the reference
console.log(name.value); // prints "Pikachu"
// Access through the reactive object (no '.value' is needed)
console.log(pokemon.name); // prints "Pikachu"
// Modifications made to the reference affect the object's property, and vice versa
name.value = 'Charizard';
console.log(pokemon.name); // prints "Charizard"
pokemon.name = 'Mewtwo';
console.log(name.value); // prints "Mewtwo"
Reaction.js can only observe changes in JavaScript plain objects. This means it can't observe changes made to complex types like Date, Array, Map, Set, WeakMap or WeakSet. However, you can use an immutable approach in order to achieve reactivity. We strongly recommend you to use the great Immutable.js library for this purpouse 🙂
For those who doesn't want to add another library to their projects, here we show you some vanilla JS immutability examples for Date and Array objects:
Date
Example data object:
const pokemon = reactive({
name: 'Pikachu',
dateOfCapture: new Date(2020, 0, 1)
});
Don't do this 👎
pokemon.dateOfCapture.setMonth(2);
Do this instead 👍
// Clone the Date object
const newDateOfCapture = new Date(pokemon.dateOfCapture.getTime());
// Modify the new object
newDateOfCapture.setMonth(2);
// Set the new object as the value of the property
pokemon.dateOfCapture = newDateOfCapture;
Array
Example data object:
const pokemon = reactive({
name: 'Pikachu',
moves: [
'Thunder Shock',
'Growl'
]
});
Don't do this 👎
// Append one element at the end
pokemon.moves.push('Tail Whip');
// Modify one element by index
pokemon.moves[index] = 'Thunder';
// Remove the last element
pokemon.moves.pop();
Do this instead 👍
// Append one element at the end
pokemon.moves = [...pokemon.moves, 'Tail Whip'];
// Modify one element by index
pokemon.moves = [
...pokemon.moves.slice(0, index),
'Thunder',
...pokemon.moves.slice(index+1)
];
// Remove the last element
pokemon.moves = pokemon.moves.slice(0, -1);
Calling reactive()
returns a new object that is observed. Changes made on this object will be reflected on the original one:
const originalObject = {
name: 'Pikachu',
// ... other properties...
};
const reactiveObject = reactive(originalObject);
reactiveObject.name = 'Charizard';
console.log(originalObject.name); // prints "Charizard"
However, changes made directly on the original object will not tracked by the system – this implies that computed properties as watchers will not work as expected. The recommendation is to not store the original object and always use the one returned by reactive()
:
// Don't do this 👎
const pokemon = {
name: 'Pikachu'
};
reactive(pokemon);
// Do this instead 👍
const pokemon = reactive({
name: 'Pikachu'
});
function computed<T>(callback: () => T): Readonly<Ref<T>>
This method creates a read-only reference whose value is the result of invoking the callback
function. It's value is automatically invalidated and recomputed when any of its dependencies change:
const pokemon = reactive({
name: 'Pikachu'
});
const upperCaseName = computed(() => pokemon.name.toUpperCase());
console.log(upperCaseName.value); // prints "PIKACHU"
pokemon.name = 'Charizard'; // old value is invalidated
console.log(upperCaseName.value); // value is recomputed, and "CHARIZARD" is printed
You can also use a reference as a dependency:
const name = ref('Pikachu');
const upperCaseName = computed(() => name.value.toUpperCase());
console.log(upperCaseName.value); // prints "PIKACHU"
name.value = 'Charizard';
console.log(upperCaseName.value); // prints "CHARIZARD"
If you try to modify a computed property, you will get an error:
upperCaseName.value = 'MEWTWO'; // Error: Cannot modify the value of a readonly reference
function watch<T>(source: Ref<T> | () => T,
callback: WatcherCallBack<T>,
options?: WatchOptions): StopHandle;
function watchEffect(callback: SimpleEffect): StopHandle;
Related types:
type WatcherCallBack<T> = (newValue: T, oldValue: T | undefined, onCleanup: CleanupRegistrator) => void;
type SimpleEffect = (onCleanup: CleanupRegistrator) => void;
type CleanupRegistrator = (invalidate: () => void) => void;
type StopHandle = () => void;
interface WatchOptions {
immediate?: boolean;
}
These methods allow you to define a watcher function that will be executed every time one of it's dependencies changes. You can define its dependencies explicitly using the source
parameter of the watch()
method, or let Reaction.js to track them for you using the watchEffect()
method.
Watchers are executed asynchronously. This means that you can do several data modifications in a row before any watcher is executed. If you want to wait for watcher's execution before continue, you can use the nextTick()
function.
We will cover watcher's features in an incremental way.
Let's define some data:
const pokemon = reactive({
name: 'Pikachu',
level: 5,
stats: {
attack: 13,
defense: 8,
speed: 17,
special: 11
},
});
Now, let's define a watcher that allows us to do some operations when the Pokemon's level changes:
watchEffect(onCleanup => {
const {name, level, stats} = pokemon;
// Show info message
console.log(`${name} grew to level ${level}`);
// Update stats
stats.attack += 3;
stats.defense += 2;
stats.speed += 4;
stats.special += 2;
});
As soon as the watcher has been created, its callback is executed for the first time in order to track its dependencies. As you can see, the callback reads some properties from the pokemon
object (name
, level
and stats
). As we didn't define the dependencies of the watcher explicitly, every property accessed within the callback is considered a dependency. This means that the callback will be executed every time that name
, level
or any of the stats
changes. What if we want the callback to execute only on level
property changes? We must define a source using the watch()
method.
Note: you may have noticed the onCleanup
callback parameter. We will cover it in the CleanupRegistrator section.
watch()
method allows you to define a source, which can be a reference or a callback, in order to specify the dependencies of the watcher.
Let's rewrite the previous example in order to define the level
property as the only dependency of the callback:
watch(
// Source callback
() => pokemon.level,
// Execution callback
(newValue, oldValue, onCleanup) => {
const {name, stats} = pokemon;
// Show info message
console.log(`${name} grew to level ${newValue}`);
// Update stats
stats.attack += 3;
stats.defense += 2;
stats.speed += 4;
stats.special += 2;
}
);
This way, the watcher will ignore changes made on the other properties, and will execute its callback only on level
property changes.
Note 1: every property accessed within the source callback is considered a dependency, no matter if it's returned by the callback or not.
Note 2: when using a source in order to define the watcher's dependencies, its callback is not executed until a change is made. If you want Reaction.js to execute the callback immediately, you can use the immediate
option:
watch(
// Source callback
() => pokemon.level,
// Execution callback
(newValue, oldValue, onCleanup) => {
// ... do something...
},
// Force the first execution of the callback
{ immediately: true }
);
As you can see, the execution callback now receives 2 more parameters:
newValue
: the new value of the dependency*.oldValue
: the previous value of the dependency*. In the first watcher's execution, its value isundefined
.onCleanup
: we will cover it in the CleanupRegistrator section.
*: when using a callback as the source of the watcher, the concept value of the dependency refers to the value returned by the callback.
Also, when your dependency is a reference, you can use the reference itself as the source of a watcher:
const nextLevel = computed(() => pokemon.level + 1);
watch(
// this is equivalent to: () => nextLevel.value
nextLevel,
(newValue, oldValue, onCleanup) => {
const {name} = pokemon;
console.log(`${name}'s next level is ${newValue}`);
}
);
Finally, if you want to define multiple dependencies, you can return an object or array containing all of them in the source callback:
watch(
// Source callback - define multiple dependencies by returning an object
() => {
const {attack, defense} = pokemon.stats;
return {attack, defense};
},
// Execution callback - "newValue" and "oldValue" are now objects
(newValue, oldValue, onCleanup) => {
const {name} = pokemon;
console.log(`${name}'s attack changed from ${oldValue.attack} to ${newValue.attack}`);
console.log(`${name}'s defense changed from ${oldValue.defense} to ${newValue.defense}`);
}
);
watch(
// Source callback - define multiple dependencies by returning an array
() => [
pokemon.stats.attack,
pokemon.stats.defense
],
// Execution callback - "newValue" and "oldValue" are now arrays
(newValue, oldValue, onCleanup) => {
const {name} = pokemon;
console.log(`${name}'s attack changed from ${oldValue[0]} to ${newValue[0]}`);
console.log(`${name}'s defense changed from ${oldValue[1]} to ${newValue[1]}`);
}
);
If you have read the previous sections, you may noticed the onCleanup
parameter of the watcher's callback. This parameter is a function that allows you to register a cleanup callback that will be executed right before the next watcher's execution. You can use it to execute some cleanup operations.
const pokemonStatus = ref('poison');
watch(
pokemonStatus,
(newValue, oldValue, onCleanup) => {
console.log(`Status changed to '${newValue}'`);
onCleanup(() => console.log(`Status is not '${newValue}' anymore`));
},
{ immediate: true }
);
pokemonStatus.value = 'burn';
Console output will be:
"Status changed to 'poison'" // initial watcher's execution
"Status is not 'poison' anymore"
"Status changed to 'burn'"
You can register at most 1 cleanup callback. If you call onCleanup
multiple times, only the last callback will be registered:
const pokemonStatus = ref('poison');
watch(
pokemonStatus,
(newValue, oldValue, onCleanup) => {
console.log(`Status changed to '${newValue}'`);
// this will be ignored
onCleanup(() => console.log(`1st cleanup callback`));
// this will be executed
onCleanup(() => console.log(`2nd cleanup callback`));
},
{ immediate: true }
);
pokemonStatus.value = 'burn';
Console output will be:
"Status changed to 'poison'" // initial watcher's execution
"2nd cleanup callback"
"Status changed to 'burn'"
The StopHandle
object is a function returned by watch()
and watchEffect()
methods. You can call it whenever you want to stop a watcher – that is, prevent its future executions.
Fisrt, store the StopHandle
callback in a variable:
const stopWatcher = watchEffect(() => {
const {name} = pokemon;
console.log(`Pokemon's name changed to: ${name}`);
});
Whenever you want, you can invoke it in order to stop the watcher:
stopWatcher(); // watcher will not be executed anymore
This will trigger the cleanup callback registered in the last watcher's execution (if any).
Changes made in the same event cycle in which stopWatcher()
is called will not trigger the watcher's execution. In example:
pokemon.name = 'Charizard'; // this will not trigger the watcher's execution
stopWatcher();
You can know more about event cycles in the nextTick()
method section.
function nextTick(callback: () => void): void
Allows you to execute a portion of code in the next event cycle of the execution environment. This is actually the same as setTimeout(callback, 0)
.
This method is very useful when you are doing multiple data modifications and you want to wait for watcher's execution before continue:
const pokemon = reactive({
name: 'Pikachu',
level: 5
});
watch(() => {
const { name, level } = pokemon;
console.log(`${name} grew to level ${level}`);
});
pokemon.level = 6;
pokemon.level = 7;
pokemon.level = 8;
// Wait for watcher's execution...
nextTick(() => {
pokemon.level = 9;
pokemon.level = 10;
});
Console output will be:
"Pikachu grew to level 5" // initial watcher's execution
"Pikachu grew to level 8" // watcher's execution before nextTick() callback
"Pikachu grew to level 10" // watcher's execution after nextTick() callback