

Show HN: A tiny and fast reactive observables library via functions
source link: https://github.com/maverick-js/observables
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Observables
The goal of this library is to provide a lightweight reactivity API for other UI libraries to be built on top of. It follows the "lazy principle" that Svelte adheres to - don't do any unnecessary work and don't place the burden of figuring it out on the developer.
This is a tiny (~850B minzipped) library for creating reactive observables via functions. You
can use observables to store state, create computed properties (y = mx + b
), and subscribe to
updates as its value changes.
Light (~850B minzipped)
Works in both browsers and Node.js
All types are observable (i.e., string, array, object, etc.)
Only updates when value has changed
Batched updates via microtask scheduler
Lazy by default - efficiently re-computes only what's needed
Computations via
$computed
Effect subscriptions via
$effect
Detects cyclic dependencies
Debugging identifiers
Strongly typed - built with TypeScript
Here's a simple demo to see how it works:
Interact with the demo live on StackBlitz.
import { $observable, $computed, $effect, $tick } from '@maverick-js/observables';
// Create - all types supported (string, array, object, etc.)
const $m = $observable(1);
const $x = $observable(1);
const $b = $observable(0);
// Compute - only re-computed when `$m`, `$x`, or `$b` changes.
const $y = $computed(() => $m() * $x() + $b());
// Effect - this will run whenever `$y` is updated.
const stop = $effect(() => console.log($y()));
$m.set(10); // logs `10` inside effect
// Wait a tick so update is applied and effect is run.
await $tick();
$b.update((prev) => prev + 5); // logs `15` inside effect
// Wait a tick so effect runs last update.
await $tick();
// Nothing has changed - no re-compute.
$y();
// Stop running effect.
stop();
Export Sizes
Total: if you import everything it'll be ~850B.
You can also check out the library size on Bundlephobia (less accurate).
Installation
$: npm i @maverick-js/observables
$: pnpm i @maverick-js/observables
$: yarn add @maverick-js/observables
$observable
Wraps the given value into an observable function. The observable function will return the
current value when invoked fn()
, and provide a simple write API via set()
and update()
. The
value can now be observed when used inside other computations created with $computed
and $effect
.
import { $observable } from '@maverick-js/observables';
const $a = $observable(10);
$a(); // read
$a.set(20); // write (1)
$a.update((prev) => prev + 10); // write (2)
Warning Read the
$tick
section below to understand batched updates.
$computed
Creates a new observable whose value is computed and returned by the given function. The given compute function is only re-run when one of it's dependencies are updated. Dependencies are are all observables that are read during execution.
import { $observable, $computed, $tick } from '@maverick-js/observables';
const $a = $observable(10);
const $b = $observable(10);
const $c = $computed(() => $a() + $b());
console.log($c()); // logs 20
$a.set(20);
await $tick();
console.log($c()); // logs 30
$b.set(20);
await $tick();
console.log($c()); // logs 40
// Nothing changed - no re-compute.
console.log($c()); // logs 40
import { $observable, $computed } from '@maverick-js/observables';
const $a = $observable(10);
const $b = $observable(10);
const $c = $computed(() => $a() + $b());
// Computed observables can be deeply nested.
const $d = $computed(() => $a() + $b() + $c());
const $e = $computed(() => $d());
$effect
Invokes the given function each time any of the observables that are read inside are updated (i.e., their value changes). The effect is immediately invoked on initialization.
import { $observable, $computed, $effect } from '@maverick-js/observables';
const $a = $observable(10);
const $b = $observable(20);
const $c = $computed(() => $a() + $b());
// This effect will run each time `$a` or `$b` is updated.
const stop = $effect(() => console.log($c()));
// Stop observing.
stop();
You can optionally destroy all inner observables when stopping the effect by passing in true
to the stop effect function:
// `$c` is from the example above.
const stop = $effect(() => console.log($c()));
// This will dispose of `$a`, `$b`, `$c`, and the effect itself.
stop(true); // <- deep flag
$peek
Returns the current value stored inside an observable without triggering a dependency.
import { $observable, $computed, $peek } from '@maverick-js/observables';
const $a = $observable(10);
$computed(() => {
// `$a` will not be considered a dependency.
const value = $peek($a);
});
$readonly
Takes in the given observable and makes it read only by removing access to write
operations (i.e., set()
and update()
).
import { $observable, $readonly } from '@maverick-js/observables';
const $a = $observable(10);
const $b = $readonly($a);
console.log($b()); // logs 10
// We can still update value through `$a`.
$a.set(20);
console.log($b()); // logs 20
$tick
Tasks are batched onto the microtask queue. This means only the last write of multiple write actions performed in the same execution window is applied. You can wait for the microtask queue to be flushed before writing a new value so it takes effect.
Note You can read more about microtasks on MDN.
import { $observable } from '@maverick-js/observables';
const $a = $observable(10);
$a.set(10);
$a.set(20);
$a.set(30); // only this write is applied
import { $observable, $tick } from '@maverick-js/observables';
const $a = $observable(10);
// All writes are applied.
$a.set(10);
await $tick();
$a.set(20);
await $tick();
$a.set(30);
$dispose
Unsubscribes the given observable and optionally all inner computations. Disposed functions will retain their current value but are no longer reactive.
import { $observable, $dispose } from '@maverick-js/observables';
const $a = $observable(10);
const $b = $computed(() => $a());
// `$b` will no longer update if `$a` is updated.
$dispose($a);
$a.set(100);
console.log($b()); // still logs `10`
The second argument to $dispose
is a deep
flag which specifies whether all inner computations
should also be disposed of:
const $a = $observable();
const $b = $computed(() => $a());
const $c = $effect(() => $b());
$dispose($c, true); // <- deep flag
// `$a`, `$b`, and `$c` are all disposed.
isComputed
Whether the given function is a computed observable.
import { $observable, $computed, isComputed } from '@maverick-js/observables';
isComputed(() => {}); // false
const $a = $observable(10);
isComputed($a); // false
const $b = $computed(() => $a() + 10);
isComputed($b); // true
Debugging
The $observable
, $computed
, and $effect
functions accept a debugging ID (string) as
their second argument. This can be helpful when logging a cyclic dependency chain to understand
where it's occurring.
import { $observable, $computed } from '@maverick-js/observables';
const $a = $observable(10, 'a');
// Cyclic dependency chain.
const $b = $computed(() => $a() + $c(), 'b');
const $c = $computed(() => $a() + $b(), 'c');
// This will throw an error in the form:
// $: Error: cyclic dependency detected
// $: a -> b -> c -> b
Note This feature is only available in a development or testing Node environment (i.e.,
NODE_ENV
).
Scheduler
We provide the underlying microtask scheduler incase you'd like to use it:
import { createScheduler } from '@maverick-js/observables';
// Creates a scheduler which batches tasks and runs them in the microtask queue.
const scheduler = createScheduler();
// Queue tasks.
scheduler.enqueue(() => {});
scheduler.enqueue(() => {});
// Schedule a flush - can be invoked more than once.
scheduler.flush();
// Wait for flush to complete.
await scheduler.tick;
Note You can read more about microtasks on MDN.
Types
import { $computed, type Observable, type Computation } from '@maverick-js/observables';
const observable: Observable<number>;
const computed: Computation<number>;
// Provide generic if TS fails to infer correct type.
const $a = $computed<string>(() => /* ... */);
Inspiration
@maverick-js/observables
was made possible based on my learnings from:
Special thanks to Wesley, Julien, and Solid/Svelte contributors for all their work
Recommend
-
65
GitHub is where people build software. More than 28 million people use GitHub to discover, fork, and contribute to over 85 million projects.
-
53
In this article, we’ll create a simple implementation of the observable pattern and work to understand the core concepts behind it. The Observer pattern: The observer pattern is a software design patte...
-
51
On this episode we will build our own implementation of an observable. I hope that by the end of this post we gain a better understanding of this pattern that is used in libraries like RxJS. About Observables
-
56
<Movie trailer voice> In a world where monoliths break up, devs build new exciting services with towering JAMstacks, serverless functions, and epic cloud services. Yet they face one chal...
-
11
Hot Vs. Cold Observables – ChristianFindlay.comSkip to content The Observer Pattern is at the c...
-
5
Excel-like Experience for Web Apps You've built a web app, but your users stick to Excel.Spreadsheet-like DataGridXL helps you win over the...
-
6
An Insanely Small Plugin Extendable, Component Based Reactive Element Template Library and JQuery Alternative What is it? Surf JS is a few different things all wrapped up in one small package. It'...
-
6
Making React fast by default and truly reactiveSep 25, 2022We love React and we've been very happily using it since 2015, but the dev experience and...
-
9
Fast reactive HTML compliant web pages Fast reactive HTML compliant web pages without virtual DOM overhead or the need for build tools ...
-
15
Repository files navigation
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK