On the last post, we implemented a pair of counters. Now, we’ll generalize that out to a dynamic list of counters, and later, give them all remove buttons. In the process, we’ll learn how to combine components, stack them, peek on them, and otherwise deal with them appropriately.
The code for this is available in this repository.
Let’s get started! We want a list of counters, a button to add a counter, and a button to remove a counter. Let’s define our state and inputs:
type StateP =
{ counterArray :: Array Int
, nextID :: Int
}
initialState :: StateP
initialState =
{ counterArray: []
, nextID: 0
}
data Input a
= AddCounter a
| RemoveCounter a
Another quick detour to define our parent-level state and query types:
type State g =
InstalledState StateP Counter.State Input Counter.Input g CounterSlot
type Query =
Coproduct Input (ChildF CounterSlot Counter.Input)
And, our UI function:
ui :: forall g. (Plus g)
=> Component (State g) Query g
ui = parentComponent render eval
where
render state =
H.div_
[ H.h1_ [ H.text "Counters" ]
, H.ul_ $ map (\i -> mslot (CounterSlot i) Counter.ui (Counter.init 0)) state.counterArray
, H.button [ E.onClick $ E.input_ AddCounter ]
[ H.text "Add Counter" ]
, H.button [ E.onClick $ E.input_ RemoveCounter ]
[ H.text "Remove Counter" ]
]
eval :: EvalParent Input StateP Counter.State Input Counter.Input g CounterSlot
eval (AddCounter next) = do
modify addCounter
pure next
eval (RemoveCounter next) = do
modify removeCounter
pure next
mslot :: forall s f g p i. p -> Component s f g -> s -> HTML (SlotConstructor s f g p) i
mslot slot comp state = H.slot slot \_ -> { component: comp, initialState: state }
Basically the same thing we’ve been working with already!
Instead of keeping a CounterSlot 0
and CounterSlot 1
around, we’ve got an array of integers.
When we want to render them, we map over them with the slot type constructor and the H.slot
to give them a place to go.
Halogen figures out all of the event routing for us.
Alright, it’s time to give counters their own remove button. Rather than touch the counter at all, we’re simply going to wrap the existing counter component in a new component. The sole responsibility of this component will be handling the removal of counters.
There’s a bit of boiler plate around the State and Query, but after that, the result is pretty tiny!
-- src/Example/CounterRem.purs
data Input a = Remove a
type State g =
InstalledState Unit Counter.State Input Counter.Input g CounterSlot
type Query =
Coproduct Input (ChildF CounterSlot Counter.Input)
ui :: forall g. (Plus g)
=> Component (State g) Query g
ui = parentComponent render eval
where
render _ =
H.div_
[ mslot (CounterSlot 0) Counter.ui (Counter.init 0)
, H.button [ E.onClick $ E.input_ Remove ]
[ H.text "Remove" ]
]
eval :: EvalParent Input Unit Counter.State Input Counter.Input g CounterSlot
eval (Remove a) = pure a
Since we’re not maintaining any state, we’ll just use the Unit
type to signify that.
Our eval
function is going to punt the behavior to the parent component.
Now… Halogen does some impressive type trickery. Coproducts, free monads, query algebrae… it can be pretty intimidating. There’s a decent amount of associated boilerplate as well. We’re about to get into some of that.
Let’s look at InstalledState
in the Halogen documentation:
type InstalledState s s' f f' g p =
{ parent :: s
, children :: Map p (Tuple (Component s' f' g) s')
, memo :: Map p (HTML Void (Coproduct f (ChildF p f') Unit))
}
It’s a record with a parent state, a map from child slots to child states, and a map from child slots to memoized HTML.
But what is all of this coproduct
stuff again?
A Coproduct
is defined like this:
newtype Coproduct f g a = Coproduct (Either (f a) (g a))
It’s a way of saying “I have a value of type a inside of a functor. That functor is either f or g.”
We know we can specialize f
in the InstalledComponent
to our Input
query algebra.
And ChildF p f'
is a given child’s identifier and the child’s query algebra.
Halogen is using the coproduct structure to keep track of the children’s query algebra inputs.
Revisiting our type synonyms again, we have:
type State g =
InstalledState Unit Counter.State Input Counter.Input g CounterSlot
The true state of this component isn’t just Unit
– it’s the result of installing the Counter.State
into this component.
We’re giving that a name we can reference, and allowing the caller to provide the functor.
type Query =
Coproduct Input (ChildF CounterSlot Counter.Input)
Finally, our QueryMiddle
just fills in the types for the combined query algebra.
Alright! Awesome! We’ve augmented a component with a Remove
button.
Let’s embed that into a list.
We’ll actually get to reuse almost everything from example three!
-- src/Example/Four.purs
data Input a = AddCounter a
type State g =
InstalledState StateP (Counter.State g) Input Counter.Query g CounterSlot
type Query =
Coproduct Input (ChildF CounterSlot Counter.Query)
ui :: forall g. (Plus g)
=> Component (State g) Query g
ui = parentComponent' render eval peek
where
Ah! We’re peeking! I can tell because of the peek
function.
And also the '
on the end of parentComponent'
.
The '
indicates peeking.
Peeking is the way to inspect child components in purescript-halogen. So when a child component of a peeking parent is done with an action, then the parent gets a chance to see the action and act accordingly.
render state =
H.div_
[ H.h1_ [ H.text "Counters" ]
, H.ul_ (map (mapSlot CounterSlot Counter.ui (installedState unit)) state.counterArray)
, H.button [ E.onClick $ E.input_ AddCounter ]
[ H.text "Add Counter" ]
]
eval :: EvalParent _ _ _ _ _ g CounterSlot
eval (AddCounter next) = do
modify addCounter
pure next
mapSlot slot comp state index = mslot (slot index) comp state
Rendering and evalling work exactly as you’d expect. Let’s look at peeking!
peek :: Peek (ChildF CounterSlot Counter.Query) StateP (Counter.State g) Input Counter.Query g CounterSlot
peek (ChildF counterSlot (Coproduct queryAction)) =
case queryAction of
Left (Counter.Remove _) ->
modify (removeCounter counterSlot)
_ ->
pure unit
So this is kind of a more complex peek
than you’d normally start with.
My bad.
Generally, the peek
function has a definition that’d look like:
peek (ChildF childSlot action) =
case action of
DoThing next -> -- ...
But we’re working with the installed/child components who manage their state using the coproduct machinery, and as of now, we have to manually unwrap the coproduct and pattern match on the Either
value inside.
When we match on the Left
value, we get to see the immediate child’s actions.
If we were to match on the Right
value, then we’d get to inspect children’s of children’s actions.
In any case, we peek
on the child component, and if it just did a Remove
action, then we modify our own state.
Otherwise, we ignore it.
-- src/Main.purs
main = ... do
app <- runEx4
appendToBody app.node
runEx4 = runUI Ex4.ui (installedState (Ex3.initialState))
And now we’ve got our dynamic list of removable embedded counters going.
Next up, we’ll be looking at AJAX, effects, and other fun stuff.
Ok, so I wasn’t happy with how unmodular the above example was. We had to redefine a whole component just to add a remove button. If I wanted another component that had a remove button, I’d have to redo all that work! No thanks. Instead, I made a higher order component out of it.
There’s no meaning for distinguishing between children, because it only has one. There’s no state involved either, so we’ll use Unit for both of them. The only query is Remove. So let’s put that all together!
-- src/Example/RemGeneric.purs
data QueryP a = Remove a
type State s f g =
InstalledState Unit s QueryP f g Unit
type Query f =
Coproduct QueryP (ChildF Unit f)
addRemove :: forall g s f. (Plus g)
=> Component s f g
-> s
-> Component (State s f g) (Query f) g
addRemove comp state = parentComponent render eval
where
render _ =
H.div_
[ H.slot unit \_ -> { component: comp, initialState: state }
, H.button [ E.onClick $ E.input_ Remove ]
[ H.text "Remove" ]
]
eval :: EvalParent QueryP Unit s QueryP f g Unit
eval (Remove a) = pure a
Easy! We’ve got a few extra type variables to represent where the child state and query will go. Fairly standard type synonym definitions for use in client components. The only kinda tricky part is rendering: we accept a component and initial state as parameters.
Cool! Let’s see what the definition for the counter looks like with the remove button added:
-- src/Example/CounterRemPrime.purs
type State g = Rem.State Counter.State Counter.Input g
type Query = Rem.Query Counter.Input
ui :: forall g. (Plus g)
=> Component (State g) Query g
ui = Rem.addRemove Counter.ui (Counter.init 0)
More type synonyms! And a fairly nice one liner function to wrap the counter.
The code for the list itself is essentially unchanged.
We do have to import the RemGeneric
as well as the CounterRemPrime
module to be able to use the RemGeneric.Input
type, but the type declarations hardly change at all.
All in all, this level of componentiziation is fairly easy! Defining the type synonyms is a bit of a pain, but you’ll likely be writing a lot fewer of them when you have more involved components.