Functional reactive programming
Effect systems
What wouldn't we do in the name of purity? Probably a lot of stuff… But read on!
So far, our journey through FRP has focused on the pure, unadultered, unblemished, obsidian-black world of Event
s and Poll
s. But let's get real - one day, you’re gonna have to make a network call. And when you do, you’ll have to decide between doing something quick and dirty or going down the rabbit hole of effect systems. We hope you choose the second option, and this section is about the many wonderful 🐇🐇🐇 you’ll find there!
Sampling
Before we look at effect systems, it's important to understand sampling in FRP. It's the mildest type of effect - one that grafts the temporality of one event onto another. It's also unlike other effect systems we’ll see later down the line because we can accomplish these directly with combinators over Event
andPoll
. This section will look at the basic mechanisms for sampling, exploring the various functions available in purescript-hyrule
as well as some important corner cases to be aware of.
How sampling works
We'll start our journey through sampling with sampleOnRight
aka <|**>
. The signature is:
sampleOnRight :: forall a b. event a -> event (a -> b) -> event b
The function listens for changes to the event on the right, recalling the most recent value from the event on the left to produce a b
. The event on the left, on the other hand, has no incedence on the emission of b
with one exception - it must fire at least once so that b
can be produced. That is, until it has fired, emissions of (a -> b)
won't result in a b
being emitted. Once an a
is fired, each subequent (a -> b)
will produce a b
.
To see this in action, let's concoct an example with two sliders. Both sliders do not emit an initial value, meaning they need to be moved in order for a value to be registered. Because of this, the system will only start producing values once both sliders have been moved, but it will only respond to the right slider. To see this, move the right (nothing), then the left (still nothing), then the right (bam!).
VITE_START=HowSamplingWorks pnpm example
module Examples.HowSamplingWorks where
import Prelude
import Deku.Toplevel (runInBody)
import Data.Tuple.Nested ((/\))
import Deku.DOM.Attributes as DA
import Deku.Control (text)
import Deku.Core (fixed)
import Deku.DOM as D
import Deku.Do as Deku
import Deku.Hooks (useState')
import Deku.DOM.Listeners as DL
import Deku.Toplevel (runInBody)
import Effect (Effect)
import ExampleAssitant (ExampleSignature)
import FRP.Event.Class ((<|**>))
import Deku.Toplevel (runInBody)
main :: Effect Unit
main = void $ runInBody Deku.do
setSlider1 /\ slider1 <- useState'
setSlider2 /\ slider2 <- useState'
fixed
[ D.div [ DA.klass_ "flex justify-around" ]
[ D.input [ DA.xtypeRange, DL.numberOn_ DL.input setSlider1 ] []
, D.input [ DA.xtypeRange, DL.numberOn_ DL.input setSlider2 ] []
]
, text
( slider1 <|**>
((\a b -> show b <> " " <> show a) <$> slider2)
)
]
If you compare the defition of sampleOnRight
to that of Poll
that we learned in Polls, you’ll see an interesting similarity. When an argument of type Event a
is applied to sampleOnRight
, it produces a Poll
.
type Poll a = forall b. Event (a -> b) -> Event b
type SampleOnRight a =
Event a -> (forall b. Event (a -> b) -> Event b)
This also allows us to write a definition for the step
function we saw in the Polls section.
step :: forall a. a -> Event a -> Poll a
step a e = sampleOnRight (pure a <|> e)
Flipping the temporality
We can flip which event controls time by using sampleOnLeft
aka <**|>
. The signature is the same as sampleOnRight
, namely:
sampleOnLeft :: forall a b. event a -> event (a -> b) -> event b
The example below is the same as the one above save time control - now, the slider on the left controls time. Make sure to move the slider on the right at least once to capture a value!.
VITE_START=FlippingTheFunctionAndArgument pnpm example
module Examples.FlippingTheFunctionAndArgument where
import Prelude
import Deku.Toplevel (runInBody)
import Data.Tuple.Nested ((/\))
import Deku.DOM.Attributes as DA
import Deku.Control (text)
import Deku.Core (fixed)
import Deku.DOM as D
import Deku.Do as Deku
import Deku.Hooks (useState')
import Deku.DOM.Listeners as DL
import Deku.Toplevel (runInBody)
import Effect (Effect)
import ExampleAssitant (ExampleSignature)
import FRP.Event.Class ((<**|>))
import Deku.Toplevel (runInBody)
main :: Effect Unit
main = void $ runInBody Deku.do
setSlider1 /\ slider1 <- useState'
setSlider2 /\ slider2 <- useState'
fixed
[ D.div [ DA.klass_ "flex justify-around" ]
[ D.input [ DA.xtypeRange, DL.numberOn_ DL.input setSlider1 ] []
, D.input [ DA.xtypeRange, DL.numberOn_ DL.input setSlider2 ] []
]
, text
( slider1 <**|>
((\a b -> show b <> " " <> show a) <$> slider2)
)
]
Biasing a side of sampling
Even though sampleOnLeft
and sampleOnRight
control the temporality of which side to sample from, there's an important corner case to consider: when the events are “cotemporal”.
Of course, in the browser, nothing can happen truly simultaneously on the UI thread - there will always be a before/after relationship. But from the viewpoint of a program, things may seem cotemporal. The easiest way to see this is by using the same event as a source. Let's recreate both examples above using a single slider.
VITE_START=BiasingASideOfSampling pnpm example
module Examples.BiasingASideOfSampling where
import Prelude
import Deku.Toplevel (runInBody)
import Data.Tuple.Nested ((/\))
import Deku.DOM.Attributes as DA
import Deku.Control (text)
import Deku.Core (fixed)
import Deku.DOM as D
import Deku.Do as Deku
import Deku.Hooks (useState')
import Deku.DOM.Listeners as DL
import Deku.Toplevel (runInBody)
import Effect (Effect)
import ExampleAssitant (ExampleSignature)
import FRP.Event.Class ((<|**>), (<|*>), (<**|>), (<*|>))
import Deku.Toplevel (runInBody)
main :: Effect Unit
main = void $ runInBody Deku.do
setSlider /\ slider <- useState'
fixed
[ D.div [ DA.klass_ "flex justify-around" ]
[ D.input [ DA.xtypeRange, DL.numberOn_ DL.input setSlider ] [] ]
, text
( slider <|**>
((\a b -> show b <> " " <> show a) <$> slider)
)
, D.br_ []
, text
( slider <**|>
((\a b -> show b <> " " <> show a) <$> slider)
)
, D.br_ []
, text
( ((\a b -> show a <> " " <> show b) <$> slider)
<|*> slider
)
, D.br_ []
, text
( ((\a b -> show a <> " " <> show b) <$> slider)
<*|> slider
)
]
As you move the slider, a curious situation appears on lines 2 and 4, n'est-ce pas? Even though the events are cotemporal, the second one lags after the first. What gives?
In the browser, one thing always happens after another thing. Technically that's probably true about everything. But let's stick to the browser. In examples 1 and 3 we use left-biased operators, and in examples 2 and 4, we use right-biased operators. In purescript-hyrule
, the left-biased operators always emit the left event first, which means that for cotemporal events, the left emits before the right. When we then use the right to control the timing, the left is hydrated with the most recent result.
On the other hand, the right-biased operators always emit the right value before the left one. So when we use the right to control the timing, the left does not have the most recent value yet and we perceive a lag.
So which one is better? You decide! The framework lets you opt into whichever poll suites your needs.
Sampling the impure
Sometimes, you’ll want to sample something impure, like an API or radioactive emissions. This section will show a few strategies when purity is not an option (and let's be real, is it ever an option?).
Sampling a poll with an event
Let's start by using an interval to sample an API call. We have two different types of effects here:
- The interval is an ex nihilo effect, meaning that it speaks Latin. No, just kidding, it means that it doesn't depend on any other effect. It just happens, sort of like my forgetfulness or the hummus in my fridge disappearing.
- The API call is a rider effect. It depends on the interval effect, because we want to poll the API every n seconds.
VITE_START=SamplingAPollWithAnEvent pnpm example
module Examples.SamplingAPollWithAnEvent where
import Prelude
import Deku.Toplevel (runInBody)
import Affjax.ResponseFormat as ResponseFormat
import Affjax.Web as AX
import Control.Alt ((<|>))
import Data.Argonaut.Core (stringifyWithIndent)
import Data.Either (Either(..))
import Data.Op (Op(..))
import Deku.Control (text)
import Deku.Toplevel (runInBody)
import Effect (Effect)
import Effect.Aff (error, killFiber, launchAff)
import Effect.Class (liftEffect)
import Effect.Ref as Ref
import ExampleAssitant (ExampleSignature)
import FRP.Event.Time (interval')
import FRP.Poll (sham)
import Fetch (Method(..))
import Deku.Toplevel (runInBody)
main :: Effect Unit
main = do
fiber <- Ref.new (pure unit)
apiz <- interval' (withAPICall fiber) 2000
void $ runInBody do
text
( pure "Fetching..." <|>
(pure "Here's a random user: " <> sham apiz.event)
)
where
withAPICall :: forall a. _ -> Op (Effect Unit) String -> Op (Effect Unit) a
withAPICall fiber (Op ff) = Op \_ -> do
fb <- launchAff do
f <- liftEffect $ Ref.read fiber
killFiber (error "cancelling") f
result <- AX.request
( AX.defaultRequest
{ url = "https://randomuser.me/api/"
, method = Left GET
, responseFormat = ResponseFormat.json
}
)
liftEffect case result of
Left err -> ff (AX.printError err)
Right response -> ff
(stringifyWithIndent 2 response.body)
Ref.write fb fiber
Fetching...
Gating events on polls
In the previous section, we saw examples of network calls being pushed to the boundary. But you can push entire effect systems to the boundary. To do that, we’ll use combinators like withTime
and withRandom
s. We'll stitch them together using function composition.
In the example below, we’ll make a gate using the mouse position. The mouse will pause the flicker when it's on the right side of the screen (sorry if you’re on mobile, no 🐁 for you!), and otherwise the flicker will continue between random hues of a similar color. Aside from my mad HTML rave skillz, the important thing to note here is how two combinators - the mouse and the randomness - are combined into a smöl effect system using function composition.
VITE_START=GatingEventsOnPolls pnpm example
module Examples.GatingEventsOnPolls where
import Prelude
import Deku.Toplevel (runInBody)
import Control.Alt ((<|>))
import Data.DateTime.Instant (unInstant)
import Data.Newtype (unwrap)
import Data.Number ((%))
import Deku.Control (text_)
import Deku.DOM as D
import Deku.DOM.Attributes as DA
import Deku.Toplevel (runInBody)
import Effect (Effect)
import ExampleAssitant (ExampleSignature)
import FRP.Event.Time (interval)
import FRP.Poll (sham)
import Deku.Toplevel (runInBody)
main :: Effect Unit
main = do
let labelMe label = map { label, time: _ }
pink <- interval 200
green <- interval 165
void $ runInBody do
D.div
[ DA.klass
$
sham
( labelMe "bg-pink-300" pink.event <|>
labelMe "bg-green-300" green.event
) <#> \{ label, time } ->
if ((unwrap $ unInstant time) % 4000.0 < 2000.0) then label
else "bg-pink-800"
]
[ text_ "Par-tay!" ]
Par-tay!
Multiplexing
The hyrule
library has several helpful combinators for building effect systems at the boundaries of your application. One of them is withMultiplexing
, which allows you to mix several combinators. Rather than using it from the library, we’ll define it inline just so you can see how short these combinators can be. The idea is that, as you get more comfortable with them, you can roll your own!
VITE_START=Multiplexing pnpm example
module Examples.Multiplexing where
import Prelude
import Deku.Toplevel (runInBody)
import Control.Alt ((<|>))
import Data.Compactable (compact)
import Data.Either (hush)
import Data.Foldable (for_)
import Data.Functor.Contravariant (cmap)
import Data.Op (Op(..))
import Deku.Control (text)
import Deku.Toplevel (runInBody)
import Effect (Effect)
import ExampleAssitant (ExampleSignature)
import FRP.Event.Time (interval', withDelay)
import FRP.Poll (sham)
import Deku.Toplevel (runInBody)
withMultiplexing
:: forall a b
. Array (Op (Effect Unit) b -> Op (Effect Unit) a)
-> Op (Effect Unit) b
-> Op (Effect Unit) a
withMultiplexing l op = Op \a ->
for_ l \f -> let Op x = f op in x a
main :: Effect Unit
main = do
let
ms = 967
loop = 16 * ms
beat w t op = withDelay (t * ms) (cmap (hush >>> (_ $> w)) op)
beats =
[ beat "Work it" 0
, beat "Make it" 1
, beat "Do it" 2
, beat "Makes us" 3
, beat "Harder" 8
, beat "Better" 9
, beat "Faster" 10
, beat "Stronger" 11
]
dj <- interval' (withMultiplexing beats) loop
void $ runInBody do
text $ (pure "Wait for it") <|> sham (compact dj.event)
Wait for it