@maluscat/reactive-storage v1.0.0
ReactiveStorage
Register, observe and intercept deeply reactive data on any object without the need for proxies!
const storage = new ReactiveStorage({
depth: Infinity,
setter: ({ val, path }) => { console.log(`SET ${path.join('.')}:`, val) }
});
storage.register('foo');
storage.target.foo = {
bar: 3,
baz: [ 'a', 'b' ]
};
// SET foo: { ... }
// SET foo.bar: 3
// SET foo.baz: [ ... ]
// SET foo.baz.0: "a"
// SET foo.baz.1: "a"
storage.target.foo.bar++;
// SET foo.bar: 4
storage.target.foo.baz[1] = 'lor';
// SET foo.baz.1: "lor"Contents
Rationale
Proxies are dope and allow for full reactivity, but they come with a significant performance overhead. Even though JS is very fast nowadays, property accesses add up quickly as they are used constantly and everywhere. Sure, ReactiveStorage is somewhat limited when compared to proxies, but it can be very powerful when used in the right spots!
I've also seen some sources claim deep reactivity to be impossible without using proxies. Even Vue didn't support it before making the switch to proxies. Take that!
Limitations
ReactiveStorage is explicitly not a catch-all solution for reactivity. Since it purely relies on Object.defineProperty, it inherits all of its limitations too. These largely amount to:
- No dynamic approach; Data must be explicitly registered in order to become reactive.
- Only getters and setters; The data cannot be modified in-place using methods
like
array.push(...)orarray.splice(...).
Installation
Since this library is completely runtime agnostic, it can be used inside any JavaScript environment, including the web. The work on the library is mostly finished, so there won't be many more updates in the future.
Download
The only required file is ReactiveStorage.js inside the script
folder. If you want type checking, fetch ReactiveStorage.d.ts as well!
npm
Available on npm under @maluscat/reactive-storage. Use your favorite package
manager (or use it with Deno):
yarn add @maluscat/reactive-storage
bun install @maluscat/reactive-storage
npm install @maluscat/reactive-storageConcepts
A reactive property is defined via Object.defineProperty with attached
getters/setters. Properties are defined on a target object and store their
actual values at an arbitrary place, the endpoint. In this library, the
endpoint is always an object, and a property's values are stored under the
property name within that endpoint (see below).
!note In most of this document, "object" refers to any JavaScript object, be it an object literal, an array, a class instance, etc.
Let's say we register a reactive property "foo". The data flows like this:
# "foo" on target = {}
target.foo <-[GET]-- endpoint.foo
target.foo --[SET]-> endpoint.fooArrays work analogously since they are just objects with special syntax:
# "0" on target = []
target[0] <-[GET]-- endpoint[0]
target[0] --[SET]-> endpoint[0]Instead of only having a single target that points to an endpoint, we can scale horizontally by sequentially routing a value through multiple targets:
# "foo" on:
# 1. target0 = {}
# 2. target1 = {}
target0.foo <-[GET]-- target1.foo <-[GET]-- endpoint.foo
target0.foo --[SET]-> target1.foo --[SET]-> endpoint.fooDeep reactivity is a recursive registration of object values to make their children reactive as well (vertical scaling). The properties of each distinct object are registered into a new storage object which their respective parents point to:
# "foo = { bar: 3, baz: 4 }" on target = {}
# implicit: storage1 = {} with { GET/SET bar, GET/SET baz }
target.foo <-[GET]-- storage1
target.foo --[SET]-> endpoint.foo
storage1.bar <-[GET]-- endpoint.foo.bar
storage1.bar --[SET]-> endpoint.foo.bar
storage1.baz <-[GET]-- endpoint.foo.baz
storage1.baz --[SET]-> endpoint.foo.bazLooking only at the getters makes it a bit clearer:
# "foo = { bar: 3, baz: { lor: 10 } }" on target = {}
# implicit: storage1 = {} with { GET/SET bar, GET/SET baz }
# implicit: storage2 = {} with { GET/SET lor }
target.foo <-[GET]-- storage1
storage1.bar <-[GET]-- endpoint.foo.bar
storage1.baz <-[GET]-- storage2
storage2.lor <-[GET]-- endpoint.foo.baz.lorUsage
The only non-typing exports are ReactiveStorage, Filter (also exposed via
ReactiveStorage.Filter) and, if needed, ReactiveStorageError:
import { ReactiveStorage, Filter, ReactiveStorageError } from '@maluscat/reactive-storage';See the docs for an overview of all additional typing related exports for use in TypeScript.
ReactiveStorage can make properties reactive such that they invoke callbacks
whenever they are accessed or assigned to. In addition, object values can be
made deeply reactive – This allows any change within
arbitrarily deeply nested properties to be caught and intercepted. Any object
can be deeply registered, though by default only arrays and object literals will
propagate to avoid infinite recursion and unwanted overhead (can be controlled
with the depthFilter config option). In addition, properties
can be sequentially routed through
multiple targets, each with their own
configuration.
Instanced vs. static approach
There are two ways to use this library: Using a ReactiveStorage instance or
using static methods.
An instance always holds a single immutable configuration which is passed in the constructor and used every time a property is registered. The static methods take the configuration on a per-registration basis as an additional argument.
The used configuration is stored in the config instance property. The
target(s) and endpoint are additionally exposed via the targets, target and
endpoint properties respectively. target always points to the first item of
targets, so unless you're using
multiple targets, you can always use that one.
import { ReactiveStorage, Filter } from './ReactiveStorage.js';
const storage = new ReactiveStorage({
target: {},
endpoint: {},
enumerable: true,
depth: 0,
depthFilter: Filter.objectLiteralOrArray,
getter: undefined,
setter: undefined,
postSetter: undefined,
});Registering properties
The register(...) and registerFrom(...) methods are used to register one or
multiple properties. When using a depth configuration, any value, so either
the initial value or values assigned at any later point in time, will be
recursively traversed and registered until the given depth as long as it matches
the depthFilter (only object literals and arrays by default). See the
examples for more info.
Instance
The instance method register(...) uses the instance's
configuration and returns the instance to allow for chaining.
The initial value is optional.
register(
key: number | string | symbol | (number | string | symbol)[],
initialValue?: any
): ReactiveStorageregisterFrom(...) can be used to register all properties (including symbols)
of a given object on the instance's target.
registerFrom(data: object): ReactiveStorageStatic
The static methods optionally take a configuration and return
an object containing the used targets and endpoint, in which target points to
the first item in targets.
register(
key: number | string | symbol | (number | string | symbol)[],
initialValue?: any,
options: RegistrationOptions = {}
): { targets: object[], target: object, endpoint: object }registerFrom(
data: object,
options: RegistrationOptions = {}
): { targets: object[], target: object, endpoint: object }There are helper functions to extend each of the above defined functions with
infinitely deep recursion (same as depth: Infinity in the deepest depth).
registerRecursive(
key: number | string | symbol | (number | string | symbol)[],
initialValue?: any,
options?: RegistrationOptions = {}
): { targets: object[], target: object, endpoint: object }registerRecursiveFrom(
data: object,
options: RegistrationOptions = {}
): { targets: object[], target: object, endpoint: object }Configuring deep values
To register a property deeply, you can use the depth config option which
accepts either a number or a configuration. A deep configuration will again
register any properties (including symbols) of an assigned object, provided that
it matches the depthFilter config option.
If given a number, this will be the max depth until which assigned values will
be made reactive. In this case, a layer's configuration will be inherited from
the parent config, with the exception of the target and endpoint options.
A given configuration will define options for that specific layer, which is
useful to specify individual getters/setters for each layer of depth. The
target option may not be specified since it will change with each new
assignment. Missing options except endpoint and target will be inherited
from its parent unless explicitly set to false. This setup can be nested
infinitely deep. To mitigate needing to extensively nest depth configurations,
you can also make use of the getter/setter path argument
(specifically, its length).
In this example, three explicit reactivity layers are defined, each of which
defining an individual setter while inheriting the topmost getter. Layer 2
defines one additional implicit layer. Any layers below that won't be reactive.
Note how the path argument of the first two setters always has the same length
since they are not inherited downwards:
const storage = new ReactiveStorage({
depth: {
depth: {
setter: ({ val, path }) => { console.log(`Layer 2 or 3 SET ${path.join('.')}:`, val) },
depth: 1
},
setter: ({ val, path }) => { console.log(`Layer 1 SET ${path.join('.')}:`, val) },
},
setter: ({ val, path }) => { console.log(`Layer 0 SET ${path.join('.')}:`, val) },
getter: ({ val, path }) => { console.log(`GET ${path.join('.')}:`, val) },
});
storage.register('foo', { bar: 3 });
// Layer 0 SET foo: { bar: 3 }
// Layer 1 SET foo.bar: 3
storage.target.foo = { bar: { baz: { lor: { val: 9 } } } }
// Layer 0 SET foo: { bar: ... }
// Layer 1 SET foo.bar: { baz: ... }
// Layer 2 or 3 SET foo.bar.baz: { lor: ... }
// Layer 2 or 3 SET foo.bar.baz.lor: { val: 9 }
/// <Layer 4 and downwards is not reactive>Reactivity is kept alive
The initial registration configuration is always kept alive,
meaning that reassigning a value will register it with the configuration used in
its initial registration. This also means that providing an initial value is
optional – If omitted, an initial setter call will not be invoked (same with
explicitly passing undefined).
const storage = new ReactiveStorage({
depth: Infinity,
setter: ({ val, path }) => { console.log(`SET ${path.join('.')}:`, val) },
});
storage.register('foo');
storage.target.foo = 3;
// SET foo: 3
storage.target.foo = [ { lor: 69 }, 'bar', 'baz' ];
// SET foo: [ ... ]
// SET foo.0: { foo: 69 }
// SET foo.0.lor: 69
// SET foo.1: "bar"
// SET foo.2: "baz"Initial assignment
The initial assignment will already call the specified setter and postSetter
(unless the initial value is omitted or undefined). This can be filtered using
the callback functions' initial parameter.
const storage = new ReactiveStorage({
depth: Infinity,
setter: ({ val, initial, path }) => {
console.log(`${initial ? 'initial' : ''} SET ${path.join('.')}:`, val)
},
postSetter: ({ val, initial, path }) => {
console.log(`${initial ? 'initial' : ''} POST-SET ${path.join('.')}:`, val)
}
});
storage.register('foo', {
bar: [ 10, 20 ],
baz: {
lor: 'my-string'
}
});
// initial SET foo: { bar: ..., baz: ... }
// initial SET foo.bar: [ 10 ]
// initial SET foo.bar.0: 10
// initial POST-SET foo.bar.0: 10
// initial POST-SET foo.bar: [ 10 ]
// initial SET foo.baz: { lor: ... }
// initial SET foo.baz.lor: "my-string"
// initial POST-SET foo.baz.lor: "my-string"
// initial POST-SET foo.baz: { lor: ... }
// initial POST-SET foo: { bar: ..., baz: ... }
storage.target.foo = 3;
// SET foo: 3
// POST-SET foo: 3Intercepting values
By default, a configured setter is a passive observer, so after being called,
the passed value will automatically be set to the property's respective
endpoint. However, a setter may return true to prevent the value from being
set. In addition to just dropping a value like this, a modified/custom value
can be assigned instead using the passed default setter set.
A getter analogously only observes the fetched values passively by default,
being given the value of the underlying endpoint when a property is fetched.
Any return value other than a nullish value (null or undefined) will yield
this value to the caller.
In this example, any assigned value that isn't a number will be discarded, while numbers will always be clamped to the range 0, 100. When fetched, they will be rounded to the nearest 5:
const storage = new ReactiveStorage({
depth: Infinity,
getter: ({ val }) => {
return Math.round(val / 5) * 5;
},
setter: ({ val, set }) => {
if (typeof val !== 'number') return true;
if (val > 100) {
set(100);
return true;
} else if (val < 0) {
set(0);
return true;
}
},
});
storage.register('foo', 38);
console.log(storage.endpoint.foo) // 38
console.log(storage.target.foo) // 40
storage.target.foo = -6;
console.log(storage.endpoint.foo) // 0
console.log(storage.target.foo) // 0
storage.target.foo = 52;
console.log(storage.endpoint.foo) // 52
console.log(storage.target.foo) // 50
storage.target.foo = 'bar'
console.log(storage.endpoint.foo) // 52
console.log(storage.target.foo) // 50Multiple sequential targets
By passing not just one but multiple configuration objects, it's easy to setup
multiple target points, each with their own configuration, that a value is
sequentially routed through until it reaches the endpoint. This is also
explained in Concepts as horizontal scaling. Only the last
configuration may define the endpoint property – In all others, it will be
overridden.
All defined targets (one for each passed configuration) are stored in the
targets property of either the returned data when using the static methods or
of the created instance. The target property always points to the first
element in targets and can be conveniently used when only one target has been
defined.
Here, two layers are defined. The first does high-level work such as validating its values while the second does some mandatory operations. In one possible scenario, the first target could be exposed to the user as a high-level interface while the second is used for internal purposes where the validity of an assigned value is already ensured:
const storage = new ReactiveStorage([
{
setter: ({ val }) => {
return !inputIsValid(val);
},
}, {
setter: ({ val, path }) => {
propertyHasNewValue(path, val);
},
}
]);
storage.register('foo');
storage.targets[0].foo = 3;
// First go through `inputIsValid`, then `propertyHasNewValue`
storage.targets[1].foo = 4;
// Only `propertyHasNewValue` is calledInstance helper functions
The has(...) instance method returns true if the given property key exists on
the instance's target and has thus been registered, false otherwise.
has(key: number | string | symbol): booleanThe delete(...) instance method deletes a registered property from the
instance's target and endpoint. Returns true if a property was successfully
deleted (speak, if the property had been registered), false otherwise.
Deep properties will not be deleted because the class does not hold a reference to them. As such, they will be garbage collected instead.
delete(key: number | string | symbol): booleanUsing with types
Since TypeScript is an entirely static language, there is no way to propagate
type information from an instance method to an instance property. This is why,
without additional information, only the target/targets returned by the two
static methods ReactiveStorage.register(...) and
ReactiveStorage.registerRecursive(...) knows about the registered properties.
To supply additional type information, a property-value interface can be passed
as a generic to the ReactiveStorage class or the static methods mentioned
above. This is the best I can do since Typescript generics are quite limited
(I'd be happy to be convinced of the contrary!).
interface Properties {
foo: number
bar: string[]
baz: Array<number> | number
}
const storage = new ReactiveStorage<Properties>();
storage.register('lor'); // ERROR: Not a known property!
storage.register('bar', {}); // ERROR: Type for 'bar' does not match!
storage.register('foo', 4);
// `storage.target` is typed to contain the properties
// 'foo', 'bar', 'baz' and their respective types
// Analogously:
ReactiveStorage.register<Properties>('foo', 4);Configuration
The configuration can either be passed to the constructor or to the static methods. See here a summarized version of its interface with the full one available at the docs (along with more examples). All fields are optional.
target
- Type:
object - Default:
{}
The access point for the registered property/properties. Values are deposited at the endpoint. Can be any object, so it may also be an array, a class instance, etc.
This property may only be defined in the topmost level of a configuration and
not within depth since these change on each assignment.
endpoint
- Type:
object - Default:
{}
The object that holds the actual data of registered properties. The configured setter and getter will deposit the value to and fetch the value from this endpoint respectively.
Can be accessed directly to bypass all specified setters and getters. In a classic use case, it can be a good idea to use the endpoint for internal use, while the setters/getters on the target do some extra work when accessed by a user.
enumerable
- Type:
boolean - Default:
true
Whether registered properties should be enumerable inside the target.
Corresponds to the
Object.defineProperty option
of the same name.
depth
- See also Configuring deep values
- Type:
Configuration | number - Default:
0
Whether and how keys inside object values should be registered such that they go through additional layers of getters and setters. If a value is reassigned, it is re-registered with the same configuration until the configured depth.
If given a configuration, the registered property will assume these options
in its layer and inherit missing ones unless set to false. Can be nested
infinitely deep.
If given a number, keys will be registered recursively up until the given
depth, inheriting the parent options. Can be Infinity.
depthFilter
- Type:
(obj: object, path: Array<string | symbol>) => boolean - Default:
Filter.objectLiteralOrArray
Decide whether to deeply register an object covered by depth.
This is useful to mitigate registering properties within any object (class
instances, DOM nodes, etc.) in favor of simpler objects.
Be careful when changing this, especially when there is user input involved! Unrestricted recursion may lead to a significant overload or even an infinite loop when (accidentally) assigning complex objects like a DOM node.
postSetter
- Type:
(event: PostSetterEvent) => void
Called after a value has been set.
The passed event object has the following properties:
val: The value that was setprevVal: The previous valueinitial(boolean): Whether this call is propagated by the initial registrationpath(Array<string | symbol>): Key path of the property that was set
setter
- See also Intercepting values
- Type:
(event: SetterEvent) => void | boolean
Called before a value is set. Return true to discard the value, i.e. to stop
the default action of setting the value to the underlying endpoint.
The passed event object has the following properties:
val: The value that will be set (unless discarded)prevVal: The previous valueinitial(boolean): Whether this call is propagated by the initial registrationpath(Array<string | symbol>): Key path of the property that was setset((val) => void): Default setter that sets a given value to the underlying endpoint. When using it, you should prevent the default value from being set by returningtrue.
getter
- See also Intercepting values
- Type:
(event: GetterEvent) => void | any
Called anytime a value is fetched. Return null or undefined to propagate the
default value. Any other return value will be the property's value.
As inferred in Concepts, deep properties require a lot of getter
calls, so when using depth extensively, you should probably keep inherited
getter functions lightweight.
The passed event object has the following properties:
val: The value from the underlying endpointpath(Array<string | symbol>): Key path of the property that was set
Examples
Basic example using registerFrom
Using registerFrom to register every property of a predefined object. Notice
how "bar" does not invoke an initial setter call since its initial value is
undefined.
const data = {
foo: 3,
bar: undefined,
[Symbol.for('baz')]: 'unique Symbol!'
};
// We just extract `target` because we only have a single target
// and don't care about `endpoint`
const { target } = ReactiveStorage.registerFrom(data, {
setter: ({ val, path }) => { console.log(`${path}: ${val}`) }
});
// ['foo']: 3
// Symbol: 'unique Symbol!'Deep arrays with registerFrom and multiple targets
Arrays work analogously. Here we use a nested depth configuration and multiple targets. We assume that we only ever work with arrays in this scenario, so we set each target to an empty array as opposed to an empty object literal (even though both are functionally equivalent).
The first target simply reports the values it gets. The second caps any received
number to 100 and the third discards any strings set in the first vertical layer
since it does not configure any depth. Keep in mind that getters and setters
propagate in contrary directions, so a setter trickles down: target[0] ->
target[1] -> target[2] -> endpoint while a getter bubbles up: target[0] <-
target[1] <- target[2] <- endpoint.
const data = ReactiveStorage.registerFrom([ 0, 1, 2, 3 ], [
{
target: [],
depth: {
getter: false
},
getter: ({ val }) => {
console.log("Layer 0:", val);
}
}, {
target: [],
depth: 1,
getter: ({ val }) => {
if (typeof val === 'number') {
return Math.min(val, 100);
}
}
}, {
target: [],
setter: ({ val }) => {
if (typeof val === 'string') {
console.log("Dropping string!");
return true;
}
},
getter: ({ val }) => {
console.log("Layer 2:", val);
}
}
]);
/* Getters invoked by setting a value have been omitted */
console.log(data.targets[0][0]); // 0
// Layer 2: 0
// Layer 0: 0
data.targets[0][0] = 120;
console.log(data.targets[0][0]); // 100
// Layer 2: 120
// Layer 0: 100
console.log(data.targets[2][0]); // 120
// Layer 2: 120
data.targets[0][1] = [ 200 ];
console.log(data.targets[0][1][0]); // 100
// Layer 0: 100
data.targets[0][2] = -3;
data.targets[0][2] = 'foobar';
// Dropping string!
console.log(data.targets[0][2]); // -3
// Layer 2: -3
// Layer 0: -3Register deep properties using an instance
This example makes use of a ReactiveStorage instance instead of calling a
static registration method directly. This gives us the ability to register
multiple properties as well as delete them again at any time.
Here, the setters of the first two depth layers are configured manually while
changes in layer 2 and below invoke a catch-all setter. Property paths (and with
that, also their depth) can be determined by the path argument. If the
respective depth was set to, say, 2 instead of Infinity, only layers 2, 3
and 4 would be made reactive (the defined depth layer + 2).
const storage = new ReactiveStorage({
depth: {
depth: {
depth: Infinity,
setter: ({ val, path }) => {
console.log(`Catch-all layer ${path.length - 1}:`, val);
}
},
setter: ({ val }) => {
console.log("Layer 1:", val);
}
},
setter: ({ val }) => {
console.log("Layer 0:", val);
}
});
storage
.register(['foo', 'bar', 'baz'])
.register('lor', 3);
// Layer 0: 3
storage.target.foo = {
foo1: {
foo2: {
foo3: [ 10, 20, 30 ]
}
}
};
// Layer 0: { foo1: ... }
// Layer 1: { foo2: ... }
// Catch-all layer 2: { foo3: ... }
// Catch-all layer 3: [ 10, 20, 30 ]
// Catch-all layer 4: 10
// Catch-all layer 4: 20
// Catch-all layer 4: 30Docs
See the generated docs for a more in-depth overview of the library.
6 months ago