Higher-order FRP with a pure reference implementation and an efficient push-pull graph traversal implementation.
The implementation is inspired by Reflex FRP and Reactive Banana. The API is modeled in the spirit of Aff
.
NOTE: package is still unpublished, this won't work yet!
spago install reactive-effect
Module documentation is published on Pursuit.
The examples
directory contains some code samples that demonstrate how to use
the library.
More comprehensive documentation is in the works. For now, this mini-tutorial should provide a basic overview of the library and its API.
Functional reactive programming (FRP) is a composable abstraction for working with data with respect to time. This is represented by two core types:
-- A stream of discrete occurances of `a` values over time.
data Event t a
-- A value of type `a` that changes over time.
data Behaviour t a
Events represent push-based data sources while behaviours represent pull-based data sources. Another way to say this is that an event tells you when it fires a value (you can react to them), and you ask a behaviour what its value is (you can sample them). You cannot, however, sample an event - it only has a value when it fires,and you cannot react to a behaviour's value changing (it changes continuously).
For example, you might use an Event
to represent mouse clicks or a clock
tick, and you might use a Behaviour
to represent the position of the mouse
cursor or the current time.
When writing an application with reactive-effect
, you create new events and
behaviours and connect them to the outside world from within the Raff
monad
(short for "ReActive eFFect"). The Raff
monad is a reader monad that has
access to the (mutable) network of events and behaviours being built.
-- A monadic context that allows new events and behaviours to be created and
-- connected to the outside world.
data Raff t a
The t
type variable is a phantom type variable that restricts the scope of
Events
and Behaviours
to the top-level Raff
action that created them. It
is instantiated in the rank-2 signature:
launchRaff :: forall a. (forall t. Raff t (Event t a)) -> Aff a
As events and behaviours are essentially handles to mutable data, this
restriction prevents them from leaking into a context where they could cause
unexpected state mutation (the same reason for the s
parameter in ST s a
).
t
is short for "timeline" - i.e. each call to launchRaff
creates a new
timeline for the provided action to run in.
The entrypoint API for Raff
actions is:
launchRaff :: forall a. (forall t. Raff t (Event t a)) -> Aff a
launchRaff_ :: forall a. (forall t. Raff t (Event t a)) -> Aff Unit
The provided Raff
action is referred to as the "guest application", which
launchRaff
runs inside an Aff
host. The guest application returns an event
which it can fire when it wants to be terminated by the host. When the host
detects that the shutdown event has fired, it stops running the guest, cleans up
resources acquired by the guest, and returns the value fired by the shutdown
event.
There are 2 primary API functions for creating new events:
-- these type signatures are slight simplifications:
newEvent :: forall a. Raff { event :: Event a, fire :: a -> Effect Unit }
makeEvent :: forall a. ((a -> Effect Unit) -> Effect (Effect Unit)) -> Raff (Event a)
newEvent
is the simplest way to create an event - it simply returns a record
containing the new event and an Effect
that can fire the event imperatively.
Note that because fire
is an Effect
and not a Raff
action, it can be used
in arbitrary contexts.
makeEvent
is creates an event from an async callback. Breaking down the
signature a little more:
-- Setup action (A):----------------------\
-- Fire action (B):----------\ \
-- Teardown action (C):----------------------------------\
-- \ \ \
-- |--------------| \ |----------|
-- |-----------------------------------------|
makeEvent :: forall a. ((a -> Effect Unit) -> Effect (Effect Unit)) -> Raff (Event a)
The setup action (A)
will be run when the Event
is first subscribed to
(not when makeEvent
is called! See more below). The setup action receives a
fire action (B)
which will cause the resulting Event
to fire. The fire action
can be used zero or more times by the setup action, which can, for instance,
spawn a new Aff
fiber (using launchAff
) to fork an asynchronous process to
fire the event as needed. The setup action returns a teardown action (C)
,
which can be used to release resources when the event is unsubscribed from
(e.g., remove event listeners added by the setup action, or cancel pending Aff
fibers created using launchAff
.
The API for creating behaviours is almost the same as that for creating events:
-- these type signatures are slight simplifications:
newBehaviour :: forall a. a -> Raff { behaviour :: Behaviour a, update :: a -> Effect Unit }
makeBehaviour :: forall a. a -> ((a -> Effect Unit) -> Effect (Effect Unit)) -> Raff (Behaviour a)
The main difference is that both functions accept an initial value a
(since
behaviours must always have a value). In addition, the fire action
is instead
referred to as the update action
, as you don't "fire" behaviours.
Under the hood, these API functions are simply implemented by composing their
corresponding Event
API functions with the stepper
combinator.
Event
has a Functor
instance, so event values can be transformed with pure
functions via map
. This is the most straightforward means to manipulate
events.
Events can also be filtered. Instances for Compactable
and Filterable
are
provided. From Compactable
, you can take an Event (Maybe a)
and create an
Event a
which only fires when the original event fires Just
values using
compact
(similar to catMaybes
for arrays). You can also turn an
Event (Either a b)
into a record { left :: Event a, right :: Event b }
using separate
. From Filterable
, you can filter and partition events with
predicates (using filter
and partition
, respectively), and you can combine
the behaviour of map
and compact/separate
using filterMap
and
partitionMap
.
The indexed
combinator tags occurances of an event with a monotonically
increasing counter value. Every time the input event fires, the output event
will fire the next counter value alongside the original value.
liftSample2
can be used to sample a behaviour with an event:
liftSample2 :: forall t a b c. (a -> b -> c) -> Behaviour t a -> Event t b -> Event t c
It takes a binary function, a behaviour, and an event, returning a new event
ala lift2
. Whenever the input event fires, it samples the current value of
the input behaviour and applies the value to the given function, along with the
value fired. The resulting even fires this value.
The composable version of this is sampleApply
, or its more commonly used
infix operator form <&>
, which has a signature similar to apply
/ <@>
:
sampleApply :: forall t a b. Behaviour t (a -> b) -> Event t a -> Event t b
As an extension of sampling behaviours, values in behaviours can also be used
to filter events. See gate
, split
, filterApply
, and partitionApply
.
There are some operations for events (notably accumulating state) that are not
pure functions. These can only be run in the Raff
monad (or any monad that has
an instance of MonadRaff
, such as RaffPush
). In order to support using these
combinators dynamically (i.e. when events fire), instead of limiting them to the
static Raff
context, there is a special combinator which runs a RaffPush
action when an event fires.
push :: forall t a b. (a -> RaffPush t (Maybe b)) -> Event t a -> Event t b
Which has an infix operator form <~
and a flipped infix operator form ~>
.
There is also an alias pushed = push identity
, and a less flexible (but often
more convenient) form which doesn't allow the provided action to omit values
fired by the parent event by returning Nothing
:
pushAlways :: forall t a b. (a -> RaffPush t b) -> Event t a -> Event t b
Events also have Apply
and Bind
instances, but their utility is rather
limited. In the expression let e3 = e1 <*> e2 in e3
, the event e3
fires only
when both e1
and e2
are firing simultaneously (i.e. they fire during the
same frame). This means <*>
is normally not suitable for merging two unrelated
events (for this you should use align
). However, it can be useful for merging
events that were fanned out from the same source:
let e1 = map f e0
let e2 = map g e0
...
let e3 = Tuple <$> e1 <*> e2
In this example, e3
will fire once for every time e0
fires. Of course, the
above could be written more succinctly as let e3 = map (f &&& g) e0
, but
there may be cases when it makes sense to fan an event out into multiple events
and later merge a subset of them back together.
The Bind
instance is even more limited. In the expression let e1 = f =<< e0
,
f
will be called every time e0
fires. The event e1
will fire if and only
if the event returned by f
happens to be firing during the same frame that
e0
fired. As a result, bind
and join
are not usually the preferred means
of flattening events that fire other events (this would be switchE
). The Bind
instance does however cover the one case that switchE
misses - the inner
event firing at the same time as the outer event. Therefore, it is available in
the rare cases where this is important to capture (switchEImmediately
combines both of these behaviours).
There is no Applicative
(and hence, no Monad
instance) because the only
lawful behaviour for pure
would be an event that is always firing, which seems
like a bad idea. No other behaviour would satisfy the left and right identity
laws for Applicative
.
There are several ways to merge two or more events together into a single
event, and all of them are variations of the align
combinator (from the
Align
class). Align
has the following signature (specialized to events):
align :: forall t a b c. (These a b -> c) -> Event t a -> Event t b -> Event t c
-- Given
data These a b = This a | That b | Both a b
The interpretation is that given two events and a function for merging their
values, align
is able to create a new event whenever either (or both) of the
input events are firing. This can be chained with further calls to align
to
merge arbitrarily many events.
The Semigroup
instance uses align
to implement append
by using append
to handle the Both
case. The Alt
instance uses align
to implement alt
by ignoring the right hand value in case of a Both
. Even the Apply
instance
is defined in terms of a variation of align
by only allowing Both
values
through. align
has a variation for filtering occurences, alignMaybe
, one
for performing an impure merge, alignM
, and one that allows both,
alignMaybeM
.
mempty
, empty
, and nil
(from Monoid
, Plus
, and Alternative
) all
mean the same thing: they are events that never fire any values.
Naturally, implementing interesting interactive programs involves updating state. There are 4 primary functions for doing this, each with several minor variations:
accumE :: forall t a b. (b -> a -> b) -> b -> Event t a -> Raff t (Event t b)
accumB :: forall t a b. (b -> a -> b) -> b -> Event t a -> Raff t (Behaviour t b)
accumAccum
:: forall t a b
. (b -> a -> Accum b c)
-> b
-> Event t a
-> Raff t (Accum (Behaviour t b) (Event t c))
In each case, a folding function is provided to update some state b
whenever
an event fires, using the previous value of the state. accumE
fires an event
when the state updates, and accumB
returns a behaviour that updates its value
to the current state when it changes. mapAccum
is an efficient combination of
the two. It allows a state of type b
to be accumulated, while emitting events
of type c
.
There are variations of each of these for omitting state updates and event
occurences, as well as performing inpure state updates (in RaffPush
).
Inevitably when writing a sufficiently complex application, you find yourself
with an Event t (Event t _)
, or an Behaviour t (Event t _)
, etc... In order
to use these, you often need to flatten them to a single layer, and there a few
ways to do so:
switch :: forall t a. Behaviour t (Event t a) -> Event t a
switcher :: forall t a. Behaviour t a -> Event t (Behaviour t a) -> Raff t (Behaviour t a)
switchE :: forall t a. Event t a -> Event t (Event t a) -> Raff t (Event t a)
-- Given by `Bind` instances
join :: forall t a. Event t (Event t a) -> Event t a
join :: forall t a. Behaviour t (Behaviour t a) -> Behaviour t a
There is a more advanced event creation function, makeEventWithFireCallback
that is very similar to makeEvent
except that the fire action accepts an
additional parameter:
-- Setup action (A):-----------------------------------------------------\
-- Fire action (B):----------------------------------\ \
-- Teardown action (C):-------------------------------\--------------------\------------\
-- Fire callback (D):----------------------------\ \ \ \
-- |--------\--------------------| \ |----------|
-- |----------\--------------------------------------------|
-- |---------|
makeEventWithFireCallback :: forall a. ((a -> Effect Unit -> Effect Unit) -> Effect (Effect Unit)) -> Raff (Event a)
The additional parameter fire callback (D)
is called when the Event
has
actually been fired. The reason why this is helpful may not be immediately
evident. To explain why, it is necessary to understand a little bit about how
things work under the hood. Invoking fire action
does not immediately cause
the event to fire. Instead, it writes an item to a Queue
to be run inside a
frame by the machinery setup inside launchRaff
. As a result, when fire action
returns, the event will not have fired yet. If you need to perform a specific
action when the event has actually fired (specifically, when event propagation
has finished in the frame in which the event was fired), you can pass that
action to fire action
, and it will be run when the event eventually fires.