StreetStrider / fluh

simple & easy functional reactive library with atomic push strategy
Other
17 stars 1 forks source link
flyd frp functional-programming javascript streams

fluh

npm|fluh typescript

Functional reactive library with atomic push strategy

This library is designed to be easy to learn and simple to use. It may be meant as an answer to more complex systems which demand a lot from you even while solving simplest tasks.

fluh is sync-by-default event graph library with only push strategy. This particular design is easy to reason about. Most of the time it will work just as you expect it to work: reactive nodes pass values to dependents and fires effects after that. The order of firing is deterministic and straighforward.

The API is designed in a composable way, which means that it is really easy to create new transformations and utilities without tapping into the core. The API borrows heavily from functional paradigm, but without becoming it a burden. You still can use impure and stateful code, if you need. You can write code from left to right: there is chainable API and you don't need to use data-last functions if you don't want to.

The package is now mature enough for others to use it. You're welcome to try it.

The idea

When thinking of reactive stuff there're multiple spaces of decisions in which you have to make a choice.

Choosing some decisions will lead to a specific reactive system. Watch this speech about this vast decision space.

fluh is inspired by flyd in general. I want to use the reactivity for request-response systems, command-line applications and (especially) UIs. For that scope push strategy is good.

The main unit is called Bud.

If you think some of that decisions are not good, there's a great family of different opinions. Check out at least theese great ones: most.js, bacon.js, flyd, hareactive, xstream, graflow, S.js, RX, pull-stream, Highland, MobX.

API

Bud

Bud is a main FRP unit.

/* empty Bud (Nothing) */
const bud = Bud()

/* Bud with value */
const bud = Bud('value')

/* emit new values */
bud.emit('value 1').emit('value 2')

derivatives

const a = Bud()

/* derive from `a` (map) */
const b = join(a, (a) => a + 'b')

/* derive from both `a` and `b` */
const c = join(a, b, (a, b) => a + b + 'c')

/* skip (filter out, reject) values */
const n = join(a, (a) => Nothing)

/* return two values for each input (like transducer) */
const n = join(a, (a) => Many(a, a + 'x'))

/* derive from single Bud `a` */
const b = a.map((a) => a + 'b')

merging

const a = Bud()
const b = Bud()

/* merge all from `a` and `b` */
const c = merge(a, b)

/* diamond is also possible */
const a = Bud()
const b = join(a, (a) => a + 'b')
const c = join(a, (a) => a + 'c')
const d = merge(b, c)

high-order

const a = Bud()

/* delay must return function from Bud to Bud */
const b = a.thru(delay(50))

effects

const a = Bud()

/* subscribe to changes */
const disposer = a.on((value) => console.log('a:', value))

/* disposing of the effect */
disposer()

resources

/* create Bud from DOM Event */
function dom_event (element, eventname) {
    return resource((emit) => {
        element.addEventListener(eventname, emit)

        return function disposer () {
            if (! element) return

            element.removeEventListener(eventname, emit)

            /* allow gc to release Bud and target element earlier */
            element = null
            emit = null
        }
    })
}

/* create Bud from interval timer */
function interval (ms) {
    return resource((emit) => {
        let t = setInterval(emit, ms)

        return function disposer () {
            if (! t) return

            clearInterval(t)

            t = null
            emit = null
        }
    })
}

Interesting reading

atomic updates

fluh just like flyd solves atomic updates problem. This means that in case of graph A → B, A → C, B & C → D stream D indirectly depends twice on A, via B and C. fluh guarantees that in case of single emission on A dependent D would recieve update only once, with two updated values from B and C.

To do this, fluh recursively collects all dependencies of any A and orders them topologically. That order is lazily cached and is in use until graph changes. This gives excellent results for static graphs and optimal reordering when graph changes rarely.

order is used as a basis for cycle, so all dependencies will be updated in single pass, without using recursion.

See also flyd's atomic updates.

map with Nothing and Many

fluh's bud.map(fn) is very similar to functor protocol, however, with additional features. The thing with usual map is that it always returns single value, mapping functor from one value to another. If you need to skip values or add another values you need to use something like filter or flatMap. In some cases this is not enough and you need to address more complex tasks with the help of reduce or transducers.

