Purescript III: Making a Web Page with Purescript and React!
Last week we continued learning the basic elements of Purescript. We examined how typeclasses and monads work and the slight differences from Haskell. Now it's finally time to use Purescript for its main purpose: frontend web development. We'll accomplish this using the Halogen framework, built on React.js.
In this article, we'll learn about the basic concepts of Halogen/React. We'll build a couple simple components to show how these work. Next week, we'll conclude our look at Purescript by making a more complete application. We'll see how to handle routing and sending web requests.
If you're building a frontend, you'll also need a backend at some point. Check out our Haskell Web Series to learn how to do that in Haskell!
Also, getting Purescript to work can be tricky business! Take a look at our Github repository for some more setup instructions!
Halogen Crash Course
The Halogen framework uses React.js under the hood, and the code applies similar ideas. If you don't do a lot of web development, you might not be too familiar with the details of React. Luckily, there are a few simple principles we'll apply that will remind us of Elm!
With Halogen, our UI consists of different "components". A component is a UI element that maintains its own state and properties. It also responds to queries, and sends messages. For any component, we'll start by defining a a state type, a query type, and a message type.
data CState = …
data CQuery = …
data CMessage = ...
Our component receives queries from within itself or from other components. It can then send messages to other components, provided they have queries to handle them. With these types in place, we'll use the component
function to define a component with 3 main elements. As a note, we'll be maintaining these import prefixes throughout the article.
import Halogen as H
import Halogen.HTML as HH
import Halogen.Events as HE
import Halogen.Properties as HP
myComponent :: forall m.
H.Component HH.HTML CQuery Unit CMessage m
myComponent = H.component
{ initialState: …
, render: …
, eval: …
, receiver: const Nothing
}
where
render ::
CState ->
H.ComponentHTML CQuery
eval ::
CQuery ~>
H.ComponentDSL CState CQuery CMessage m
The initialState
is self explanatory. The render
function will be a lot like our view
function from Elm. It takes a state and returns HTML components that can send queries. The eval
function acts like our update
function in Elm. Its type signature looks a little strange. But it takes queries as inputs and can update our state using State
monad function. It can also emit messages to send to other components.
Building a Counter
For our first example of a component, we'll make a simple counter. We'll have an increment button, a decrement button and a display of the current count. Our state will be a simple integer. Our queries will involve events from incrementing and decrementing. We'll also send a message each time we update our number.
type State = Int
data Query a =
Increment a |
Decrement a
data Message = Updated Int
Notice we have an extra parameter on our query type. This represents the "next" action that will happen in our UI. We'll see how this works when we write our eval
function. But first, let's write out our render
function. It has three different HTML elements: two buttons and a p
label. We'll stick them in a div
element.
render :: State -> H.ComponentHTML Query
render state =
let incButton = HH.button
[ HP.title "Inc"
, HE.onClick (HE.input_ Increment)
]
[ HH.text "Inc" ]
decButton = HH.button
[ HP.title "Dec"
, HE.onClick (HE.input_ Decrement)
]
[ HH.text "Dec" ]
pElement = HH.p [] [HH.text (show state)]
in HH.div [] [incButton, decButton, pElement]
Each of our elements takes two list parameters. The first list includes properties as well as event handlers. Notice our buttons send query messages on their click events using the input_
function. Then the second list is "child" HTML elements, including the inner text of a button.
Now, to write our eval
function, we use a case statement. This might seem a little weird, but all we're doing is breaking it down into our query cases:
eval :: Query ~> H.ComponentDSL State Query Message m
eval = case _ of
Increment next -> ...
Decrement next -> ...
Within each case, we can use State
monad-like functions to manipulate our state. Our cases are identical except for the sign. We'll also use the raise
function to send an update message. Nothing listens for that message right now, but it illustrates the concept.
eval :: Query ~> H.ComponentDSL State Query Message m
eval = case _ of
Increment next -> do
state <- H.get
let nextState = state + 1
H.put nextState
H.raise $ Updated nextState
pure next
Decrement next -> do
state <- H.get
let nextState = state - 1
H.put nextState
H.raise $ Updated nextState
pure next
As a last note, we would use const 0
as the initialState
in our component function.
Installing Our Component
Now to display this component in our UI, we write a short Main
module like so. We get our body element with awaitBody
and then use runUI
to install our counter component.
module Main where
import Prelude
import Effect (Effect)
import Halogen.Aff as HA
import Halogen.VDom.Driver (runUI)
import Counter (counter)
main :: Effect Unit
main = HA.runHalogenAff do
body <- HA.awaitBody
runUI counter unit body
And our counter component will now work! (See Github for more details on you could run this code).
Building Our Todo List
Now that we've got the basics down, let's see how to write a more complicated set of components. We'll write a Todo list like we had in the Elm series. To start, let's make a Todo
wrapper type and derive some instances for it:
newtype Todo = Todo
{ todoName :: String }
derive instance eqTodo :: Eq Todo
derive instance ordTodo :: Ord Todo
Our first component will be the entry form, where the user can add a new task. This form will use the text input string as its state. It will respond to queries for updating the name as well as pressing the "Add" button. When we create a new Todo, we'll send a message for that.
type AddTodoFormState = String
data AddTodoFormMessage = NewTodo Todo
data AddTodoFormQuery a =
AddedTodo a |
UpdatedName String a
When we render this component, we'll have two main pieces. First, we need the text field to input the name. Then, there's the button to add the task. Each of these has an event attached to it sending the relevant query. In the case of updating the name, notice we use input
instead of input_
. This allows us to send the text field's value as an argument of the UpdatedName
query. Otherwise, the properties are pretty straightforward translations of HTML properties you might see.
render ::
AddTodoFormState ->
H.ComponentHTML AddTodoFormQuery
render currentName =
let nameInput = HH.input
[ HP.type_ HP.InputText
, HP.placeholder "Task Name"
, HP.value currentName
, HE.onValueChange (HE.input UpdatedName)
]
addButton = HH.button
[ HP.title "Add Task"
, HP.disabled (length currentName == 0)
, HE.onClick (HE.input_ AddedTodo)
]
[ HH.text "Add Task" ]
in HH.div [] [nameInput, addButton]
Evaluating our queries is pretty simple. When updating the name, all we do is update the state and trigger the next action. When we add a new Todo item, we save the empty string as the state and raise our message. In the next part, we'll see how our list will respond to this message.
eval ::
AddTodoFormQuery ~>
H.ComponentDSL
AddTodoFormState AddTodoFormQuery AddTodoFormMessage m
eval = case _ of
AddedTodo next -> do
currentName <- H.get
H.put ""
H.raise $ NewTodo (Todo {todoName: currentName})
pure next
UpdatedName newName next -> do
H.put newName
pure next
And of course, we tie this all up by using the component
function:
addTodoForm :: forall m.
H.Component HH.HTML AddTodoFormQuery Unit AddTodoFormMessage m
addTodoForm = H.component
{ initialState: const ""
, render
, eval
, receiver: const Nothing
}
Finishing the List
Now to complete our todo list, we'll need another component to store the tasks themselves. As always, let's start with our basic types. We won't bother with a message type since this component won't send any messages. We'll use Void
when assigning the message type in a type signature:
type TodoListState = Array Todo
data TodoListQuery a =
FinishedTodo Todo a |
HandleNewTask AddTodoFormMessage a
Our state is our list of tasks. Our query type is a little more complicated. The HandleNewTask
query will receive the new task messages from our form. We'll see how we make this connection below.
We'll also add a type alias for AddTodoFormSlot
. Halogen uses a "slot ID" to distinguish between child elements. We only have one child element though, so we'll use a string.
type AddTodoFormSlot = String
We'll consider this component a "parent" of our "add task" form. This means the types will look a little different. We'll be making something of type ParentHTML
. The type signature will include references to its own query type, the query type of its child, and the slot ID type. We'll still use most of the same functions though.
render ::
TodoListState ->
H.ParentHTML TodoListQuery AddTodoFormQuery AddTodoFormSlot m
eval ::
TodoListQuery ~>
H.ParentDSL TodoListState TodoListQuery AddTodoFormQuery
AddTodoFormSlot Void m
To render our elements, we'll have two sub-components. First, we'll want to be able to render an individual Todo within our list. We'll give it a p
label for the name and a button that completes the task:
renderTask ::
Todo ->
H.ParentHTML TodoListQuery AddTodoFormQuery AddTodoFormSlot m
renderTask (Todo t) = HH.div_
[ HH.p [] [HH.text t.todoName]
, HH.button
[ HE.onClick (HE.input_ (FinishedTodo (Todo t)))]
[HH.text "Finish"]
]
Now we need some HTML for the form slot itself. This is straightforward. We'll use the slot
function and provide a string for the ID. We'll specify the component we have from the last part. Then we'll attach the HandleNewTask
query to this component. The allows our list component to receive the new-task messages from the form.
formSlot ::
H.ParentHTML TodoListQuery AddTodoFormQuery AddTodoFormSlot m
formSlot = HH.slot
"Add Todo Form"
addTodoForm
unit
(HE.input HandleNewTask)
Now we combine these elements in our render
function:
render ::
TodoListState ->
H.ParentHTML TodoListQuery AddTodoFormQuery AddTodoFormSlot m
render todos =
let taskList = HH.ul_ (map renderTask todos)
in HH.div_ [taskList, formSlot]
Writing our eval
is now a simple matter of using a few array functions to update the list. When we get a new task, we add it to our list. When we finish a task, we remove it from the list.
eval ::
TodoListQuery ~>
H.ParentDSL TodoListState TodoListQuery AddTodoFormQuery
AddTodoFormSlot Void m
eval = case _ of
FinishedTodo todo next -> do
currentTasks <- H.get
H.put (filter (_ /= todo) currentTasks)
pure next
HandleNewTask (NewTodo todo) next -> do
currentTasks <- H.get
H.put (currentTasks `snoc` todo)
pure next
And that's it! We're done! Again, take a look at the Github repo for some more instructions on how you can run and interact with this code.
Conclusion
This wraps up our look at building simple UI's with Purescript. Next week we'll conclude our Purescript series. We'll look at some of the broader elements of building a web app. We'll see some basic routing as well as how to send requests to a backend server.
Elm is another great functional language you can use for Web UIs. To learn more about it, check out our recently concluded Elm Series!