Enhance value with plain and explicit reactive wrapper. Think of it as hook-style signals.
Docs: https://value-enhancer.js.org
npm add value-enhancer
Object.defineProperty
nor
Proxy
. Keeping everything as plain JavaScript value
makes it easier to work with other libraries and easier for the
JavaScript engine to optimize.
Val
s are managed with
FinalizationRegistry
and WeakRef
which
means you can create and access derived
Val.value
without worrying about memory leaks.
Disposers returned by subscriptions can also be easily managed
with libraries like
@wopjs/disposable
.
import | size(brotli) |
---|---|
* |
1.85 kB |
{ readonlyVal, val } (core) |
1.05 kB |
{ from } |
26 B |
{ derive } |
93 B |
{ combine } |
204 B |
{ compute } |
270 B |
{ flattenFrom } |
227 B |
{ flatten } |
36 B |
{ reactiveMap } |
489 B |
{ reactiveSet } |
359 B |
{ reactiveList } |
528 B |
MobX is cleverly designed to make properties magically reactive. But after using it in many of our large projects people started to complain about this implicit behavior. It is hard to tell if a property is reactive unless enforcing some kind of code style rules. Rules of MobX are easy to be broken especially for new team members.
MobX does not work well with other libraries. It could break other
libraries if you forget to exclude third-party instances from
making observable. toJS
is also needed if data is
passed to other libraries.
MobX also prints error when it sees another version of MobX in the global. It is not a good choice for making SDK or library that will be delivered into customer's environment.
In my opinion, the vision of MobX has to be implemented as language-level features, otherwise it will create all kinds of compatibility issues. Svelte, SolidJS and even Vue are heading towards the compiler direction now, looking forward to the next generation of MobX.
In value-enhancer
reactive Vals and plain JavaScript
values are easy to distinguish since they have different types.
The values of reactive Vals are still plain JavaScript values so
it works fine with other libraries. It is small and does not have
global variable issues.
Vue3 brings Reactivity as standalone APIs. It is beautifully designed and I had learned a lot from its source code.
But even though it is made standalone, it is still very Vue centered. Many extra works related to Vue Components are added under the hood.
Vue supports lazy deep reactive conversion. It converts plain JavaScript values into reactive values which means it also suffers from the same issues of MobX.
It is a good choice if you are choosing the Vue ecosystem. The
implementation of value-enhancer
absorbs many
optimization strategies from Vue Reactivity while staying
framework agnostic.
I love RxJS and the reactive paradigm behind it. But the goal of RxJS is to compose asynchronous or callback-based code. It is not optimized for state management.
It also requires you to write code in a pipe-able way which may not be acceptable for everyone.
The signature of combine
and derive
in
value-enhancer
may look familiar to those who have
used React hooks.
import { useMemo } from "react";
const derived = useMemo(() => source + 1, [source]);
I really like the explicit dependency declaration, but in React it
is error-prone since people keep forgetting adding or removing
dependencies. The React team even made a
exhaustive-deps
linter rule for this.
value-enhancer
solves this by absorbing the
RxJS-style callbacks.
import { val, derive, combine } from "value-enhancer";
const source$ = val(1);
console.log(source$.value); // 1
const derived$ = derive(source$, source => source + 1);
console.log(derived$.value); // 2
const combined$ = combine(
[source$, derived$],
([source, derived]) => source + derived
);
console.log(combined$.value); // 3
Since the type of reactive objects are different from its values,
it is hard to have mismatched dependencies inside the
transform
function.
value-enhancer
can be used in React with a
super-simple hook
use-value-enhancer
.
Svelte offers excellent support for Observables. Svelte store is one of the simplest implementations. The code is really neat and clean.
Svelte store works well for simple cases but it also leaves some
edge cases unresolved. For example, when derived
a
list of stores, the transform function could be
invoked with intermediate states.
Svelte also adds a $xxx
syntax for subscribing
Observables as values. The compiled code is really simple and
straightforward.
value-enhancer
is compatible with Svelte Store
contract. It can be used in Svelte just like Svelte stores.
value-enhancer
also fixes the edge cases of Svelte
stores by leveraging Vue's layered subscriber design.
SolidJS "create"s are like React hooks but with saner signatures. It is also thoughtfully optimized for edge cases.
A thing that one may feel odd in SolidJS is accessing reactive
value by calling it as function. value-enhancer
keeps
the xxx.value
way to access reactive value which I
think should be more intuitive.
It also suffers from implicit magic issues like MobX and Vue where
you ended up using something like
mergeProps
and
splitProps
.
value-enhancer
is compatible with SolidJS using
from
.
Preact recently released
Signals
which shares similar ideas with value-enhancer
. It is
like signals of SolidJS but without the odd function-like value
accessing. It flushes reactions top-down then bottom-up like Vue
and value-enhancer
.
The Preact team also took a step further to support writing Signals directly within TSX. This offers Svelte-like neat coding experience.
const count = signal(0);
// Instead of this:
<p>Value: {count.value}</p>
// … we can pass the signal directly into JSX:
<p>Value: {count}</p>
// … or even passing them as DOM properties:
<input value={count} />
But it also uses Vue-like magic to collect effects.
const counter = signal(0);
effect(() => {
console.log(counter.value);
});
It might seem clean at first but it's not a self-consistent
solution either. You'll probably meet weird issues and find
workarounds like
signal.peek()
which is error-prone.
const counter = signal(0);
const effectCount = signal(0);
effect(() => {
console.log(counter.value);
// Whenever this effect is triggered, increase `effectCount`.
// But we don't want this signal to react to `effectCount`
effectCount.value = effectCount.peek() + 1;
});
This issue does not exist in value-enhancer
because
we do not collect dependencies implicitly by default.
In case of subscribing to flexible dynamic dependencies are
needed, value-enhancer
does offer a simple
compute
API which is similar to Jotai.
import { val, compute } from "value-enhancer";
const count$ = val(0);
const a$ = val("a");
const b$ = val("b");
const s$ = compute(get => {
return get(count$) % 2 === 0 ? get(a$) : get(b$);
});
value-enhancer
in development mode supports DevTools
custom formatters. You may enable it by checking the "Enable custom
formatters" option in the "Console" section of
DevTools general settings.
import { val } from "value-enhancer";
const count$ = val(2);
console.log(count$.value); // 2
count$.set(3);
console.log(count$.value); // 3
count$.value = 4;
console.log(count$.value); // 4
import { readonlyVal } from "value-enhancer";
const [count$, setCount] = readonlyVal(2);
console.log(count$.value); // 2
setCount(3);
console.log(count$.value); // 3
count$.value = 4;
console.log(count$.value); // 4
import { val, combine, derive, nextTick } from "value-enhancer";
const count$ = val(3);
// Emit the current value synchronously, then emit the new value when it changes.
const disposeSubscribe = count$.subscribe(count => {
console.log(`subscribe: ${count}`);
}); // printed "subscribe: 3"
// Only emit the new value when it changes.
const disposeReaction = count$.reaction(count => {
console.log(`reaction: ${count}`);
}); // (nothing printed)
// `Object.is` equality check by default
count$.set(3); // nothing happened
// subscription triggered asynchronously by default
count$.set(4); // nothing happened
await nextTick(); // subscription triggered asynchronously by default
// printed "subscribe: 4"
// printed "reaction: 4"
disposeSubscribe();
disposeReaction();
derive
a new Val from another Val.
import { val, derive } from "value-enhancer";
const count$ = val(2);
const derived$ = derive(count$, count => count * 3);
console.log(derived$.value); // 6
Pipe-able style with functional lib like rubico
:
(CodeSandbox)
import { derive, val } from "value-enhancer";
import { pipe, map, filter } from "rubico";
const isOdd = (number: number) => number % 2 == 1;
const square = (number: number) => number ** 2;
const numbers$ = val([1, 2, 3, 4, 5]);
const derived$ = derive(numbers$, pipe([filter(isOdd), map(square)]));
console.log(derived$.value); // [1, 9, 25]
derived$.reaction(numbers => {
console.log("reaction", numbers);
});
numbers$.set([6, 7, 8, 9, 10]);
// `reaction [49, 81]`
combine
multiple Vals into a new Val.
import { val, derive, combine } from "value-enhancer";
const count$ = val(2);
const derived$ = derive(count$, count => count * 3);
const combined$ = combine(
[count$, derived$],
([count, derived]) => count + derived
);
console.log(combined$.value); // 8
flatten
the inner Val from a Val of Val. This is useful
for subscribing to a dynamic Val that is inside another Val.
import { val, flatten } from "value-enhancer";
const itemList$ = val([val(1), val(2), val(3)]);
const firstItem$ = flatten(itemList$, itemList => itemList[0]);
console.log(firstItem$.value); // 1
itemList$.set([val(4), val(5), val(6)]);
console.log(firstItem$.value); // 4
compute
is useful for subscribing to flexible dynamic
dependencies.
The get
function passed to the effect callback can be
used to get the current value of a Val and subscribe to it. The
effect callback will be re-evaluated whenever the dependencies
change. Stale dependencies are unsubscribed automatically.
import { val, compute } from "value-enhancer";
const count$ = val(0);
const a$ = val("a");
const b$ = val("b");
const s$ = compute(get => {
return get(count$) % 2 === 0 ? get(a$) : get(b$);
});
from
creates a Val from any value source. Both
derive
and combine
are implemented using
from
under the hood.
import { from } from "value-enhancer";
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
const isDarkMode$ = from(
() => prefersDark.matches,
notify => {
prefersDark.addEventListener("change", notify);
return () => prefersDark.removeEventListener("change", notify);
}
);
flattenFrom
creates a Val from any value source like
from
but also flatten the value if the value is a Val.
flatten
is implemented using flattenFrom
.
By default, Object.is
equality check is used to
determine whether a value has changed. You can customize the
equality check by passing a equal
function.
import { val } from "value-enhancer";
const isSameXYPosition = (p1, p2) => p1.x === p2.x && p1.y === p2.y;
const isSameXYZPosition = (p1, p2) =>
p1.x === p2.x && p1.y === p2.y && p1.z === p2.z;
const xyzPosition$ = val({ x: 0, y: 0, z: 0 }, { equal: isSameXYZPosition });
const xyPosition$ = derive(xyzPosition$, ({ x, y }) => ({ x, y }), {
equal: isSameXYPosition,
});
xyPosition$.set({ x: 0, y: 0, z: 0 }); // nothing happened
Subscription is triggered asynchronously on next tick by default. To
trigger synchronously, set eager
parameter to
true
.
count$.subscribe(count => console.log(`subscribe: ${count}`), true);
count$.reaction(count => console.log(`reaction: ${count}`), true);
Or set eager
to true
when creating the
Val.
// subscription of count$ is trigger synchronously by default
const count$ = val(3, { eager: true });
const derived$ = derive(count$, count => count * 3, { eager: true });
The Reactive Collections are a group of classes that expand on the built-in JavaScript collections, allowing changes to the collections to be observed. See docs for API details.
import { derive } from "value-enhancer";
import { reactiveList } from "value-enhancer/collections";
const list = reactiveList(["a", "b", "c"]);
const item$ = derive(list.$, list => list[2]); // watch the item at index 2
console.log(item$.value); // "c"
list.set(2, "d");
console.log(item$.value); // "d"
import { val, flatten } from "value-enhancer";
import { reactiveMap } from "value-enhancer/collections";
const map = reactiveMap();
const v = val("someValue");
const item$ = flatten(map.$, map => map.get("someKey")); // watch the item at "someKey"
console.log(item$.value); // undefined
map.set("someKey", v); // set a val, the value inside the val is subscribed and flatten to `item$`
console.log(item$.value); // "someValue"
v.set("someValue2"); // you can also set a non-val value, which is passed to `item$`` directly
console.log(item$.value); // "someValue2"
With this pattern, Writable Val
properties are exposed
as $$
and ReadonlyVal
properties are
exposed as $
.
Note that they are all Writable Val
under the hood. The
difference is just the type.
import { val, type ReadonlyVal, type Val } from "value-enhancer";
interface MyClassVals {
a: number;
b: string;
}
export type MyClass$ = {
[K in keyof MyClassVals]: ReadonlyVal<MyClassVals[K]>;
};
export type MyClass$$ = {
[K in keyof MyClassVals]: Val<MyClassVals[K]>;
};
export class MyClass {
public readonly $: MyClass$;
public readonly $$: MyClass$$;
public constructor() {
this.$ = this.$$ = {
a: val(1),
b: val("2"),
};
}
}
const myClass = new MyClass();
console.log(myClass.$.a.value);
myClass.$$.a.set(3);
If you want to ensure maximum safety and prevent others from
modifying the value accidentally, you can use a real
ReadonlyVal
.
import {
readonlyVal,
type ReadonlyVal,
type ValSetValue,
} from "value-enhancer";
interface MyClassVals {
a: number;
b: string;
}
export type MyClass$ = {
[K in keyof MyClassVals]: ReadonlyVal<MyClassVals[K]>;
};
export type MyClassSet$ = {
[K in keyof MyClassVals]: ValSetValue<MyClassVals[K]>;
};
export class MyClass {
public readonly $: MyClass$;
public readonly set$: MyClassSet$;
public constructor() {
const [a$, setA] = readonlyVal(1);
const [b$, setB] = readonlyVal("2");
this.$ = { a: a$, b: b$ };
this.set$ = { a: setA, b: setB };
}
}
const myClass = new MyClass();
console.log(myClass.$.a.value);
myClass.set$.a(3);
Writing all these ReadonlyVals and setters could be cumbersome. With
groupVals
you can easily create a group of ReadonlyVals
and setters.
import {
type ReadonlyVal,
type ValSetValue,
type FlattenVal,
readonlyVal,
groupVals,
} from "value-enhancer";
export interface Foo$ {
a: ReadonlyVal<number>;
b: ReadonlyVal<number>;
c: ReadonlyVal<string>;
}
export class Foo {
public readonly $: Foo$;
private readonly set$: { [K in keyof Foo$]: ValSetValue<UnwrapVal<Foo$[K]>> };
public constructor() {
const [vals, set$] = groupVals({
a: readonlyVal(1),
b: readonlyVal(2),
c: readonlyVal("3"),
});
this.$ = vals;
this.set$ = set$;
}
public myMethod() {
this.set$.a(2);
this.set$.c("4");
}
}
const foo = new Foo();
console.log(foo.$.a.value); // 1
foo.myMethod();
console.log(foo.$.a.value); // 2
Sharing vals to other classes directly should be careful. Other
classes may dispose
the vals and cause unexpected
behavior.
To share ReadonlyVals to other classes, use .ref()
to
create a ref ReadonlyVal. It is just like derive
a val
without transform
. It is simpler hence more efficient.
import { val, type ReadonlyVal } from "value-enhancer";
interface AProps {
a$: ReadonlyVal<number>;
}
class A {
a$: ReadonlyVal<number>;
constructor(props: AProps) {
this.a$ = props.a$;
}
dispose() {
this.a$.dispose();
}
}
const a$ = val(1);
const a = new A({ a$: a$.ref() });
a.dispose(); // will not affect a$
To share writable vals to other classes, use a
.ref(true)
.
It creates a new Val referencing the value of the current Val as source. All ref Vals share the same value from the source Val. The act of setting a value on the ref Val is essentially setting the value to the source Val.
The ref Vals can be safely disposed without affecting the source Val and other ref Vals.
import { val, type Val } from "value-enhancer";
interface AProps {
a$: Val<number>;
}
class A {
a$: Val<number>;
constructor(props: AProps) {
this.a$ = props.a$;
}
dispose() {
this.a$.dispose();
}
}
const a$ = val(1);
const a1 = new A({ a$: a$.ref(true) });
const a2 = new A({ a$: a$.ref(true) });
a2.a$.set(2);
console.log(a$.value); // 2
console.log(a1.a$.value); // 2
console.log(a2.a$.value); // 2
a1.dispose(); // will not affect a$ and a2.a$