@funkia/hareactive
v0.4.0
Published
Purely functional reactive library.
Downloads
115
Readme
Hareactive
Hareactive is a purely functional reactive programming (FRP) library for JavaScript and TypeScript. It is simple to use, powerful, and performant.
Key features
- Simple and precise semantics. This means that everything in the library can be understood based on a very simple mental model. This makes the library easy to use and free from surprises.
- Purely functional API.
- Based on classic FRP. This means that the library makes a distinction between behaviors and streams.
- Supports continuous time for expressive and efficient creation of time-dependent behavior.
- Integrates with declarative side-effects in a way that is pure, testable and uses FRP for powerful handling of asynchronous operations.
- Declarative testing. Hareactive programs are easy to test synchronously and declaratively.
- Great performance.
Introduction
Hareactive is simple. It aims to have an API that is understandable and easy to use. It does that by making a clear distinction between semantics and implementation details. This means that the library implements a very simple mental model. By understanding this conceptual model the entire API can be understood.
This means that to you use Hareactive you do not have to worry about things such as "lazy observables", "hot vs cold observables" and "unicast vs multicast observables". These are all unfortunate concepts that confuse people and make reactive libraries harder to use. In Hareactive we consider such things implementation detail that users should never have to think about.
Hareactive implements what is called classic FRP. This means that it makes a distinction between two types of time dependent concepts. This makes code written in Hareactive more precise and easier to understand.
Hareactive is powerful. It features all the typical methods found in other FRP libraries. But on top of that it comes with many unique features that are rarely found elsewhere. For instance, continuous time.
Table of contents
Installation
Hareactive can be installed from npm. The package ships with both CommonJS modules and ES6 modules
npm install @funkia/hareactive
Conceptual overview
Hareactive contains four key concepts: Behavior, stream, future and now. This section will describe each of these at conceptual level.
For a practical introduction into using Hareactive see the tutorial. Unless you're already familiar with classic FRP you should at least read the sections on behavior, stream and now before you dive into the tutorial.
Behavior
A behavior is a value that changes over time. For instance, the current position of the mouse or the value of an input field is a behavior. Conceptually a behavior is a function from a point in time to a value. A behavior always has a value at any given time.
Since a behavior is a function of time we can visualize it by plotting it as a graph. The figure below shows two examples of behaviors. The left behavior is what we call a continuous behavior since it changes infinitely often. The right behavior only changes at specific moments, but it's still a function of time. Hareactive is implemented so that both types of behavior can be represented efficiently.
It is important to understand that behaviors are not implemented as functions. Although, in theory, they could be. All operations that Hareactive offers on behaviors can be explained and defined based on the understanding that a behavior is a function of time. It is a mental model that can be used to understand the library.
Stream
A Stream
is a series of values that arrive over time. Conceptually
it is a list of values where each value is associated with a moment in
time.
An example could be a stream of keypresses that a user makes. Each keypress happens at a specific moment in time and with a value indicating which key was pressed.
Similarily to behaviors a stream can be visualized. But, in this case
we wont get a graph. Instead we will get some points in time. Each
point is called an occurrence. The value of an occurrence can be
anything. For instance, the figure to the left may represent a stream
of booleans where all the "low" stars represents an occurrence with
the value false
and the "high" stars represents true
.
The difference between a stream and a behavior is pretty clear when we see them visually. A behavior has a value at all points in time where a stream is a series of events that happens at specific moments in time.
To understand why Hareactive features both behavior and stream you may want to read the blog post Behaviors and streams, why both?.
Future
A future is a value associated with a certain point in time. For instance, the result of an HTTP-request is a future since it occurs at a specific time (when the response is received) and contains a value (the response itself).
Future has much in common with JavaScript's Promises. However, it is simpler. A future has no notion of resolution or rejection. That is, a specific future can be understood simply as a time and a value. Conceptually one can think of it as being implemented simply like this.
{time: 22, value: "Foo"}
The relationship between Future
and Stream
is the same as the
relationship between having a variable of a type and a variable that
is a list of that type. You wouldn't store a username as
["username"]
because there is always exactly one username.
Similarly in Hareactive we don't use Stream
to express the result of
a HTTP-request since a HTTP-request only delivers a response exactly
once. It is more precise to use a Future
for things where there is
exactly one occurrence and Stream
where there may be zero or more.
Future, stream or behavior?
At first, the difference between the three things may be tricky to understand. Especially if you're used to other libraries where all three are represented as a single structure (maybe called "stream" or "observable"). The key is to understand that the three types represent things that are fundamentally different. And that expressing different things with different structures is beneficial.
You could forget about future and use a stream where you'd otherwise
use a future. Because stream is more powerful than future. In the same
way you could always use arrays of values instead of just single
values. But you don't do that because username = "foo"
expresses
that only one username exists whereas username = ["foo"]
gives the
impression that a user can have more than one username. Similarly one
could forget about numbers and just use strings instead. But saying
amount = 22
is obviously better than amount = "22"
because it's
more precise.
This is how to figure out if a certain thing is a future, a stream or a behavior:
- Ask the question: "does the thing always have a current value?". If yes, you're done, the thing should be represented as a behavior.
- Ask the question: "does the thing happen exactly once?". If yes, the thing should be represented as a future. If no, you should use a stream.
Below are some examples:
- The time remaining before an alarm goes off: The remaining time always have a current value, therefore it is a behavior.
- The moment where the alarm goes off: This has no current value. And since the alarm only goes off a single time this is a future.
- User clicking on a specific button: This has no notion of a current value. And the user may press the button more than once. Thus a stream is the proper representation.
- Whether or not a button is currently pressed: This always has a current value. The button is always either pressed or not pressed. This should be represented as a behavior.
- The tenth time a button is pressed: This happens once at a specific moment in time. Use a future.
Now
Now
represents a computation that should be run in the present
moment. Hence the name "now". Now
is perhaps the most difficult
concept in Hareactive.
A value of type Now
is a description of something that we'd like
to do. Such a description can declare that it wants to do one of two
things.
- Get the current value of behavior. This is done with the
sample
function. Since aNow
-computation will always be run in the present it is impossible to sample a behavior in the past. - Describe side-effects. This is done with functions such as
perform
andperformStream
. With these functions we can describe things that should happen when a stream occurs.
Most Hareactive programs are bootstrapped by a Now
-computation. That
is, they take the form.
const main = ...
runNow(main);
Now
is closely tied to the concept of stateful behaviors which is
the topic of the next section.
How stateful behaviors work
A notorious problem in FRP is how to implement functions that return behaviors or streams that depend on the past. Such behaviors or streams are called "stateful"
For instance accumFrom
creates a behavior that accumulates values over
time. Clearly such a behavior depends on the past. Thus we say that
accumFrom
returns a stateful behavior.
Implementing stateful methods such as accumFrom
in a way that is both
intuitive to use, pure and memory safe is very tricky.
When implementing functions such as accumFrom
most reactive libraries in
JavaScript do one of these two things:
- Calling
accumFrom
doesn't begin accumulating state at all. Only when someone starts observing the result ofaccumFrom
is state accumulated. This is very counter intuitive behavior. - Calling
accumFrom
starts accumulating state from whenaccumFrom
is called. This is pretty easy to understand. But it makesaccumFrom
impure as it will not return the same behavior when called at different time.
To solve this problem Hareactive uses a solution invented by Atze van der Ploeg and presented in his paper "Principled Practical FRP". His brilliant idea gives Hareactive the best of both worlds. Intuitive behavior and purity.
The solution means that some functions return a value that, compared
to what one might expect, is wrapped in an "extra" behavior. This
"behavior wrapping" is applied to all functions that return a result
that depends on the past. The before mentioned accumFrom
, for instance,
returns a value of type Behavior<Behavior<A>>
.
Remember that a behavior is a value that depends on time. It is a
function from time. Therefore a behavior of a behavior is like a value
that depends on two moments in time. This makes sense for accumFrom
because the result of accumulating depends both on when we start
accumulating and where we are now.
To get rid of the extra layer of nesting we often use sample
. The
sample
function returns a Now
-computation that asks for the
current value of a behavior. It has the type (b: Behavior<A>) => Now<A>
. Using sample
with accumFrom
looks like this.
const count = sample(accumFrom((acc, inc) => acc + inc, 0, incrementStream));
Here count
has type Now<Behavior<A>>
and it represents a
Now
-computation that will start accumulating from the present
moment.
Flattening nested FRP values
The definition of higher-order FRP is that it allows for FRP primitives nested inside other FRP primitives. Combinations like streams of streams, behaviors of behaviors, streams of futures, and any others are possible.
The benefit of higher-order FRP is increased expressiveness that makes it
possibe to express many real-world scenarios with ease. One example would be an
application with a list of counters. Each counter has a value which can be
represented as a Behavior<number>
. A list of counters would then have the type
Array<Behavior<number>>
. If additionally the list itself can change (maybe new
counters can be added) then the type whould be
Behavior<Array<Behavior<number>>
. This higher-order type nicely captures that
we have a changing list of changing numbers.
The downside of higher-order FRP is that sometimes dealing with these nested types can be tricky. Hareactive provides a number of functions to help with this. The table below gives an overview.
| Outer | Inner | Function |
| -------- | -------- | -------------------------- |
| Behavior | anything | sample
(when inside Now) |
| Behavior | Behavior | flat
|
| Behavior | Stream | shiftCurrent
|
| Stream | Behavior | switcher
, selfie
|
| Stream | Stream | shift
|
| Stream | Future | n/a |
| Future | Behavior | switchTo
|
Tutorial/cookbook
This cookbook will demonstrate how to use Hareactive. The examples gradually increase in complexity. Reading from the top serves as an tutorial about the library.
Please open an issue if anything is unclear from the explanations given.
General
How do I apply a function to the value inside a behavior?
You can use the map
method. For instance, if you have a behavior of
a number you can square the number as follows. map
returns a new
behavior with all values of the original behavior passed through the function:
behaviorOfNumber.map((n) => n * n);
map
is also available as a function instead of a method.
map((n) => n * n, behaviorOfNumber);
Can I also apply a function to the occurrences in a stream?
Yes. Streams also have a map
method.
streamOfNumbers.map((n) => n * n);
The map
function also works with streams.
map((n) => n * n, streamOfNumbers);
If I have two streams how can I merge them into one with the occurrences from both?
This is done with the combine
method or the combine
function.
combine(firstStream, secondStream);
You can similarly combine any number of streams:
combine(firstStream, secondStream, thirdStream, etcStream);
How do I combine two behaviors?
Behaviors always have a current value. So to combine them you will
have to specify how to turn the two values from the two behaviors into
a single value. You do that with the lift
function.
For instance, if you have two behaviors of numbers you can combine them by adding their values together.
lift((n, m) => n + m, behaviorN, behaviorM);
You can also combine in this fashion any number of behaviors, which has to match the number of the function arguments:
lift((n, m, q) => (n + m) / q, behaviorN, behaviorM, behaviorQ);
How do I turn a stream into a behavior?
You probably want stepperFrom
:
const b = stepperFrom(initial, stream);
Creating behaviors and streams
Can I create a stream from events on a DOM element?
We've though of that. Hareactive comes with a function for doing just that:
streamFromEvent(domElement, "click");
Can I turn an item in localStorage
into a behavior?
Definitely. Yes. fromFunction
takes an impure function and turns it
into a behavior whose value at any time is equal to what the impure
function would return at that time:
const localStorageBehavior = fromFunction(() => localStorage.getItem("foobar"));
Debugging
My program isn't working. Is there an easy way to check what is going on in my behaviors or streams?
Both streams and behaviors have a log
method that logs to the
console when something happens.
misbehavingStream.log();
API
Future
Future.of<A>(a: A): Future<A>
Converts any value into a future that has "always occurred". Semantically Future.of(a)
is equivalent to (-Infinity, a)
.
fromPromise<A>(p: Promise<A>): Future<A>
Converts a promise to a future.
isFuture(f: any): f is Future<any>
Returns true
if f
is a future and false
otherwise.
Future#listen<A>(o: Consumer<A>): void
Adds a consumer as listener to a future. If the future has already occurred the consumer is immediately pushed to.
Stream
empty: Stream<any>
Empty stream.
~Stream.of<A>(a: A): Stream<A>
~
This function does not exist. Use empty
to create a dummy stream for testing purposes.
isStream(s: any): s is Stream<any>
Returns true
if s
is a behavior and false
otherwise.
apply<A, B>(behavior: Behavior<(a: A) => B>, stream: Stream<A>): Stream<B>
Applies a function-valued behavior to a stream. Whenever the stream has an occurrence the value is passed through the current function of the behavior.
filter<A>(predicate: (a: A) => boolean, s: Stream<A>): Stream<A>
Returns a stream with all the occurrences from s
for which
predicate
returns true
.
const stream = testStreamFromArray([1, 3, 2, 4, 1]);
const filtered = stream.filter((n) => n > 2);
filtered.semantic(); //=> [{ time: 1, value: 3 }, { time: 3, value: 4 }]
split<A>(predicate: (a: A) => boolean, stream: Stream<A>): [Stream<A>, Stream<A>]
Returns a pair of streams. The first contains all occurrences from
stream
for which predicate
returns true
and the other the
occurrences for which predicate
returns false
.
const whereTrue = stream.filter(predicate);
const whereFalse = stream.filter((v) => !predicate(v));
// is equivalent to
const [whereTrue, whereFalse] = split(predicate, stream);
filterApply<A>(predicate: Behavior<(a: A) => boolean>, stream: Stream<A>): Stream<A>
Filters a stream by applying the predicate-valued behavior to all occurrences.
keepWhen<A>(stream: Stream<A>, behavior: Behavior<boolean>): Stream<A>
Whenever stream
has an occurrence the current value of behavior
is
considered. If it is true
then the returned stream also has the
occurrence—otherwise it doesn't. The behavior works as a filter that
decides whether or not values are let through.
scanFrom<A, B>(fn: (a: A, b: B) => B, startingValue: B, stream: Stream<A>): Behavior<Stream<B>>
A stateful scan.
snapshot<B>(b: Behavior<B>, s: Stream<any>): Stream<B>
Creates a stream that occurs exactly when s
occurs. Every time the stream s
has an occurrence the current value of b
is sampled. The value in the
occurrence is then replaced with the sampled value.
const stream = testStreamFromObject({
1: 0,
4: 0,
8: 0,
12: 0
});
const shot = snapshot(time, stream);
const result = testStreamFromObject({
1: 1,
4: 4,
8: 8,
12: 12
});
// short == result
snapshotWith<A, B, C>(f: (a: A, b: B) => C, b: Behavior<B>, s: Stream<A>): Stream<C>
Returns a stream that occurs whenever s
occurs. At each occurrence
the value from s
and the value from b
is passed to f
and the
return value is the value of the returned streams occurrence.
shiftCurrent<A>(b: Behavior<Stream<A>>): Stream<A>
Takes a stream valued behavior and returns a stream that emits values from the current stream at the behavior. I.e. the returned stream always "shifts" to the current stream at the behavior.
shift
function shift<A>(s: Stream<Stream<A>>): Now<Stream<A>>;
Takes a stream of a stream and returns a stream that emits from the last stream.
shiftFrom
function shiftFrom<A>(s: Stream<Stream<A>>): Behavior<Stream<A>>;
Takes a stream of a stream and returns a stream that emits from the last stream.
changes
changes<A>(b: Behavior<A>, comparator: (v: A, u: A) => boolean = (v, u) => v === u): Stream<A>;
Takes a behavior and returns a stream that has an occurrence whenever the behaviors value changes.
The second argument is an optional comparator that will be used to determine
equality between values of the behavior. It defaults to using ===
. This
default is only intended to be used for JavaScript primitives like booleans,
numbers, strings, etc.
combine<A, B>(a: Stream<A>, b: Stream<B>): Stream<(A|B)>
Combines two streams into a single stream that contains the
occurrences of both a
and b
sorted by the time of their
occurrences. If two occurrences happens at the exactly same time then
the occurrence from a
comes first.
const s1 = testStreamFromObject({ 0: "#1", 2: "#3" });
const s2 = testStreamFromObject({ 1: "#2", 2: "#4", 3: "#5" });
const combined = combine(s1, s2);
assert.deepEqual(combined.semantic(), [
{ time: 0, value: "#1" },
{ time: 1, value: "#2" },
{ time: 2, value: "#3" },
{ time: 2, value: "#4" },
{ time: 3, value: "#5" }
]);
isStream(obj: any): boolean
Returns true
if obj
is a stream and false
otherwise.
isStream(empty); //=> true
isStream(12); //=> false
delay<A>(ms: number, s: Stream<A>): Stream<A>
Returns a stream that occurs ms
milliseconds after s
occurs.
throttle<A>(ms: number, s: Stream<A>): Stream<A>
Returns a stream that after occurring, ignores the next occurrences in
ms
milliseconds.
stream.log(prefix?: string)
The log method on streams logs the value of every occurrence using
console.log
. It is intended to be used for debugging streams during
development.
The option prefix
argument will be logged along with every value if specified.
myStream.log("myStream:");
Behavior
Behavior.of<A>(a: A): Behavior<A>
Converts any value into a constant behavior.
fromFunction<B>(fn: () => B): Behavior<B>
This takes an impure function that varies over time and returns a
pull-driven behavior. This is particularly useful if the function is
contionusly changing, like Date.now
.
isBehavior(b: any): b is Behavior<any>
Returns true
if b
is a behavior and false
otherwise.
whenFrom(b: Behavior<boolean>): Behavior<Future<{}>>
Takes a boolean valued behavior an returns a behavior that at any
point in time contains a future that occurs in the next moment where
b
is true
.
snapshot<A>(b: Behavior<A>, f: Future<any>): Behavior<Future<A>>
Creates a future than on occurence samples the current value of the behavior and occurs with that value. That is, the original value of the future is overwritten with the behavior value at the time when the future occurs.
stepTo<A>(init: A, next: Future<A>): Behavior<A>
From an initial value and a future value, stepTo
creates a new behavior
that has the initial value until next
occurs, after which it has the value
of the future.
switchTo<A>(init: Behavior<A>, next: Future<Behavior<A>>): Behavior<A>
Creates a new behavior that acts exactly like initial
until next
occurs after which it acts like the behavior it contains.
switcher<A>(init: Behavior<A>, s: Stream<Behavior<A>>): Now<Behavior<A>>
A behavior of a behavior that switches to the latest behavior from s
.
switcherFrom<A>(init: Behavior<A>, s: Stream<Behavior<A>>): Behavior<Behavior<A>>
A behavior of a behavior that switches to the latest behavior from s
.
stepperFrom<B>(initial: B, steps: Stream<B>): Behavior<Behavior<B>>
Creates a behavior whose value is the last occurrence in the stream.
scanFrom<A, B>(fn: (a: A, b: B) => B, init: B, source: Stream<A>): Behavior<Behavior<B>>
The returned behavior initially has the initial value, on each
occurrence in source
the function is applied to the current value of
the behaviour and the value of the occurrence, the returned value
becomes the next value of the behavior.
moment<A>(f: (sample: <B>(b: Behavior<B>) => B) => A): Behavior<A>
Constructs a behavior based on a function. At any point in time the value of the behavior is equal to the result of applying the function to a sampling function. The sampling function returns the current value of any behavior.
moment
is a powerful function that can do many things and sometimes it can do
them in a way that is a lot easier than other functions. A typical usage of
moment
has the following form.
moment((at) => {
...
})
Above, the at
function above can be applied to any behavior and it will
return the current value of the behavior. The following example adds together
the values of three behaviors of numbers.
const sum = moment((at) => at(aBeh) + at(bBeh) + at(cBeh));
The above could also be achieved with lift
. However, moment
can give better
performance when used with a function which dynamically switches which
behaviors it depends on. To understand this, consider the following contrived
example.
const lifted = lift((a, b, c, d) => a && b ? c : d, aB, bB, cB, dB);
Here the resulting behavior will always depend on both aB
, bB
, cB
,
dB
. This means that if any of them changes then the value of lifted
will be
recomputed. But, if for instance, aB
is false
then the function actually
only uses aB
and there is no need to recompute lifted
if any of the other
behaviors changes. However, lift
can't know this since the function given to
it is just a "black box".
If, on the other hand, we use moment
:
const momented = moment((at) => at(aB) && at(bB) ? at(cB) : at(dB));
Then moment
can simply check which behaviors are actually sampled inside the
function passed to it, and it uses this information to figure out which
behaviors momented
depends upon in any given time. This means that when aB
is false
the implementation can figure out that, currently, momented
only
depends on atB
and there is no need to recompute momented
when any of the
other behaviors changes.
moment
can also be very useful with behaviors nested inside behaviors. If
persons
is a behavior of an array of persons and is of the type Behavior<{
age: Behavior<number>, name: string }[]>
then the following code creates a
behavior that at any time is equal to the name of the first person in the array
whose age is greater than 20.
const first = moment((at) => {
for (const person of at(persons)) {
if (at(person.age) > 20) {
return person.name;
}
}
});
Achieving something similar without moment
would be quite tricky.
time: Behavior<Time>
A behavior whose value is the number of milliseconds elapsed since UNIX epoch.
I.e. its current value is equal to the value got by calling Date.now
.
measureTime: Now<Behavior<Time>>
The now-computation results in a behavior that tracks the time passed since its creation.
measureTimeFrom: Behavior<Behavior<Time>>
A behavior giving access to continuous time. When sampled the outer behavior gives a behavior with values that contain the difference between the current sample time and the time at which the outer behavior was sampled.
integrate(behavior: Behavior<number>): Behavior<Behavior<number>>
Integrate behavior with respect to time.
The value of the behavior is treated as a rate of change per millisecond.
integrateFrom(behavior: Behavior<number>): Behavior<Behavior<number>>
Integrate behavior with respect to time.
The value of the behavior is treated as a rate of change per millisecond.
behavior.log(prefix?: string, ms: number = 100)
The log method on behaviors logs the value of the behavior whenever it changes
using console.log
. It is intended to be used for debugging behaviors during
development.
If the behavior is a pull behavior (i.e. it may change infinitely often) then
changes will only be logged every ms
milliseconds.
The option prefix
argument will be logged along with every value if specified.
myBehavior.log("myBehavior:");
time.map(t => t * t).log("Time squared is:", 1000);
Now
The Now monad represents a computation that takes place in a given moment and where the moment will always be now when the computation is run.
Now.of<A>(a: A): Now<A>
Converts any value into the Now monad.
async<A>(comp: IO<A>): Now<Future<A>>
Run an asynchronous IO action and return a future in the Now monad that resolves with the eventual result of the IO action once it completes. This function is what allows the Now monad to execute imperative actions in a way that is pure and integrated with FRP.
sample<A>(b: Behavior<A>): Now<A>
Returns the current value of a behavior in the Now monad. This is possible because computations in the Now monad have an associated point in time.
performStream<A>(s: Stream<IO<A>>): Now<Stream<A>>
Takes a stream of IO
actions and return a stream in a now
computation. When run the now computation executes each IO
action
and delivers their result into the created stream.
performStreamLatest<A>(s: Stream<IO<A>>): Now<Stream<A>>
A variant of performStream
where outdated IO
results are ignored.
performStreamOrdered<A>(s: Stream<IO<A>>): Now<Stream<A>>
A variant of performStream
where IO
results occur in the same order.
plan<A>(future: Future<Now<A>>): Now<Future<A>>
Convert a future now computation into a now computation of a future. This function is what allows a Now-computation to reach beyond the current moment that it is running in.
runNow<A>(now: Now<Future<A>>): Promise<A>
Run the given Now-computation. The returned promise resolves once the future that is the result of running the now computation occurs. This is an impure function and should not be used in normal application code.
Contributing
Contributions are very welcome. Development happens as follows:
Install dependencies.
npm install
Run tests.
npm test
Running the tests will generate an HTML coverage report in ./coverage/
.
Continuously run the tests with
npm run test-watch
We also use tslint
for ensuring a coherent code-style.
Benchmark
Get set up to running the benchmarks:
npm run build
./benchmark/prepare-benchmarks.sh
Run all benchmarks with:
npm run bench
Run a single benchmark with:
node benchmark/<name-of-benchmark>
For example
node benchmark/scan.suite