State
Library for defining, updating, and accessing data models in a reactive and type-safe manner.
Because JavaScript is not a strongly-typed language, this library is intended to be used with TypeScript.
Installation
npm install @boninger-works/state
Usage
Add core imports.
import { State, IState, IStateReadOnly }
from "@boninger-works/state/library/core";
Instantiating
Define an interface to represent the state.
interface IOrder {
items: string[];
shipping: {
strategy: "ShipItemsTogether" | "ShipItemsImmediately";
speed: "OneDay" | "TwoDay" | "Standard";
};
offerCode?: string;
giftWrap?: {
giftReceipt: boolean;
personalMessage: string;
};
}
Create the generic state instance based the interface.
const state = State.create<IOrder>({
items: [],
shipping: {
strategy: "ShipItemsTogether",
speed: "Standard"
}
});
Subscribing
Subscribe to the entire state
const observable = state.observe();
observable.subscribe(order => {
console.log(order, "ENTIRE_STATE");
});
Subscribe to part of the state. The "items"
string below is strongly typed (not a magic string!).
const observable = state.to("items").observe();
observable.subscribe(items => {
console.log(items, "ONLY_ITEMS");
});
Subscribe to a nested part of the state. Note that the to()
method can be chained to continue to define the desired path.
const observable = state.to("shipping").to("speed").observe();
observable.subscribe(shipSpeed => {
console.log(shipSpeed, "ONLY_SHIP_SPEED");
});
Handling Undefined
Keys provide return types that are aware of possibly incomplete paths. This occurs if an ancestor of the desired property can potentially be undefined
or null
. As a result, the keys type will reflect this by adding undefined
as part of the generic type.
Without TypeScript's
strictNullChecks
compiler option enabled, this effect is not visible because anything can always beundefined
ornull
.
const path = state.to("giftWrap").to("giftReceipt");
const observable = path.observe();
observable.subscribe(giftReceipt => {
console.log(giftReceipt, "GIFT_RECEIPT_OR_UNDEFINED");
});
However, it can be desirable to deal with only defined values. In this case, the defined()
operator can be used to filter the observable created from the keys.
Add operator imports.
import { defined }
from "@boninger-works/state/library/operators";
const path = state.to("giftWrap").to("giftReceipt");
const observable = path.observe().pipe(defined());
observable.subscribe(giftReceipt => {
console.log(giftReceipt, "GIFT_RECEIPT");
});
Getting
Get the state. Returns the current version of the state.
const order = state.get();
console.log(order);
Updating
Update the entire state.
state.set({
items: [],
shipping: {
strategy: "ShipItemsImmediately"
speed: "OneDay"
}
});
Update part of the state.
state.to("items").set([
"popularBook",
"goodMovie",
"sweetMusic"
]);
Batch update the state.
state.batch(batch => {
batch.to("offerCode").set("EVERYTHING4FREE");
batch.to("giftWrap").set({
giftReceipt: true,
personalMessage: "Here is something special for you!"
});
});
Transforming
Add transform imports.
import {
push, unshift, pop, shift, filter, insert, remove,
increment, decrement, add, subtract, multiply, divide,
set, unset
}
from "@boninger-works/state/library/transforms";
Tranform part of the state. In this case, add an item to the array of items.
state.to("items").transform(push("tastyTreat"));
Opting Out of Type-Safety
If the need arises, keys can be created in a non-type-safe manner. Note that the keysUnsafe
method takes a single array argument, which is different from the keys
method, which builds type-safe keys.
const keysUnsafe = state.keysUnsafe(["one", "two", "three"]);
const observableUnsafe = state.path(keysUnsafe).observe();
observable.subscribe(anything => {
console.log(anything, "ANYTHING");
});
Remarks
Emissions
When the observe()
method is used to subscribe to part of a state, the returned observable is automatically configured in the following ways:
- It will immediately emit the current value for the defined path.
- It will emit any future changes to that value.
The observable is configured to act only on the specific slice of the state that was requested. For instance, if
items
is subscribed to in the above examples, then updates to theshipping
property of the state will not emit for theitems
subscriber because the value ofitems
has not changed.
Batching
When batching updates, the state batcher is immediately updated with each call to the set()
or transform()
method, but the value is not set and emitted for the state being acted on until the very end. Batching can be used to solve one of the following two problems:
- Small frequent updates are causing too many emissions to subscribers.
- Individual updates would make a state emit an object which is not correct until other updates are also applied.
Batch operations can also be emitted on demand in the middle of the batch using the emit()
method.
The current value of the batch state can be retrieved with get()
during the batch operation.
Read Only
The IStateReadOnly
interface provides read-only state functionality. This is available through the readOnly
property on IState
. It is preferable to use the IStateReadOnly
stored on the readOnly
property because it has better protection from unwanted writes than IState
itself.
Immutability
The intent of the state is always to avoid mutating references that are stored within it. Methods provided by this library will always create shallow copies of reference types to avoid mutations while avoiding unnecessary memory allocations for references that do not need modification. However, it is also important to understand that reference types retrieved from the state do not have any inherent protection. Enforcing or respecting immutability of these references is the responsibility of the the implementer in this case, because the state cannot control the generic type that is provided to it.