@oneii3/4iv
v0.0.12
Published
A novel, composable, unopinionated FRP library.
Downloads
3
Readme
4IV
4IV <pronounced "four four"> is a library for reactive programming, inspired by react, to ease the stress of composing data which changes over time. It takes the design decisions around react's state and effects, and applies it to general functions, decoupling the state from the view completely.
Its designed to be flexible and interoperable to allow for a progressive integration into your current apis.
Features
- 🔬 Tiny (35k unzipped)
- ⛓ Interoperable
- 🧩 Composable
- 🙌 Minimalistic and unopinionated
- 🔄 2 way binding
- 🤖 Typescript Support
- 🤷🏽♂️ It works!
Demos
//TBD
Installation
yarn
yarn add @oneii3/4iv
npm
npm i @oneii3/4iv --save
Basics
Concepts
On the surface, 4iv is just another reactive programming library. Where it differs is that the abstractions over state in other libraries is too opinionated, and makes coordinating and composing state more difficult that it should be.
Under the hood, 4iv uses a class called Action
which implements a modified Observer pattern.
interface Action<T> {
value: T;
constructor(initialValue: T, effects: ((next: T, prev: T) => void)[]);
next(value: T): this;
tick(): this;
then(effect: (next: T, prev: T) => void): this;
stop(effect: (next: T, prev: T) => void): this;
catch(effect: (error: Error | any, next: T, prev: T) => any): this;
}
Every call to the then
method adds a new effect to the Action
. When the next
method is subsequently called with a new value, the Action
's effects are signaled that a change has occurred, supplying the effects with the current and previous value of the action.
Example:
let action: Action<number> = new Action(5);
let effect = (next, prev) => {
console.log(prev, next);
};
action.then(effect).catch((e) => console.log("Error: " + e.message));
action.next(6); //logs: 5, 6
action.stop(effect);
action.next(7); // noop
action.next(new Promise((res, rej) => rej(new Error("Some Error")))); //logs: "Error: Some Error"
It's not very different from an Observable. In fact, it essentially is an observable with a slightly modified interface. What 4iv does different is abstract over this interface in order to provide easier, more coherent compositions of these observables. These abstractions allow 4iv to remain small, without losing the ablity to construct complex compositions of the Action
s.
All methods of the Action return the Action instance, which provides an action with a chainable api.
action
.then((value) => someMEthod(value + 1))
.next(9)
.next(10);
NOTE: Actions may only contain a single error handler (supplied via
.catch()
method). Subsequent calls tocatch
will override the previous handler. The error handler is called whenever a promise is rejected or if an effect throws an error.
Primitives
😀 State
State is a value that has a functional interface for changing it over time.
State
is a functional interface over an Action
, which also simultaneously maintains the interface of the underlying value.
type state<T> = (value: T, setter?: (...args: any[]) => any) => State<T>;
type State<T> = T & ((T) => T) & Action<T>;
You create one using the state function.
import { state } from "@oneii3/4iv";
let a: State<number> = state(1);
Since a state variable shares the same interface as the value that constructed it. This means, the state of a number
, will still work just like any other number
in your code. It just additionally acts as a setter for the next
or then
method of the underlying Action when it is called like as function, with either a new value or an effect respectively.
import { state } from "@oneii3/4iv";
let a = state(1);
assert(typeof a == "function");
assert(a instanceof Number);
assert(a == 1);
assert(a + 3 == 4);
a(2);
assert(a == 2);
assert(a(3) == 3);
When a state variable is called with a promise, it will internally await the value returned from the promise.
a(
new Promise((res) => {
setTimeout(() => {
res(10);
}, 1000);
})
); //a implicitly becomes 10 in 1 sec
Because the values supplied to a state variable may change at any time, to capture the values as they change, a function may also be supplied as an argument to the state setter. This adds the function to the state's effect queue. This effect
is called whenever the state variable changes values (more on this below).
a((next, last) => console.log(next, last));
a(3); //logs: 3, 2
When using a state variable as a function, it returns the value supplied. So to bind one state to another, simply compose them.
let b = state();
let c = a(b(1));
assert(a == 1);
assert(b == 1);
assert(c == 1);
Two state variables that are equal to the same value, are not equal to one another.
assert(a != b);
You may define a specialized setter for a state by supplying the state function with an optional setter as a second argument.
let b = state(1, (a, b) => a + b);
b(1, 4);
assert(b == 5);
The result of the setter function will become the value supplied to the next
method of the underlying Action
.
😃 Product
type product<T, U> = (
value: (last: T) => T,
dependencies: State<U>[]
) => Product<T>;
type Product<T> = State<T>;
A product is a state variable that is derived from two or more other state variables. When any of the input variables change, it recomputes its value. The changes are tracked by the values supplied to the depencency array and not the values used in the value function.
import { state, product } from "@oneii3/4iv";
let a = state(1);
let b = state(2);
let c = product(() => a + b, [a, b]);
assert(c == 3);
a(2);
assert(c == 4);
Since it is also a state variable itself, the value can also be changed directly. It will also still recompute once any of its inputs change.
c(0);
assert(c == 0);
NOTE: setting the product in this way will not affect the input variables in any way.
assert(a == 2); assert(b == 2);
😅 Byproduct
type byproduct<T> = (
product: (value?: T, lastValue?: T) => T,
quotient: (value?: T, lastValue?: T) => void,
deps: State<T>[] = []
) => Byproduct<T>;
type Byproduct<T> = State<T>;
A Byproduct
is a bidirectional Product
. What this means is that changing either its dependencies or the result of a byproduct, will cause either the product
effect or quotient
effect respectively in response to the change.
This is especially useful when data needs to be derived back to the input dependencies.
let x = state(1);
let y = state(2);
let z = byproduct(
(next, last) => x + y,
(next, last) => x(z - y),
[x, y]
);
assert(z == 3);
assert(x == 1);
z(5);
assert(z == 5);
assert(x == 3);
In the above example, the byproduct is able to maintain the constraints of all of its values, no matter which state gets changed. This becomes extremely valuable when components can be editted from two different sources, which dictate either the pieces or the derived value of a stateful value.
😄 Effect
Effects are simply the functions called in response to a state change. The effect
function however is sugar for composing state variables with an effect callback and calling it immediately.
import { state, effect } from "@oneii3/4iv";
let a = state(1);
let b = state(2);
effect(() => console.log({ a, b }), [a, b]); //logs: { a: 1, b: 2 }
//same as: a(b(() => console.log({ a, b }))).call()
a(2); //logs: { a: 2, b: 2 }
b(3); //logs: { a: 2, b: 3 }
a(2); //doesnt log
Since effects are called immediately, you can defer execution of the effect until the dependencies change for the first time, call effect.defer()
effect.defer(() => console.log({ a, b }), [a, b]); //nothing
a(10); // logs: { a: 10, b: 3 }
If you supply a product as a dependency, the effect will be triggered when either the product is set directly or being recomputed.
let c = product(() => `values: "${a} ${b}"`, [a, b]);
effect.defer(() => console.log(c), [c]);
a(b("hello"));
//logs: `values: "10 hello"`
//logs: `values: "hello hello"`
c("goodbye");
//logs: `goodbye`
b("world");
//logs: `values: "hello world"
Effect Nesting
Unlike react, There is no restriction to where you may place your state and effects. This means that you may define an effect within the body of an effect.
effect(()=>{
let c = state("Higher Order State");
let d = state("More State");
effect(()=>{
console.log("Higher Order Effects")
}, [c,d])
},[a,b])
These "Instance Effects/State", if not used responsibly, can pollute your effect chains, and cause infinite loops and runtime slowness. Be careful not to needlessly nest these structures.
😁 Event
event<T extends EventCallback> = ( callback: T ) => [
State<T>,
State<[
State<T[arguments]>,
State<T[return]>
]>
]
EventCallback = (...args: any) => any;
An event is a state variable which triggers its effects when the supplied callback is used.
import { event } from "@oneii3/4iv";
let [add, state] = event((a, b) => a + b);
effect(() => {
let [args, result] = state;
console.log(`${args} -> ${result}`);
}, [state]);
add(1, 2); //logs 1,2 -> 3
Comparisons
// TBD
API
Action
Instance
Action.prototype.next<T>(value: T): this
Used to change the value of the action; triggers effects if present.
Action.prototype.then<T>(effect: Effect<T>): this
Adds effect to effects set for Action.
Action.prototype.stop<T>(effect: Effect<T>): this
Removes effect from effects set of Action.
Action.prototype.toJson(): string
Returns a json string of action.
Static
Action.all<T>(state: State<T> | Action<T>, effect: Effect<T>): void
Binds the supplied state variables to the supplied effect. (adds effect to each state variables' effect set). Will call effect if, and only if, all of the states have changed at least once.
Action.any<T>(state: State<T> | Action<T>, effect: Effect<T>): void
Binds the supplied state variables to the supplied effect. Will call effect if any of the state variables change.
Action.once<T>(state: State<T> | Action<T>, effect: Effect<T>): void
Binds the supplied state variables to the supplied effect. Will call effect only once when any of the state variables change, then removes effect from each state variable.
Action.onceAll<T>(state: State<T> | Action<T>, effect: Effect<T>): void
Binds the supplied state variables to the supplied effect. Will call effect if, and only if, all of the states have changed at least once, then removes effect from each state variable.
Action.fromJson(json: string): State<T>
Turns json data into an Action instance.
Action.fromObject(obj: Record<string, any>): State<T>
Turns an Action record (object which follows json config for Action) into a state variable.
state
Instance
(Inherits from Action.prototype
)
Static
state.of( emitter: any, event: string ) : state<any>
state.of( event: string ) : state<any>
Creates a state variable from an event emitter (objects which possess an .on
or .addEventListener
method). If only the name of the event is provided, the emitter will default to window.document
.