purescript-reactive-effect
June 13, 2022 ยท View on GitHub
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.
Installation
NOTE: package is still unpublished, this won't work yet!
spago install reactive-effect
Documentation
Module documentation is published on Pursuit.
The examples directory contains some code samples that demonstrate how to use
the library.
Mini Tutorial
More comprehensive documentation is in the works. For now, this mini-tutorial should provide a basic overview of the library and its API.
Basic Concepts - Events and Behaviours
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.
Basic Concepts - Raff monad
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.
Running Raff Actions
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.
Creating New Events
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.
Creating New Behaviours
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.
Transforming events.
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
Note on Apply and Bind for Events
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.
Merging Events
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.
Accumulating State
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).
Flattening Nested Structures
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
Creating New Events (Advanced)
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.