silmaril
Compile-time reactivity for JS
Install
npm install --save silmaril
yarn add silmaril
pnpm add silmaril
Features
- Compile-time reactivity
- Minimal reactive runtime
- Auto-memoization
- Stores
Requirement
Due to the compile-time nature of this library, it requires the use of Babel. silmaril
provides a Babel plugin under silmaril/babel
.
Usage
Basic reactivity
$$
defines the reactive boundary in your JS code. Any top-level variables (function-scoped) declared in $$
will be treated as “reactive” as possible. $
can be used to asynchronously react to variable changes.
Variable changes and reactions are only limited in $$
(even for nested $$
calls).
import { $$, $ } from 'silmaril';
$$(() => {
// Create a "reactive" variable
let count = 0;
// Log count for changes
$(console.log('Count: ', count));
function multiply() {
// Update count
count *= 100;
}
multiply();
// After some time, this code logs `Count: 100`.
});
$
will know which variables to track with, but it can only know if the variable is accessed in that same call.
import { $$, $ } from 'silmaril';
$$(() => {
// Create a "reactive" variable
let count = 0;
let prefix = 'Count';
function log(current) {
// `prefix` is not tracked
console.log(`${prefix}: `, current);
}
// This only tracks `count`
$(log(count));
});
$
can also accept a function expression, and has the same tracking capabilities.
$(() => {
// This tracks `count`
console.log('Count:', count);
});
$
will only run if the tracked variables have actually changed (except for the first run), which means that it has some “auto-memoization”.
Computed variables
If a reactive variable references another, the variable becomes computed, which means that it will re-evaluate everytime the referenced variables changes.
import { $$, $ } from 'silmaril';
$$(() => {
// Create a "reactive" variable
let count = 0;
// Create a "reactive" const variable.
const message = `Count: ${count}`;
// This only tracks `message`
$(console.log(message));
count = 100; // Logs 'Count: 100'
});
Updates on computed variables are synchronous.
import { $$ } from 'silmaril';
$$(() => {
let count = 0;
const message = `Count: ${count}`;
count = 100; // message = Count: 100
count = 200; // message = Count: 200
});
Computed variables are also writable if declared with let
.
import { $$, $sync } from 'silmaril';
$$(() => {
let count = 0;
let message = `Count: ${count}`;
$sync(console.log('Log', message)); // Log Count: 0
count = 100; // Log Count: 100
message = 'Hello World'; // Log Hello World
count = 200; // Log Count: 200
});
Lifecycles
onMount
onMount
can be used to detect once $$
has finished the setup.
import { $$, onMount } from 'silmaril';
$$(() => {
onMount(() => {
console.log('Mounted!');
});
console.log('Not mounted yet!');
});
onMount
can also be used in $
, $sync
, $composable
and computed variables.
onDestroy
$$
returns a callback that allows disposing the reactive boundary. You can use onDestroy
to detect when this happens.
import { $$, onDestroy } from 'silmaril';
const stop = $$(() => {
onDestroy(() => {
console.log('Destroyed!');
});
});
// ...
stop();
onDestroy
can also be used in $
, $sync
, $composable
and computed variables.
Synchronous tracking
$
is deferred by a timeout schedule which means that $
asynchronously reacts on variable updates, this is so that updates on variables are batched by default (writing multiple times synchronously will only cause a single asynchronous update).
$sync
provides synchronous tracking.
import { $$, $, $sync } from 'silmaril';
$$(() => {
// Create a "reactive" variable
let count = 0;
// Create a "reactive" const variable.
const message = `Count: ${count}`;
$sync(console.log('Sync', message)); // Logs "Sync Count: 0"
$(console.log('Async', message));
count = 100; // Logs "Sync Count: 100"
count = 200; // Logs "Sync Count: 200"
// After some time the code ends, logs "Async Count: 200"
});
Stores
Reactivity is isolated in $$
, but there are multiple ways to expose it outside $$
e.g. emulating event emitters, using observables, global state management, etc.
silmaril/store
provides a simple API for this, and $store
allows two-way (or one-way) binding for stores.
import { $$, $, $sync, $store } from 'silmaril';
import Store from 'silmaril/store';
// Create a store
const count = new Store(100);
// Subscribe to it
count.subscribe((current) => {
console.log('Raw Count:', current);
});
$$(() => {
// Bind the store to a reactive variable
let current = $store(count);
// `const` can also be used as an alternative
// for enforcing one-way binding
// Tracking the bound variable
$sync(console.log('Sync Count:', current));
$(console.log('Async Count:', current));
// Mutate the variable (also mutates the store)
current += 100;
// Logs
// Sync Count: 100
// Raw Count: 200
// Sync Count: 200
// Async Count: 200
});
$store
can accept any kind of implementation as long as it follows the following interface:
subscribe(callback: Function): Function
: accepts a callback and returns a cleanup callbackget()
: returns the current state of the storeset(state)
: optional, mutates the state of the store.
Composition
$composable
$composable
allows composing functions that can be used in $$
, $sync
, $
, another $composable
or computed variables.
import { $$, $sync, $composable, $store, onDestroy } from 'silmaril';
import Store from 'silmaril/store';
// Create a composable
const useSquared = $composable((store) => {
// Bind the input store to a variable
const input = $store(store);
// Create a store
const squaredStore = new Store(0);
// Make sure to cleanup the store
onDestroy(() => squaredStore.destroy());
// Update the store based on the bound input store
$sync(squaredStore.set(input ** 2));
// Return the store
return squaredStore;
});
$$(() => {
// Create a store
const store = new Store(0);
// Bind it
let input = $store(store);
// Track the value of the store
$sync(console.log('Value', input));
// Create a "squared" store based on the input store
// then bind it
const squared = $store(useSquared(store));
// Track the squared store
$sync(console.log('Squared', squared));
// Update the input store
input = 100;
// Logs
// Count: 0
// Count: 100
// Count: 200
});
$
and $sync
Both $
and $sync
behave much like $$
: variables become reactive, onMount
and onDestroy
can be used, same goes to other APIs.
import { $$, $, onDestroy } from 'silmaril';
$$(() => {
let y = 0;
$(() => {
let x = 0;
$(console.log(x + y));
onDestroy(() => {
console.log('This will be cleaned up when `y` changes');
});
x += 100;
});
y += 100;
});
Inspirations/Prior Art
License
MIT © lxsmnsyc