The Effect Monad
Chapter Goals
In the last chapter, we introduced applicative functors, an abstraction we used to deal with side-effects: optional values, error messages, and validation. This chapter will introduce another abstraction for dealing with side-effects more expressively: monads.
The goal of this chapter is to explain why monads are a useful abstraction and their connection with do notation.
Project Setup
The project adds the following dependencies:
effect
– defines theEffect
monad, the subject of the second half of the chapter. This dependency is often listed in every starter project (it's been a dependency of every chapter so far), so you'll rarely have to install it explicitly.react-basic-hooks
– a web framework we will use for our Address Book app.
Monads and Do Notation
Do notation was first introduced when we covered array comprehensions. Array comprehensions provide syntactic sugar for the concatMap
function from the Data.Array
module.
Consider the following example. Suppose we throw two dice and want to count the number of ways in which we can score a total of n
. We could do this using the following non-deterministic algorithm:
- Choose the value
x
of the first throw. - Choose the value
y
of the second throw. - If the sum of
x
andy
isn
, return the pair[x, y]
, else fail.
Array comprehensions allow us to write this non-deterministic algorithm naturally:
import Prelude
import Control.Plus (empty)
import Data.Array ((..))
countThrows :: Int -> Array (Array Int)
countThrows n = do
x <- 1 .. 6
y <- 1 .. 6
if x + y == n
then pure [ x, y ]
else empty
We can see that this function works in PSCi:
> import Test.Examples
> countThrows 10
[[4,6],[5,5],[6,4]]
> countThrows 12
[[6,6]]
In the last chapter, we formed an intuition for the Maybe
applicative functor, embedding PureScript functions into a larger programming language supporting optional values. In the same way, we can form an intuition for the array monad, embedding PureScript functions into a larger programming language supporting non-deterministic choice.
Generally, a monad for some type constructor m
provides a way to use do notation with values of type m a
. Note that in the array comprehension above, every line contains a computation of type Array a
for some type a
. In general, every line of a do notation block will contain a computation of type m a
for some type a
and our monad m
. The monad m
must be the same on every line (i.e., we fix the side-effect), but the types a
can differ (i.e., individual computations can have different result types).
Here is another example of do notation, this time applied to the type constructor Maybe
. Suppose we have some type XML
representing XML nodes, and a function
child :: XML -> String -> Maybe XML
Which looks for a child element of a node and returns Nothing
if no such element exists.
In this case, we can look for a deeply-nested element using do notation. Suppose we wanted to read a user's city from a user profile that had been encoded as an XML document:
userCity :: XML -> Maybe XML
userCity root = do
prof <- child root "profile"
addr <- child prof "address"
city <- child addr "city"
pure city
The userCity
function looks for a child element profile
, an element address
inside the profile
element, and finally, an element city
inside the address
element. If any of these elements are missing, the return value will be Nothing
. Otherwise, the return value is constructed using Just
from the city
node.
Remember, the pure
function in the last line is defined for every Applicative
functor. Since pure
is defined as Just
for the Maybe
applicative functor, it would be equally valid to change the last line to Just city
.
The Monad Type Class
The Monad
type class is defined as follows:
class Apply m <= Bind m where
bind :: forall a b. m a -> (a -> m b) -> m b
class (Applicative m, Bind m) <= Monad m
The key function here is bind
, defined in the Bind
type class. Just like for the <$>
and <*>
operators in the Functor
and Apply
type classes, the Prelude defines an infix alias >>=
for the bind
function.
The Monad
type class extends Bind
with the operations of the Applicative
type class we've already seen.
It will be useful to see some examples of the Bind
type class. A sensible definition for Bind
on arrays can be given as follows:
instance Bind Array where
bind xs f = concatMap f xs
This explains the connection between array comprehensions and the concatMap
function that has been alluded to before.
Here is an implementation of Bind
for the Maybe
type constructor:
instance Bind Maybe where
bind Nothing _ = Nothing
bind (Just a) f = f a
This definition confirms the intuition that missing values are propagated through a do notation block.
Let's see how the Bind
type class is related to do notation. Consider a simple do notation block that starts by binding a value from the result of some computation:
do value <- someComputation
whatToDoNext
Every time the PureScript compiler sees this pattern, it replaces the code with this:
bind someComputation \value -> whatToDoNext
or, written infix:
someComputation >>= \value -> whatToDoNext
The computation whatToDoNext
is allowed to depend on value
.
If there are multiple binds involved, this rule is applied multiple times, starting from the top. For example, the userCity
example that we saw earlier gets desugared as follows:
userCity :: XML -> Maybe XML
userCity root =
child root "profile" >>= \prof ->
child prof "address" >>= \addr ->
child addr "city" >>= \city ->
pure city
Notably, code expressed using do notation is often much clearer than the equivalent code using the >>=
operator. However, writing binds explicitly using >>=
can often lead to opportunities to write code in point-free form – but the usual warnings about readability apply.
Monad Laws
The Monad
type class comes equipped with three laws, called the monad laws. These tell us what we can expect from sensible implementations of the Monad
type class.
It is simplest to explain these laws using do notation.
Identity Laws
The right-identity law is the simplest of the three laws. It tells us that we can eliminate a call to pure
if it is the last expression in a do notation block:
do
x <- expr
pure x
The right-identity law says that this is equivalent to just expr
.
The left-identity law states that we can eliminate a call to pure
if it is the first expression in a do notation block:
do
x <- pure y
next
This code is equivalent to next
, after the name x
has been replaced with the expression y
.
The last law is the associativity law. It tells us how to deal with nested do notation blocks. It states that the following piece of code:
c1 = do
y <- do
x <- m1
m2
m3
is equivalent to this code:
c2 = do
x <- m1
y <- m2
m3
Each of these computations involves three monadic expressions m1
, m2
, and m3
. In each case, the result of m1
is eventually bound to the name x
, and the result of m2
is bound to the name y
.
In c1
, the two expressions m1
and m2
are grouped into their own do notation block.
In c2
, all three expressions m1
, m2
, and m3
appear in the same do notation block.
The associativity law tells us that it is safe to simplify nested do notation blocks in this way.
Note that by the definition of how do notation gets desugared into calls to bind
, both of c1
and c2
are also equivalent to this code:
c3 = do
x <- m1
do
y <- m2
m3
Folding With Monads
As an example of working with monads abstractly, this section will present a function that works with any type constructor in the Monad
type class. This should solidify the intuition that monadic code corresponds to programming "in a larger language" with side-effects, and also illustrate the generality which programming with monads brings.
The function we will write is called foldM
. It generalizes the foldl
function we met earlier to a monadic context. Here is its type signature:
foldM :: forall m a b. Monad m => (a -> b -> m a) -> a -> List b -> m a
foldl :: forall a b. (a -> b -> a) -> a -> List b -> a
Notice that this is the same as the type of foldl
, except for the appearance of the monad m
.
Intuitively, foldM
performs a fold over a list in some context supporting some set of side-effects.
For example, if we picked m
to be Maybe
, then our fold would be allowed to fail by returning Nothing
at any stage – every step returns an optional result, and the result of the fold is therefore also optional.
If we picked m
to be the Array
type constructor, then every step of the fold would be allowed to return zero or more results, and the fold would proceed to the next step independently for each result. In the end, the set of results would consist of all folds over all possible paths. This corresponds to a traversal of a graph!
To write foldM
, we can simply break the input list into cases.
If the list is empty, then to produce the result of type a
, we only have one option: we have to return the second argument:
foldM _ a Nil = pure a
Note that we have to use pure
to lift a
into the monad m
.
What if the list is non-empty? In that case, we have a value of type a
, a value of type b
, and a function of type a -> b -> m a
. If we apply the function, we obtain a monadic result of type m a
. We can bind the result of this computation with a backwards arrow <-
.
It only remains to recurse on the tail of the list. The implementation is simple:
foldM f a (b : bs) = do
a' <- f a b
foldM f a' bs
Note that this implementation is almost identical to that of foldl
on lists, except for do notation.
We can define and test this function in PSCi. Here is an example – suppose we defined a "safe division" function on integers, which tested for division by zero and used the Maybe
type constructor to indicate failure:
safeDivide :: Int -> Int -> Maybe Int
safeDivide _ 0 = Nothing
safeDivide a b = Just (a / b)
Then we can use foldM
to express iterated safe division:
> import Test.Examples
> import Data.List (fromFoldable)
> foldM safeDivide 100 (fromFoldable [5, 2, 2])
(Just 5)
> foldM safeDivide 100 (fromFoldable [2, 0, 4])
Nothing
The foldM safeDivide
function returns Nothing
if a division by zero was attempted at any point. Otherwise, it returns the result of repeatedly dividing the accumulator, wrapped in the Just
constructor.
Monads and Applicatives
Every instance of the Monad
type class is also an instance of the Apply
type class, by virtue of the superclass relationship between the two classes.
However, there is also an implementation of the Apply
type class which comes "for free" for any instance of Monad
, given by the ap
function:
ap :: forall m a b. Monad m => m (a -> b) -> m a -> m b
ap mf ma = do
f <- mf
a <- ma
pure (f a)
If m
is a law-abiding member of the Monad
type class, then there is a valid Apply
instance for m
given by ap
.
The interested reader can check that ap
agrees with apply
for the monads we have already encountered: Array
, Maybe
, and Either e
.
If every monad is also an applicative functor, then we should be able to apply our intuition for applicative functors to every monad. In particular, we can reasonably expect a monad to correspond, in some sense, to programming "in a larger language" augmented with some set of additional side-effects. We should be able to lift functions of arbitrary arities, using map
and apply
, into this new language.
But monads allow us to do more than we could do with just applicative functors, and the key difference is highlighted by the syntax of do notation. Consider the userCity
example again, in which we looked for a user's city in an XML document that encoded their user profile:
userCity :: XML -> Maybe XML
userCity root = do
prof <- child root "profile"
addr <- child prof "address"
city <- child addr "city"
pure city
Do notation allows the second computation to depend on the result prof
of the first, and the third computation to depend on the result addr
of the second, and so on. This dependence on previous values is not possible using only the interface of the Applicative
type class.
Try writing userCity
using only pure
and apply
: you will see that it is impossible. Applicative functors only allow us to lift function arguments which are independent of each other, but monads allow us to write computations which involve more interesting data dependencies.
In the last chapter, we saw that the Applicative
type class can be used to express parallelism. This was precisely because the function arguments being lifted were independent of one another. Since the Monad
type class allows computations to depend on the results of previous computations, the same does not apply – a monad has to combine its side-effects in sequence.
Exercises
-
(Easy) Write a function
third
that returns the third element of an array with three or more elements. Your function should return an appropriateMaybe
type. Hint: Look up the types of thehead
andtail
functions from theData.Array
module in thearrays
package. Use do notation with theMaybe
monad to combine these functions. -
(Medium) Write a function
possibleSums
which usesfoldM
to determine all possible totals that could be made using a set of coins. The coins will be specified as an array which contains the value of each coin. Your function should have the following result:> possibleSums [] [0] > possibleSums [1, 2, 10] [0,1,2,3,10,11,12,13]
Hint: This function can be written as a one-liner using
foldM
. You might want to use thenub
andsort
functions to remove duplicates and sort the result. -
(Medium) Confirm that the
ap
function and theapply
operator agree for theMaybe
monad. Note: There are no tests for this exercise. -
(Medium) Verify that the monad laws hold for the
Monad
instance for theMaybe
type, as defined in themaybe
package. Note: There are no tests for this exercise. -
(Medium) Write a function
filterM
which generalizes thefilter
function on lists. Your function should have the following type signature:filterM :: forall m a. Monad m => (a -> m Boolean) -> List a -> m (List a)
-
(Difficult) Every monad has a default
Functor
instance given by:map f a = do x <- a pure (f x)
Use the monad laws to prove that for any monad, the following holds:
lift2 f (pure a) (pure b) = pure (f a b)
Where the
Apply
instance uses theap
function defined above. Recall thatlift2
was defined as follows:lift2 :: forall f a b c. Apply f => (a -> b -> c) -> f a -> f b -> f c lift2 f a b = f <$> a <*> b
Note: There are no tests for this exercise.
Native Effects
We will now look at one particular monad of central importance in PureScript – the Effect
monad.
The Effect
monad is defined in the Effect
module. It is used to manage so-called native side-effects. If you are familiar with Haskell, it is the equivalent of the IO
monad.
What are native side-effects? They are the side-effects that distinguish JavaScript expressions from idiomatic PureScript expressions, which typically are free from side-effects. Some examples of native effects are:
- Console IO
- Random number generation
- Exceptions
- Reading/writing mutable state
And in the browser:
- DOM manipulation
- XMLHttpRequest / AJAX calls
- Interacting with a websocket
- Writing/reading to/from local storage
We have already seen plenty of examples of "non-native" side-effects:
- Optional values, as represented by the
Maybe
data type - Errors, as represented by the
Either
data type - Multi-functions, as represented by arrays or lists
Note that the distinction is subtle. It is true, for example, that an error message is a possible side-effect of a JavaScript expression in the form of an exception. In that sense, exceptions do represent native side-effects, and it is possible to represent them using Effect
. However, error messages implemented using Either
are not a side-effect of the JavaScript runtime, and so it is not appropriate to implement error messages in that style using Effect
. So it is not the effect itself, which is native, but rather how it is implemented at runtime.
Side-Effects and Purity
In a pure language like PureScript, one question presents itself: without side-effects, how can one write useful real-world code?
The answer is that PureScript does not aim to eliminate side-effects but to represent them in such a way that pure computations can be distinguished from computations with side-effects in the type system. In this sense, the language is still pure.
Values with side-effects have different types from pure values. As such, it is impossible to pass a side-effecting argument to a function, for example, and have side-effects performed unexpectedly.
The only way side-effects managed by the Effect
monad will be presented is to run a computation of type Effect a
from JavaScript.
The Spago build tool (and other tools) provide a shortcut by generating additional JavaScript to invoke the main
computation when the application starts. main
is required to be a computation in the Effect
monad.
The Effect Monad
The Effect
monad provides a well-typed API for computations with side-effects, while at the same time generating efficient JavaScript.
Let's look at the return type of the familiar log
function. Effect
indicates that this function produces a native effect, console IO in this case.
Unit
indicates that no meaningful data is returned. You can think of Unit
as analogous to the void
keyword in other languages, such as C, Java, etc.
log :: String -> Effect Unit
Aside: You may encounter IDE suggestions for the more general (and more elaborately typed)
log
function fromEffect.Class.Console
. This is interchangeable with the one fromEffect.Console
when dealing with the basicEffect
monad. Reasons for the more general version will become clearer after reading about "Monad Transformers" in the "Monadic Adventures" chapter. For the curious (and impatient), this works because there's aMonadEffect
instance forEffect
.log :: forall m. MonadEffect m => String -> m Unit
Now let's consider an Effect
that returns meaningful data. The random
function from Effect.Random
produces a random Number
.
random :: Effect Number
Here's a full example program (found in test/Random.purs
of this chapter's exercises folder).
module Test.Random where
import Prelude
import Effect (Effect)
import Effect.Random (random)
import Effect.Console (logShow)
main :: Effect Unit
main = do
n <- random
logShow n
Because Effect
is a monad, we use do notation to unwrap the data it contains before passing this data on to the effectful logShow
function. As a refresher, here's the equivalent code written using the bind
operator:
main :: Effect Unit
main = random >>= logShow
Try running this yourself with:
spago run --main Test.Random
You should see a randomly chosen number between 0.0
and 1.0
printed to the console.
Aside:
spago run
defaults to searching in theMain
module for amain
function. You may also specify an alternate module as an entry point with the--main
flag, as in the above example. Just be sure that this alternate module also contains amain
function.
Note that it's also possible to generate "random" (technically pseudorandom) data without resorting to impure effectful code. We'll cover these techniques in the "Generative Testing" chapter.
As mentioned previously, the Effect
monad is of central importance to PureScript. The reason why it's central is that it is the conventional way to interoperate with PureScript's Foreign Function Interface
, which provides the mechanism to execute a program and perform side effects. While it's desirable to avoid using the Foreign Function Interface
, it's fairly critical to understand how it works and how to use it, so I recommend reading that chapter before doing any serious PureScript work. That said, the Effect
monad is fairly simple. It has a few helper functions but doesn't do much except encapsulate side effects.
Exceptions
Let's examine a function from the node-fs
package that involves two native side effects: reading mutable state and exceptions:
readTextFile :: Encoding -> String -> Effect String
If we attempt to read a file that does not exist:
import Node.Encoding (Encoding(..))
import Node.FS.Sync (readTextFile)
main :: Effect Unit
main = do
lines <- readTextFile UTF8 "iDoNotExist.md"
log lines
We encounter the following exception:
throw err;
^
Error: ENOENT: no such file or directory, open 'iDoNotExist.md'
...
errno: -2,
syscall: 'open',
code: 'ENOENT',
path: 'iDoNotExist.md'
To manage this exception gracefully, we can wrap the potentially problematic code in try
to handle either outcome:
main :: Effect Unit
main = do
result <- try $ readTextFile UTF8 "iDoNotExist.md"
case result of
Right lines -> log $ "Contents: \n" <> lines
Left error -> log $ "Couldn't open file. Error was: " <> message error
try
runs an Effect
and returns eventual exceptions as a Left
value. If the computation succeeds, the result gets wrapped in a Right
:
try :: forall a. Effect a -> Effect (Either Error a)
We can also generate our own exceptions. Here is an alternative implementation of Data.List.head
that throws an exception if the list is empty rather than returning a Maybe
value of Nothing
.
exceptionHead :: List Int -> Effect Int
exceptionHead l = case l of
x : _ -> pure x
Nil -> throwException $ error "empty list"
Note that the exceptionHead
function is a somewhat impractical example, as it is best to avoid generating exceptions in PureScript code and instead use non-native effects such as Either
and Maybe
to manage errors and missing values.
Mutable State
There is another effect defined in the core libraries: the ST
effect.
The ST
effect is used to manipulate mutable state. As pure functional programmers, we know that shared mutable state can be problematic. However, the ST
effect uses the type system to restrict sharing in such a way that only safe local mutation is allowed.
The ST
effect is defined in the Control.Monad.ST
module. To see how it works, we need to look at the types of its actions:
new :: forall a r. a -> ST r (STRef r a)
read :: forall a r. STRef r a -> ST r a
write :: forall a r. a -> STRef r a -> ST r a
modify :: forall r a. (a -> a) -> STRef r a -> ST r a
new
is used to create a new mutable reference cell of type STRef r a
, which can be read using the read
action and modified using the write
and modify
actions. The type a
is the type of the value stored in the cell, and the type r
is used to indicate a memory region (or heap) in the type system.
Here is an example. Suppose we want to simulate the movement of a particle falling under gravity by iterating a simple update function over many small time steps.
We can do this by creating a mutable reference cell to hold the position and velocity of the particle, and then using a for
loop to update the value stored in that cell:
import Prelude
import Control.Monad.ST.Ref (modify, new, read)
import Control.Monad.ST (ST, for, run)
simulate :: forall r. Number -> Number -> Int -> ST r Number
simulate x0 v0 time = do
ref <- new { x: x0, v: v0 }
for 0 (time * 1000) \_ ->
modify
( \o ->
{ v: o.v - 9.81 * 0.001
, x: o.x + o.v * 0.001
}
)
ref
final <- read ref
pure final.x
At the end of the computation, we read the final value of the reference cell and return the position of the particle.
Note that even though this function uses a mutable state, it is still a pure function, so long as the reference cell ref
is not allowed to be used by other program parts. We will see that this is exactly what the ST
effect disallows.
To run a computation with the ST
effect, we have to use the run
function:
run :: forall a. (forall r. ST r a) -> a
The thing to notice here is that the region type r
is quantified inside the parentheses on the left of the function arrow. That means that whatever action we pass to run
has to work with any region r
whatsoever.
However, once a reference cell has been created by new
, its region type is already fixed, so it would be a type error to try to use the reference cell outside the code delimited by run
. This allows run
to safely remove the ST
effect and turn simulate
into a pure function!
simulate' :: Number -> Number -> Int -> Number
simulate' x0 v0 time = run (simulate x0 v0 time)
You can even try running this function in PSCi:
> import Main
> simulate' 100.0 0.0 0
100.00
> simulate' 100.0 0.0 1
95.10
> simulate' 100.0 0.0 2
80.39
> simulate' 100.0 0.0 3
55.87
> simulate' 100.0 0.0 4
21.54
In fact, if we inline the definition of simulate
at the call to run
, as follows:
simulate :: Number -> Number -> Int -> Number
simulate x0 v0 time =
run do
ref <- new { x: x0, v: v0 }
for 0 (time * 1000) \_ ->
modify
( \o ->
{ v: o.v - 9.81 * 0.001
, x: o.x + o.v * 0.001
}
)
ref
final <- read ref
pure final.x
Then the compiler will notice that the reference cell cannot escape its scope and can safely turn ref
into a var
. Here is the generated JavaScript for simulate
inlined with run
:
var simulate = function (x0) {
return function (v0) {
return function (time) {
return (function __do() {
var ref = { value: { x: x0, v: v0 } };
Control_Monad_ST_Internal["for"](0)(time * 1000 | 0)(function (v) {
return Control_Monad_ST_Internal.modify(function (o) {
return {
v: o.v - 9.81 * 1.0e-3,
x: o.x + o.v * 1.0e-3
};
})(ref);
})();
return ref.value.x;
})();
};
};
};
Note that this resulting JavaScript is not as optimal as it could be. See this issue for more details. The above snippet should be updated once that issue is resolved.
For comparison, this is the generated JavaScript of the non-inlined form:
var simulate = function (x0) {
return function (v0) {
return function (time) {
return function __do() {
var ref = Control_Monad_ST_Internal["new"]({ x: x0, v: v0 })();
Control_Monad_ST_Internal["for"](0)(time * 1000 | 0)(function (v) {
return Control_Monad_ST_Internal.modify(function (o) {
return {
v: o.v - 9.81 * 1.0e-3,
x: o.x + o.v * 1.0e-3
};
})(ref);
})();
var $$final = Control_Monad_ST_Internal.read(ref)();
return $$final.x;
};
};
};
};
The ST
effect is a good way to generate short JavaScript when working with locally-scoped mutable state, especially when used together with actions like for
, foreach
, and while
, which generate efficient loops.
Exercises
- (Medium) Rewrite the
safeDivide
function asexceptionDivide
and throw an exception usingthrowException
with the message"div zero"
if the denominator is zero. - (Medium) Write a function
estimatePi :: Int -> Number
that usesn
terms of the Gregory Series to calculate an approximation ofpi
. Hints: You can pattern your answer like the definition ofsimulate
above. You might need to convert anInt
into aNumber
usingtoNumber :: Int -> Number
fromData.Int
. - (Medium) Write a function
fibonacci :: Int -> Int
to compute then
th Fibonacci number, usingST
to track the values of the previous two Fibonacci numbers. Using PSCi, compare the speed of your newST
-based implementation against the recursive implementation (fib
) from Chapter 5.
DOM Effects
In the final sections of this chapter, we will apply what we have learned about effects in the Effect
monad to the problem of working with the DOM.
There are several PureScript packages for working directly with the DOM or open-source DOM libraries. For example:
web-dom
provides type definitions and low-level interface implementations for the W3C DOM spec.web-html
provides type definitions and low-level interface implementations for the W3C HTML5 spec.jquery
is a set of bindings to the jQuery library.
There are also PureScript libraries that build abstractions on top of these libraries, such as
thermite
builds onreact
react-basic-hooks
builds onreact-basic
halogen
provides a type-safe set of abstractions on top of a custom virtual DOM library.
In this chapter, we will use the react-basic-hooks
library to add a user interface to our address book application, but the interested reader is encouraged to explore alternative approaches.
An Address Book User Interface
Using the react-basic-hooks
library, we will define our application as a React component. React components describe HTML elements in code as pure data structures, which are then efficiently rendered to the DOM. In addition, components can respond to events like button clicks. The react-basic-hooks
library uses the Effect
monad to describe how to handle these events.
A full tutorial for the React library is well beyond the scope of this chapter, but the reader is encouraged to consult its documentation where needed. For our purposes, React will provide a practical example of the Effect
monad.
We are going to build a form that will allow a user to add a new entry into our address book. The form will contain text boxes for the various fields (first name, last name, city, state, etc.) and an area where validation errors will be displayed. As the user types text into the text boxes, the validation errors will be updated.
To keep things simple, the form will have a fixed shape: the different phone number types (home, cell, work, other) will be expanded into separate text boxes.
You can launch the web app from the exercises/chapter8
directory with the following commands:
$ npm install
$ npx spago build
$ npx parcel src/index.html --open
If development tools such as spago
and parcel
are installed globally, then the npx
prefix may be omitted. You have likely already installed spago
globally with npm i -g spago
, and the same can be done for parcel
.
parcel
should launch a browser window with our "Address Book" app. If you keep the parcel
terminal open and rebuild with spago
in another terminal, the page should automatically refresh with your latest edits. You can also configure automatic rebuilds (and therefore automatic page refresh) on file-save if you're using an editor that supports purs ide
or are running pscid
.
In this Address Book app, you can enter some values into the form fields and see the validation errors printed onto the page.
Let's explore how it works.
The src/index.html
file is minimal:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Address Book</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" crossorigin="anonymous">
</head>
<body>
<div id="container"></div>
<script type="module" src="./index.js"></script>
</body>
</html>
The <script
line includes the JavaScript entry point, index.js
, which contains this single line:
import { main } from "../output/Main/index.js";
main();
It calls our generated JavaScript equivalent of the main
function of module Main
(src/main.purs
). Recall that spago build
puts all generated JavaScript in the output
directory.
The main
function uses the DOM and HTML APIs to render our address book component within the container
element we defined in index.html
:
main :: Effect Unit
main = do
log "Rendering address book component"
-- Get window object
w <- window
-- Get window's HTML document
doc <- document w
-- Get "container" element in HTML
ctr <- getElementById "container" $ toNonElementParentNode doc
case ctr of
Nothing -> throw "Container element not found."
Just c -> do
-- Create AddressBook react component
addressBookApp <- mkAddressBookApp
let
-- Create JSX node from react component. Pass-in empty props
app = element addressBookApp {}
-- Render AddressBook JSX node in DOM "container" element
D.render app c
Note that these three lines:
w <- window
doc <- document w
ctr <- getElementById "container" $ toNonElementParentNode doc
Can be consolidated to:
doc <- document =<< window
ctr <- getElementById "container" $ toNonElementParentNode doc
Or consolidated even further to:
ctr <- getElementById "container" <<< toNonElementParentNode =<< document =<< window
-- or, equivalently:
ctr <- window >>= document >>= toNonElementParentNode >>> getElementById "container"
It is a matter of personal preference whether the intermediate w
and doc
variables aid in readability.
Let's dig into our AddressBook reactComponent
. We'll start with a simplified component and then build up to the actual code in Main.purs
.
Take a look at this minimal component. Feel free to substitute the full component with this one to see it run:
mkAddressBookApp :: Effect (ReactComponent {})
mkAddressBookApp =
reactComponent
"AddressBookApp"
(\props -> pure $ D.text "Hi! I'm an address book")
reactComponent
has this intimidating signature:
reactComponent ::
forall hooks props.
Lacks "children" props =>
Lacks "key" props =>
Lacks "ref" props =>
String ->
({ | props } -> Render Unit hooks JSX) ->
Effect (ReactComponent { | props })
The important points to note are the arguments after all the type class constraints. It takes a String
(an arbitrary component name), a function that describes how to convert props
into rendered JSX
, and returns our ReactComponent
wrapped in an Effect
.
The props-to-JSX function is simply:
\props -> pure $ D.text "Hi! I'm an address book"
props
are ignored, D.text
returns JSX
, and pure
lifts to rendered JSX. Now component
has everything it needs to produce the ReactComponent
.
Next, we'll examine some of the additional complexities of the full Address Book component.
These are the first few lines of our full component:
mkAddressBookApp :: Effect (ReactComponent {})
mkAddressBookApp = do
reactComponent "AddressBookApp" \props -> R.do
Tuple person setPerson <- useState examplePerson
We track person
as a piece of state with the useState
hook.
Tuple person setPerson <- useState examplePerson
Note that you are free to break-up component state into multiple pieces of state with multiple calls to useState
. For example, we could rewrite this app to use a separate piece of state for each record field of Person
, but that results in a slightly less convenient architecture in this case.
In other examples, you may encounter the /\
infix operator for Tuple
. This is equivalent to the above line:
firstName /\ setFirstName <- useState p.firstName
useState
takes a default initial value and returns the current value and a way to update the value. We can check the type of useState
to gain more insight of the types person
and setPerson
:
useState ::
forall state.
state ->
Hook (UseState state) (Tuple state ((state -> state) -> Effect Unit))
We can strip the Hook (UseState state)
wrapper off of the return value because useState
is called within an R.do
block. We'll elaborate on R.do
later.
So now we can observe the following signatures:
person :: state
setPerson :: (state -> state) -> Effect Unit
The specific type of state
is determined by our initial default value. Person
Record
in this case because that is the type of examplePerson
.
person
is how we access the current state at each rerender.
setPerson
is how we update the state. We provide a function describing how to transform the current state into the new one. The record update syntax is perfect for this when the type of state
happens to be a Record
, for example:
setPerson (\currentPerson -> currentPerson {firstName = "NewName"})
Or as shorthand:
setPerson _ {firstName = "NewName"}
Non-Record
states can also follow this update pattern. See this guide for more details on best practices.
Recall that useState
is used within an R.do
block. R.do
is a special react hooks variant of do
. The R.
prefix "qualifies" this as coming from React.Basic.Hooks
, and means we use their hooks-compatible version of bind
in the R.do
block. This is known as a "qualified do". It lets us ignore the Hook (UseState state)
wrapping and bind the inner Tuple
of values to variables.
Another possible state management strategy is with useReducer
, but that is outside the scope of this chapter.
Rendering JSX
occurs here:
pure
$ D.div
{ className: "container"
, children:
renderValidationErrors errors
<> [ D.div
{ className: "row"
, children:
[ D.form_
$ [ D.h3_ [ D.text "Basic Information" ]
, formField "First Name" "First Name" person.firstName \s ->
setPerson _ { firstName = s }
, formField "Last Name" "Last Name" person.lastName \s ->
setPerson _ { lastName = s }
, D.h3_ [ D.text "Address" ]
, formField "Street" "Street" person.homeAddress.street \s ->
setPerson _ { homeAddress { street = s } }
, formField "City" "City" person.homeAddress.city \s ->
setPerson _ { homeAddress { city = s } }
, formField "State" "State" person.homeAddress.state \s ->
setPerson _ { homeAddress { state = s } }
, D.h3_ [ D.text "Contact Information" ]
]
<> renderPhoneNumbers
]
}
]
}
Here we produce JSX
, which represents the intended state of the DOM. This JSX is typically created by applying functions corresponding to HTML tags (e.g., div
, form
, h3
, li
, ul
, label
, input
) which create single HTML elements. These HTML elements are React components themselves, converted to JSX. There are usually three variants of each of these functions:
div_
: Accepts an array of child elements. Uses default attributes.div
: Accepts aRecord
of attributes. An array of child elements may be passed to thechildren
field of this record.div'
: Same asdiv
, but returns theReactComponent
before conversion toJSX
.
To display validation errors (if any) at the top of our form, we create a renderValidationErrors
helper function that turns the Errors
structure into an array of JSX. This array is prepended to the rest of our form.
renderValidationErrors :: Errors -> Array R.JSX
renderValidationErrors [] = []
renderValidationErrors xs =
let
renderError :: String -> R.JSX
renderError err = D.li_ [ D.text err ]
in
[ D.div
{ className: "alert alert-danger row"
, children: [ D.ul_ (map renderError xs) ]
}
]
Note that since we are simply manipulating regular data structures here, we can use functions like map
to build up more interesting elements:
children: [ D.ul_ (map renderError xs)]
We use the className
property to define classes for CSS styling. We're using the Bootstrap stylesheet
for this project, which is imported in index.html
. For example, we want items in our form arranged as row
s, and validation errors to be emphasized with alert-danger
styling:
className: "alert alert-danger row"
A second helper function is formField
, which creates a text input for a single form field:
formField :: String -> String -> String -> (String -> Effect Unit) -> R.JSX
formField name placeholder value setValue =
D.div
{ className: "form-group row"
, children:
[ D.label
{ className: "col-sm col-form-label"
, htmlFor: name
, children: [ D.text name ]
}
, D.div
{ className: "col-sm"
, children:
[ D.input
{ className: "form-control"
, id: name
, placeholder
, value
, onChange:
let
handleValue :: Maybe String -> Effect Unit
handleValue (Just v) = setValue v
handleValue Nothing = pure unit
in
handler targetValue handleValue
}
]
}
]
}
Putting the input
and display text
in a label
aids in accessibility for screen readers.
The onChange
attribute allows us to describe how to respond to user input. We use the handler
function, which has the following type:
handler :: forall a. EventFn SyntheticEvent a -> (a -> Effect Unit) -> EventHandler
For the first argument to handler
we use targetValue
, which provides the value of the text within the HTML input
element. It matches the signature expected by handler
where the type variable a
in this case is Maybe String
:
targetValue :: EventFn SyntheticEvent (Maybe String)
In JavaScript, the input
element's onChange
event is accompanied by a String
value, but since strings in JavaScript can be null, Maybe
is used for safety.
The second argument to handler
, (a -> Effect Unit)
, must therefore have this signature:
Maybe String -> Effect Unit
It is a function that describes how to convert this Maybe String
value into our desired effect. We define a custom handleValue
function for this purpose and pass it to handler
as follows:
onChange:
let
handleValue :: Maybe String -> Effect Unit
handleValue (Just v) = setValue v
handleValue Nothing = pure unit
in
handler targetValue handleValue
setValue
is the function we provided to each formField
call that takes a string and makes the appropriate record-update call to the setPerson
hook.
Note that handleValue
can be substituted as:
onChange: handler targetValue $ traverse_ setValue
Feel free to investigate the definition of traverse_
to see how both forms are indeed equivalent.
That covers the basics of our component implementation. However, you should read the source accompanying this chapter to get a full understanding of the way the component works.
Obviously, this user interface can be improved in a number of ways. The exercises will explore some ways in which we can make the application more usable.
Exercises
Modify src/Main.purs
in the following exercises. There are no unit tests for these exercises.
-
(Easy) Modify the application to include a work phone number text box.
-
(Medium) Right now, the application shows validation errors collected in a single "pink-alert" background. Modify to give each validation error its own pink-alert background by separating them with blank lines.
Hint: Instead of using a
ul
element to show the validation errors in a list, modify the code to create onediv
with thealert
andalert-danger
styles for each error. -
(Difficult, Extended) One problem with this user interface is that the validation errors are not displayed next to the form fields they originated from. Modify the code to fix this problem.
Hint: The error type returned by the validator should be extended to indicate which field caused the error. You might want to use the following modified
Errors
type:data Field = FirstNameField | LastNameField | StreetField | CityField | StateField | PhoneField PhoneType data ValidationError = ValidationError String Field type Errors = Array ValidationError
You will need to write a function that extracts the validation error for a particular
Field
from theErrors
structure.
Conclusion
This chapter has covered a lot of ideas about handling side-effects in PureScript:
- We met the
Monad
type class and its connection to do notation. - We introduced the monad laws and saw how they allow us to transform code written using do notation.
- We saw how monads can be used abstractly to write code that works with different side-effects.
- We saw how monads are examples of applicative functors, how both allow us to compute with side-effects, and the differences between the two approaches.
- The concept of native effects was defined, and we met the
Effect
monad, which handles native side-effects. - We used the
Effect
monad to handle a variety of effects: random number generation, exceptions, console IO, mutable state, and DOM manipulation using React.
The Effect
monad is a fundamental tool in real-world PureScript code. It will be used in the rest of the book to handle side-effects in a number of other use-cases.