compostate
v0.6.0-alpha.1
Published
Composable and reactive state management library
Downloads
39
Readme
compostate
Fine-grained reactivity library
Install
npm install --save compostate
yarn add compostate
pnpm add compostate
Concepts
Signals and Atoms
Signals and atoms are the main source of reactivity in compostate
. They are akin to "subjects" or "observables" in the Observer pattern. Signals and atoms holds values that can either be read or written.
import { signal, atom } from 'compostate';
// with signals
const [count, setCount] = signal(0);
// reading a signal
console.log('Count', count());
// writing to a signal
setCount(count() + 100);
// with atoms
const count = atom(0);
// reading an atom
console.log('Count', count());
// writing to an atom
count(count() + 100);
Effects
Effects are the "observers" of compostate
. When reading a signal or an atom inside effects, effects will automatically mark those as "dependencies", in which when these dependencies update values, effects will automatically re-evaluate.
import { signal, syncEffect } from 'compostate';
// Create a signal
const [count, setCount] = signal(0);
// Observe the signal
syncEffect(() => {
console.log('Count:', count()); // Logs 'Count: 0'
});
// Update the count
setCount(100); // Logs 'Count: 0' due to the effect
When effects re-evaluate, it reconstructs the tracked dependencies from scratch, and so conditional dependency can also be done.
syncEffect(() => {
if (someCond()) {
// Subscribe to signalA: this effect will only evaluate
// if signalA changes
doSomething(signalA());
} else {
// Subscribe to signalB: this effect will only evaluate
// if signalB changes
doOthers(signalB());
}
});
One can also use untrack
to prevent an effect from marking a signal as a dependency
import { untrack } from 'compostate';
syncEffect(() => {
// This effect will access `someSignal` w/o subscribing
const somePassiveSignal = untrack(() => someSignal());
});
syncEffect
runs synchronously with signal updates, but this might be undesirable in some cases. An alternative is effect
which has its evaluation deferred through time-slicing.
import { effect, atom } from 'compostate';
const greeting = atom('Hello');
const receiver = atom('Alexis');
effect(() => {
// Since the evaluation is deferred, this effect will only
// log after the synchronous code ends.
console.log(`${greeting()}, ${receiver()}!`);
});
greeting('Bonjour');
receiver('Compostate');
// At the end of this code, this logs 'Bonjour, Compostate!'
setTimeout(() => {
// The effect is now tracking greeting and receiver
// however like the code above, changes to the atoms
// would not synchronously re-evaluate the effect.
greeting('Hello');
receiver('Alexis');
// At the end of this callback, it logs 'Hello, Alexis!'
}, 1000);
Deriving signals
Signals and atoms can be composed into derived signals. The basic form of a derived signal uses nothing but a simple function.
const count = atom(0);
const squared = () => count() ** 2;
syncEffect(() => {
console.log(squared()); // 0
});
count(4); // 16
Normally this is useful but there arises a problem: a derived signal may return the same value but would still trigger a re-evaluation.
const message = atom('Hello');
const length = () => message().length;
syncEffect(() => {
console.log('Length:', length()); // Length: 5
});
message('Aloha') // Logs again with Length: 5
To fix this problem, computed
can be used in place of the derived signal.
import { computed } from 'compostate';
const message = atom('Hello');
const length = computed(() => message().length);
syncEffect(() => {
console.log('Length:', length()); // Length: 5
});
message('Aloha') // Logs nothing
message('Bonjour') // Length: 7
computed
keeps track of the previously returned value and compares it with the new one, deciding if it should re-evaluate its dependents.
Batching updates
Signals are cheap to write with, but synchoronous updates can be expensive. For example, if an effect subscribes to multiple signals, whose values are also updated synchronously, the effect may re-evaluate multiple times which is undesirable. The desired result should be for the effect to wait for all the signals to update, and then re-evaluate so that it only has to do it a single time.
compostate
provides batch
to group updates into a single flush.
import { syncEffect, atom, batch } from 'compostate';
const greeting = atom('Hello');
const receiver = atom('Alexis');
syncEffect(() => {
console.log(`${greeting()}, ${receiver()}!`); // 'Hello, Alexis!'
});
// Without batching
greeting('Bonjour'); // 'Bonjour, Alexis!'
receiver('Compostate'); // 'Bonjour, Compostate!'
// With batching
batch(() => {
greeting('Bonjour'); // Update deferred
receiver('Compostate'); // Update deferred
}); // 'Bonjour, Compostate!'
Do take note that in batching, writes are already applied, only the re-evaluation is deferred.
compostate
also provides unbatch
in case flushing updates synchronously is desirable.
Cleanups
compostate
provides onCleanup
which can be called inside tracking calls such as syncEffect
, computed
, etc.. Registered cleanup callbacks are evaluated before tracking call are re-evaluated. This is useful when performing side-effects like subscribing to event listeners or making requests.
import { onCleanup } from 'compostate';
syncEffect(() => {
const request = makeRequest(someSignal());
onCleanup(() => {
// When someSignal changes, make sure to cancel
// the current request.
request.cancel();
});
});
onCleanup
will also run if syncEffect
or effect
are stopped.
const stop = syncEffect(() => {
onCleanup(() => {
console.log('Stopped!');
});
});
// ...
stop();
Tracking calls are cleanup boundaries, and tracking calls are also cleaned up by their parent cleanup boundaries, so if, for example, an effect is declared inside another effect, the nested effect is stopped when the parent effect is also stopped.
const stop = syncEffect(() => {
syncEffect(() => {
onCleanup(() => {
console.log('Stopped!');
});
});
});
// ...
stop();
compostate
also provides batchCleanup
which is what all tracking calls uses under the hood. compostate
also provides unbatchCleanup
if automatic cleanup is undesired.
Error Boundaries
Like any other code, user code in effects and computations may throw an error. Normal try
-catch
won't work in compostate
since by the time a re-evaluation happen, the try block may have already been escaped.
To solve this problem, compostate
provides errorBoundary
and onError
.
import { errorBoundary, onError } from 'compostate';
errorBoundary(() => {
onError((error) => {
console.error(error);
});
// Whenever the effect re-evaluation throws
// the error boundary will be able to receive it.
effect(() => doSomeUnsafeWork());
});
If a given onError
throws an error on itself, the thrown error and the received error is forwarded to a parent errorBoundary
.
If there's a callback that runs outside or uncaptured by errorBoundary
(e.g. setTimeout
) and you want the errorBoundary
to capture it, you can use captureError
:
import { captureError } from 'compostate';
errorBoundary(() => {
onError((error) => {
console.error(error);
});
const capture = captureError();
// Whenever the effect re-evaluation throws
// the error boundary will be able to receive it.
setTimeout(() => {
try {
doSomething();
} catch (error) {
capture(error);
}
})
});
Context API
compostate
provides a way to inject values through function calls, effects and computations
import { contextual, createContext, writeContext, readContext } from 'compostate';
// Create a context instance with a default value
const message = createContext('Hello World');
function log() {
// Read the context value
console.log(readContext(message));
}
// Create a context boundary
contextual(() => {
// Write a context value
writeContext(message, 'Ohayo Sekai');
log(); // 'Ohayo Sekai'
});
Bindings
Coming Soon
- SolidJS
- Svelte
- Vue
License
MIT © lxsmnsyc