fluh's map works in three ways:

  1. Ordinary map (return any values).
  2. Skip values. Return special symbol Nothing and current value will be skipped. This means no updates on dependencies, value would be just eliminated from flow.
  3. Return multiple values with Many(...values). Many, just like Nothing is a special type, so no collisions with arrays or other objects. If instance of Many is returned from map it will propagate further first value from it and, after graph is updated atomically, emit following values as additional usual emits.

So map covers all cases for map, filter and flatMap in a common manner.

high-order transformations

In practice, map covers most of the cases, but there're may be advanced tasks when you need to take a Bud, transform it (for instance, involving state) and return modified Bud: const new_bud = transform(bud).

In order to do this, fluh has bud.thru(transform) which accepts function from one Bud to another and returns result of invocation that function on this particular Bud.

Here's the example of how it can be used to make Bud async by default (by creating new dependent Bud which receives updates asynchronously):

function defer (bud) {
    const deferred = bud.constructor()

    bud.on((value) => {
        setTimeout(() => {
            deferred.emit(value)
        }
        , 0)
    })

    return deferred
}

Then use it via thru:

const a = Bud(1)
const b = a.thru(defer)
a.emit(2)

fluh exposes special helper for easier implementation of high-order transformations, called lib/trasfer. In terms of transfer previous defer example may be implemented in such manner:

function defer (bud) {
    return transfer(bud, (value, emit) => {
        setTimeout(() => emit(value), 0)
    })
}

handling errors

fluh does not capture throws by default, but you can make any function to do that, by decorating it with capture:

const a = Bud()

import { capture } from 'fluh'

const b = a.map(capture((x) => {
    /* throws in this function will be captured: */
    /* … throw e … */

    return x + 1
}))

From now, any throws will return raised error as normal returns instead.

Note that such function will return mixed data/error content. There's no special values aside from Nothing, Many and End. fluh treats Error objects as normal data, so you'll need additional steps to handle them.

import { when_data } from './map/when'

/* `when_data` allows to work with data in pure manner, */
/* passing past any `Error` instances and `End` */
/* only real data passed to target function */
const c = b.map(when_data((b) => b + 1))

There's no special error channel, use mixed content in combine with helper above if you need to handle errors. If you want a more pure approach, bring your own Either/Result container.

handling promises

fluh is sync by default. This decision makes whole graph predictable, allows to atomically update and opens a way for performance optimizations. Promise is just a regular value for fluh, so, in order to extract value from it, special high-order transformation is required. Such transformation will always resolve asynchronously, even if promise is already resolved.

fluh supports three strategies for resolving promises:

fluh promise transformations treats promise rejections as data values. So, the transformations will emit mixed data/error content. You'll need when_data to handle them.

TypeScript

This package has TypeScript definitions built in. The code is still vanilla JavaScript for the sake of speed and control.

Examples

Learn by examples. You can run examples/set-interval.ts example via npm run example set-interval. Run all examples by npm run examples.

To Users and Contributors

This is a project of mine to prove that simplified approach to FRP is viable. I develop and use this in private projects for a while (in fact, there're at least two years of various development activities behind). It works quite fine, it's very well tested and there're no strange and unexpected behaviors I've struggled with. The performance is good as well.

However, it lacks testing in battle conditions. There may be absence of operators you may deem to be must-have. Type definitions most likely to be not precise enough or even be incorrect. If you want to try this project you're welcome to try. Creating new transformations is easy in terms of map and thru. I would accept fixes, of course (for instance, better typings).

I also would accept new operators in most of the cases. The rule of thumb is that they must be generic enough (anything underscore-like or rxjs-like) and they must not have dependencies on their own. It is OK to have them in the base package, because it is not a burden for a consumer in server-side (HDD) nor in client-side (tree-shaking). If the new operator does depend on something (like throttle) it is better to create standalone package (like fluh-throttle or fluh-ratelimit for throttle/debounce/etc… in one package). Such package should have direct dependency on throttle/etc… and peer dependency on fluh.

If you'd have any problems while setting up a development copy of this package, I would easily help with this as well.

License

ISC, © Strider, 2022.