Strongly-typed front-end: experiment 2, simple application, in PureScript / Halogen
Contents
- Introduction
- Experiment 1, darken_color
- Experiment 2, simple application
A more “conventional” way to implement the front-end application in PureScript would be using a framework called Halogen.
Starting off with a “hello world” example:
module Main where
import Prelude
import Effect (Effect)
import Halogen as H
import Halogen.Aff as HA
import Halogen.HTML as HH
import Halogen.HTML.Events as HE
import Halogen.VDom.Driver (runUI)
main :: Effect Unit
main = HA.runHalogenAff do
body <- HA.awaitBody
runUI component unit body
data Action = Increment | Decrement
component =
H.mkComponent
{ initialState
, render
, eval: H.mkEval $ H.defaultEval { handleAction = handleAction }
}
where
initialState _ = 0
render state =
HH.div_
[ HH.button [ HE.onClick \_ -> Decrement ] [ HH.text "-" ]
, HH.div_ [ HH.text $ show state ]
, HH.button [ HE.onClick \_ -> Increment ] [ HH.text "+" ]
]
handleAction = case _ of
Increment -> H.modify_ \state -> state + 1
Decrement -> H.modify_ \state -> state - 1
Adding the utility code akin to the other technologies:
data Shape = Circle | Square
calculateArea :: Maybe Shape -> Float -> Float
calculateArea Nothing _ = 0
calculateArea (Just Circle) value = pi * value * value
calculateArea (Just Square) value = value * value
getShape :: String -> Maybe Shape
getShape "circle" = Just Circle
getShape "square" = Just Square
getShape _ = Nothing
This resurfaces few differences from Haskell, Elm and others:
- there is no
pi
constant in thePrelude
, so need to import one of the available definitions, I went withData.Number
Float
is not a type; there isNumber
, however0
is not aNumber
, it isInt
, confusing the audience
These are all minor differences, however.
But this code is not a conventional PureScript either - it is working against the good practices of
functional programming and thus defeats the purpose of these experiments.
Examples of this are the heavy reliance on String
instead of using the available type system.
Let us change that a bit:
import Data.String.Read (class Read)
data Shape = Circle | Square
calculateArea :: Shape -> Number -> Number
calculateArea Circle value = pi * value * value
calculateArea Square value = value * value
instance Read Shape where
read = case _ of
"square" -> Just Square
"circle" -> Just Circle
_ -> Nothing
instance Show Shape where
show = case _ of
Square -> "square"
Circle -> "circle"
Now, to the UI:
import Halogen.HTML.Properties as HP
render state =
HH.div_
[
HH.select [] [
HH.option [ HP.value "" ] [ HH.text "Select shape" ],
HH.option [ HP.value (show Circle) ] [ HH.text (show Circle) ],
HH.option [ HP.value (show Square) ] [ HH.text (show Square) ]
],
HH.input [],
HH.div_ [ HH.text "<area>" ]
]
In the application state we need to store the selected shape and the value, so we can utilize records for that:
initialState _ = { shape: Nothing, value: Nothing }
Then we need to modify the possible actions. Let’s stick to the same approach of utilizing the type system:
data Action = ChangeValue (Maybe Number) | ChangeShape (Maybe Shape)
The thing glueing the two together is the handleAction
function:
handleAction = case _ of
ChangeValue value ->
H.modify_ \state -> state { value = value }
ChangeShape shape ->
H.modify_ \state -> state { shape = shape }
Here, unlike Haskell (to my best knowledge), the placeholder variable is being used for pattern matching against the only function argument. So instead of a little verbose
handleAction action = case action of
-- ...
you can use this placeholder variable and just provide the branches for each of its possible values:
handleAction = case _ of
-- ...
Modifying the state is done using the Halogen.Hooks.HookM.modify_
function, which allows us to only use the previous state value and provide a new state value, without the need to mess with monads.
In turn, we modify the state record using the record syntax:
state { shape = newShapeValue }
Now the only bit left is tying the UI with the actions:
import Halogen.HTML.Events as HE
import Data.String.Read (read)
import Data.Number as N
import Data.Tuple (Tuple(..))
render state =
HH.div_
[
HH.select [ HE.onValueChange onShapeChanged ] [
HH.option [ HP.value "" ] [ HH.text "Select shape" ],
HH.option [ HP.value (show Circle) ] [ HH.text (show Circle) ],
HH.option [ HP.value (show Square) ] [ HH.text (show Square) ]
],
HH.input [ HE.onValueChange onValueChanged ],
HH.div_ [ HH.text "<area>" ]
]
onShapeChanged v = ChangeShape (read v)
onValueChanged v = ChangeValue (N.fromString v)
showArea state =
case res of
Nothing ->
HH.text "Choose shape and provide its parameter"
Just (Tuple shape area) ->
HH.text $ "Area of " <> (show shape) <> " is " <> (show area)
where
res = do
shape <- state.shape
value <- state.value
let area = calculateArea shape value
pure (Tuple shape area)
Here is where most fun and benefit from using PureScript comes into play.
First of all, the HE.onValueChange
event handler (the onShapeChanged
and onValueChanged
functions) - it will be called with the new value for the input instead of an entire
event object. This allows us to skip unpacking the raw value from that object.
Then, the action dispatchers take the value from the input and try to parse it, returning a Maybe a
:
onShapeChanged :: String -> Maybe Shape
onShapeChanged v = ChangeShape (read v)
onValueChanged :: String -> Maybe Number
onValueChanged v = ChangeValue (N.fromString v)
It is actually a quite important part, since the shape might not be selected (making the <select>
value an empty string) and the value might be either
a blank string or not a valid number string.
PureScript does not allow us to not handle these cases, so whenever we parse the user input, we get a Maybe a
value and we have to handle both
scenarios when the value is valid and when it is not.
The function showArea
is where this neatness comes together - we handle both values as one, using the Data.Tuple
type to pair them together:
res = do
shape <- state.shape -- unpacks `Shape` from `Maybe Shape`
value <- state.value -- unpacks `Number` from `Maybe Number`
let area = calculateArea shape value -- always returns a Number, since both `shape` and `value` are always provided
pure (Tuple shape area) -- returns a tuple of shape and area, packed in a `Maybe`
The above code will shortcircuit whenever at any point it is trying to unpack a value from a Nothing
and the whole do
block will return Nothing
.
Putting it all together:
module Main where
import Prelude
import Data.Maybe (Maybe(..))
import Data.Number as N
import Data.String.Read (class Read, read)
import Data.Tuple (Tuple(..))
import Effect (Effect)
import Halogen as H
import Halogen.Aff as HA
import Halogen.HTML as HH
import Halogen.HTML.Events as HE
import Halogen.HTML.Properties as HP
import Halogen.VDom.Driver (runUI)
data Shape = Circle | Square
calculateArea :: Shape -> Number -> Number
calculateArea Circle value = N.pi * value * value
calculateArea Square value = value * value
instance Read Shape where
read = case _ of
"square" -> Just Square
"circle" -> Just Circle
_ -> Nothing
instance Show Shape where
show = case _ of
Square -> "square"
Circle -> "circle"
data Action = ChangeValue (Maybe Number) | ChangeShape (Maybe Shape)
component =
H.mkComponent
{ initialState
, render
, eval: H.mkEval $ H.defaultEval { handleAction = handleAction }
}
where
initialState _ = { shape: Nothing, value: Nothing }
render state =
HH.div_
[
HH.select [ HE.onValueChange onShapeChanged ] [
HH.option [ HP.value "" ] [ HH.text "Select shape" ],
HH.option [ HP.value (show Circle) ] [ HH.text (show Circle) ],
HH.option [ HP.value (show Square) ] [ HH.text (show Square) ]
],
HH.input [ HE.onValueChange onValueChanged ],
HH.div_ [ showArea state ]
]
onShapeChanged v = ChangeShape (read v)
onValueChanged v = ChangeValue (N.fromString v)
showArea state =
case res of
Nothing ->
HH.text "Select shape and provide its value"
Just (Tuple shape area) ->
HH.text $ "Area of " <> (show shape) <> " is " <> (show area)
where
res = do
shape <- state.shape
value <- state.value
let area = calculateArea shape value
pure (Tuple shape area)
handleAction = case _ of
ChangeValue value ->
H.modify_ \state -> state { value = value }
ChangeShape shape ->
H.modify_ \state -> state { shape = shape }
main :: Effect Unit
main = HA.runHalogenAff do
body <- HA.awaitBody
runUI component unit body