graph-state
v2.1.4
Published
Digraph-based state management
Downloads
98
Maintainers
Readme
graph-state
Digraph-based state management
Install
yarn add graph-state
npm install graph-state
Usage
Features
Think atomically
graph-state
motivates the use of granular stores, or we call them "nodes", that is, nodes that only store a single state.
import { node } from 'graph-state';
// A simple graph node usage
const counter = node({
get: 0,
});
Nodes can have either a constant value as a default state, or provide a function that lazily computes the default state.
const randomState = node({
get: () => Math.random(),
});
Dependency graph states
graph-state
leverages the cumbersome process of connecting stores to communicate one another by seamlessly connecting them. A node's get
propery receives an interface which can be used to read other node's state. Once a node reads another node's value, the read node becomes a "dependency": whenever the dependency node updates value, the dependent node recomputes it's state.
const greeting = node({
get: 'Hello',
});
const person = node({
get: 'John Doe',
});
const message = node({
get: ({ get }) => {
// Read the greeting and person nodes
const greetingValue = get(greeting);
const personValue = get(person);
// When greeting or person updates their state
// message re-runs its get function to create a new state.
// Return the derived state
return `${greetingValue}, ${personValue}.`;
},
});
It doesn't matter when get
is called or how it is used:
const example = node({
get: ({ get } => {
const cond = get(A);
if (cond) {
return get(B); // If A is truthy, B becomes a dependency.
}
return get(C); // Otherwise, C becomes the dependency instead of B.
}),
});
Whenever get
is called, the dependency list is rebuilt, so the timing doesn't matter for a node to become a dependency.
Lazy Evaluation
Nodes, even though can be created on any level of context, does not evaluate until needed/used.
const example = node({
// This function does not run until
// example is read.
get: () => runExpensiveComputation(),
});
Keys
Nodes may accept a key
field:
const example = node({
key: 'example',
get: 'Hello',
});
If another node of the same key is attempted to be created, the first instance is always reused.
Keys you define are dev-only. Production environment will produce a unique id in place of the keys you provided.
Subscriptions
graph-state
allows managing subscriptions for side-effects. This is useful for subscribing to events (e.g. addEventListener
), timers (setTimeout
), etc.
const timer = node({
get: ({ subscription }) => {
subscription(() => {
const timeout = setTimeout(() => {
intervalLogic();
}, 1000);
return () => {
clearTimeout(timeout);
};
});
},
});
Similar to get
, it doesn't matter when subscription
is called. Every recomputation runs the cleanup function returned by subscription
, and re-runs the callback.
Mutations
There are two kinds of state update in graph-state
: mutate
and set
. mutate
directly changes a node's state. set
's default behavior is similar to mutate
, but if the node has a custom set
function, the custom function is called instead of mutate
. This is useful for building actions.
Both get
and set
received interfaces has functions that allow mutation.
// A node that mutates itself.
const secondClock = node({
get: ({ mutateSelf, subscription }) => {
subscription(() => {
// an internal variable that tracks a state.
let count = 0;
// Subscribe to an interval timer
const timeout = setInterval(() => {
// Update our counter
count += 1;
// Perform self-mutation
mutateSelf(count);
}, 1000);
return () => {
clearInterval(timeout);
};
});
return 0;
},
});
Actions
Inspired by Redux and Flux architecture, nodes can have an action-receiving function called set
, which overrides the state mutation of the node. The set
function accepts the same interface as get
(excluding subscription
) and the action being received. In contrast with get
, set
does not react nor connect to read nodes.
An example of a graph node emulating a Redux store.
const counter = node({
get: 0,
});
const reduce = (state, action) => {
switch (action.type) {
case 'INCREMENT':
return state + 1;
case 'DECREMENT':
return state - 1;
default:
return state;
}
};
const reducer = node({
get: ({ get }) => get(counter),
set: ({ get, set }, action) => {
set(counter, reduce(get(counter), action));
},
});
Memoization
Nodes have their values memoized. Once a node attempts to update its state with an equivalent value, dependencies don't get notified with the new state, thus bailing out of recomputation. node
accepts another optional field named shouldUpdate
which is a function that receives the current state and the next state. By default, the states are compared with Object.is
.
Concurrency
Nodes with asynchronous get
or set
may produce unwanted side-effects whenever both methods run immediately after one another. To fix this, nodes, internally, have built-in race conditions which allows further side-effects from occuring by preventing set
, mutate
, setSelf
, mutateSelf
and subscription
from further evaluation.
const itemsList = node({
get: async () => {
const data = await db.query();
return data;
},
});
const filteredItemsList = node({
get: async ({ get }) => {
// Read itemsList
const currentList = await get(itemsList);
subscription(() => {
// This will not run if itemsList emits a new result immediately
// before the previous result resolves.
runListSideEffect(currentList);
});
return applyFilter(currentList);
},
});
Nodes also accept a resolve
method from interface which wraps a given Promise such that it will only resolve if the node has not yet recomputed.
const filteredItemsList = node({
get: async ({ get, resolve }) => {
// Read itemsList
const currentList = await get(itemsList);
subscription(() => {
// This will not run if itemsList emits a new result immediately
// before the previous result resolves.
runListSideEffect(currentList);
});
// Prevent from resolving if recomputed
return resolve(applyFilter(currentList));
},
});
Nodes that return a Promise may also be converted into an ADT node which emits the stateful representation of the Promise result by using resource
. The resource node emits an object with the following fields:
status
: The status of the Promise result. Begins with"pending"
, and changes to either"success"
or"failure"
.data
: Value being represented bystatus
.- if
status === "success"
,data
is the resolved value. - if
status === "failure"
,data
is the rejected value. - if
status === "pending"
,data
is the resolving Promise instance.
- if
const listResource = resource(itemsList);
const example = node({
get: ({ get }) => {
const { status, data } = get(listResource);
// ...
},
});
A valid resource node can be reverted back to a promise using fromPromise
.
Multiple resources can be concurrently handled using waitForAll
or waitForAny
, which correspondingly behaves similarly to Promise.all
and Promise.race
.
const [name, age, email] = get(waitForAll([
nameResource,
ageResource,
emailResource,
]));
Factory
For producing multiple nodes with the same core logic but varying values, we can use factory
:
const nameFactory = factory({
// Similar to individual nodes except dynamic
key: (id) => `/profile/name/${id}`,
get: (id) => () => readProfileName(id),
});
// ...
const name = get(nameFactory(id));
factory
produces a function that passes the arguments provided to key
, get
and set
.
const updateName = factory({
key: (id) => `/profile/name/${id}/update`,
set: (id, defaultName) => ({ set }, name) => {
set(nameFactory(id), name ?? defaultName);
},
});
//...
set(updateName(id, 'John Doe'), newName);
If a factory returns a Promise, this factory can be wrapped with resourceFactory
to produce resource nodes.
License
MIT © lxsmnsyc