Introduction¶
Halogen is a powerful framework for building PureScript applications. It’s used by several companies, including SlamData and my own company, CitizenNet (a Condé Nast company), among others. The Select
library is written for the Halogen framework, so if you don’t know how to use Halogen yet, you ought to start with the Halogen guide. That said, with only passing familiarity with Halogen, you should be able to follow along just fine!
Setup¶
Instead of creating a new Halogen project from scratch, we’ll start with a minimal starter template. This template includes the HTML, build scripts, and basic Main.purs
file necessary to run your Halogen application. It also includes a component with the bare minimum definitions in place. This component does nothing at all, which is nice because we can easily use it to start building dropdowns, typeaheads, and other components.
Info
We prefer Yarn over NPM for package management and scripts, but either one will work. Anywhere you see yarn <script>
, you can substitute npm run <script>
instead. Feel free to look at the package.json
file if you want to see what these scripts are doing.
Installation¶
First, clone the Halogen template project from CitizenNet, install dependencies, and make sure things build properly. If they don’t, please reach out on the Purescript user forum so we can fix it!
Next, make sure to install Select
:
1 | bower i --save purescript-halogen-select |
Warning
The PureScript compiler recently updated to version 0.12
and many core libraries updated at the same time. If you run into version conflicts, please reach out on the Purescript user forum.
And that's it! You now have everything you need to complete the tutorials. This is the full set of steps you can follow to get all set up:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | # Get the CitizenNet starter Halogen project git clone git@github.com:citizennet/purescript-halogen-template.git # Change into the directory and install packages cd purescript-halogen-template && yarn # Install a new package: purescript-halogen-select bower i --save purescript-halogen-select # Build the project yarn build # Open the application in the browser open dist/index.html |
After you complete each step in the tutorial, make sure to rebuild the project and refresh your browser to see your updated component.
Helpful tip: Watching for file changes¶
It’s convenient to keep a terminal running which watches for file changes, rebuilds the project, and bundles JavaScript on your behalf. Then, when you make a change to a file, all you have to do is wait a moment and refresh the page to see your updates.
When I write PureScript, I usually work with two terminals open. I use the first to write code, and the second to watch those changes and rebuild. I recommend using the same technique as you walk through these tutorials. These three steps are all you need:
- Open a new terminal and run the
watch
script - Open your editor to a source file
- Open a new tab in your browser pointed to
dist/index.html
so you can see the app
To test everything is working, try editing src/Component.purs
to change the title of the page. The project should automatically rebuild on save. Then, when you refresh the browser, you should see your new text rendered.
1 2 | # Watch for changes and rebuild (remember to refresh the page after builds)
yarn watch
|
A whirlwind tour of our starter component¶
The project starts off with a minimal Halogen component. As a brief refresher, I'll step through each of the types and functions involved.
Info
If you are already quite familiar with Halogen, feel free to skip this section entirely.
Query Algebra¶
How does a component know what to do?
In Halogen, we give names to each computation we'd like a component to run. Computations that can have side effects but don't return anything are colloquially called actions; those that can have side effects and also return something are called requests. The type that lists out the possible actions and requests for a component is called the component's query algebra. The Halogen guide has a relevant section about query algebras if you'd like to know more.
What actions and requests can our starter component perform? By looking at the query algebra, we see just one constructor:
1 2 | data Query a = NoOp a |
All we know so far is that this component can do one thing: evaluate a query called NoOp
. We'll see what it does later on when we look at the eval
function.
State¶
Every component encapsulates some state, described by its State
type. You will usually see Halogen components use records to hold state, like this:
1 | type State = { on :: Boolean, name :: String } |
State is the core of your component. Most of the queries you see in Halogen components modify state in some way, and the render function that produces HTML for the component has only the State
type as its argument.
For our starter component, we don't need any state just yet, so we've simply assigned it the Unit
type. When we start building selection components, however, we'll soon create a record to hold our state.
1 | type State = Unit |
Input¶
A component's Input
type can be thought of as a container for any information you'd like to pass to the component. It's most commonly used to provide a component with some initial State
values via the initialState :: Input -> State
function. However, it's more powerful than that!
Once a Halogen component has been mounted to the DOM, there is only one way to continue sending it new information: its Input
type paired with its receiver
function. Every time the parent component re-renders, it will send a new Input
to the child component.
For more information on the Input
type, see the Parent and Child Components section of the Halogen guide.
Our starter component doesn't need any input, so we'll assign it the Unit
type. However, once we build a dropdown or typeahead, we'll probably want to receive the list of items that can be selected as input.
1 | type Input = Unit |
Message¶
How does a component tell its parent when something important has happened? In Halogen, this is accomplished with a Message
type. Like the query algebra, this is just a type describing messages that can be raised, containing some information. To actually trigger sending a particular message, you can use the raise
function provided by Halogen.
When we start building selection components, we'll use messages to notify parent components when items have been selected or removed. Our starter component doesn't need to raise any messages, however, so we've given it the Void
type.
Why are we using Void
when we have no messages?
Why use Void
instead of Unit
for the Message
type when it has no constructors? This is common practice in Halogen because of how messages are used by parent components. When your component raises a message, it gets handled by the parent using a function like this:
Child.Message -> Maybe (ParentQuery Unit)
If you want to ignore all messages from the child, you could write an implementation like this:
Halogen.HTML.Events.input <<< const Nothing
However, if the child's message type is Void
, then you can use the absurd
function from Data.Void
:
absurd :: Void -> a
This saves you a bit of typing when you mount a child component in a slot and makes it absolutely unambiguous that there are no messages to handle. It also ensures that if you add a message to the child component later on you'll get a compiler error -- this is a good thing!
Compare mounting a child component that uses Unit
to represent "no messages" vs. using Void
:
1 2 3 4 5 6 7 | -- It's unclear whether you're ignoring all messages or whether there are -- simply no messages to handle. HH.slot ComponentSlot component unit (Halogen.HTML.Events.input <<< const Nothing) -- It's obvious there are no messages, and if that changes (the component adds a -- message) you'll get a nice compile-time error. HH.slot ComponentSlot component unit absurd |
For more information on messages, see the Parent and Child Components section in the Halogen guide.
1 | type Message = Void |
ChildQuery and ChildSlot¶
Halogen components often have further child components. To maintain type safety when managing multiple child components, Halogen uses a pair of concepts: child queries and child slots.
-
The ChildQuery type lists out each unique type of child component your component has. For each type of child component, you'll add its query type here.
-
The ChildSlot type works like an address book for the various child components. If you only have one child component of any distinct
ChildQuery
, then you can just useunit
. However, if you have multiple children with the same query type, you need some way to distinguish between them. It's common to use custom types or integers for this.
See the Multiple Types of Child Component section of the Halogen guide for more details.
For now, our component has no children. Once we bring in the Select
component we'll update these types.
1 2 | type ChildQuery = Const Void type ChildSlot = Unit |
Component¶
Ah! We can finally create our component. The actual component definition is simple: we call the parentComponent
function from Halogen to assert we're creating a component that can have further child components and provide it with the four functions it needs to operate. More on those in a moment!
1 2 3 4 5 6 7 8 9 10 | component :: ∀ m. MonadAff m => H.Component HH.HTML Query Input Message m component = H.parentComponent { initialState , render , eval , receiver: const Nothing } where |
Next, lets look at those function definitions, defined in the where
clause:
initialState¶
The initialState
function describes how to go from the component's Input
type to its State
type. In this case, our State
type is just Unit
, so we'll throw away the input and return unit
.
1 2 3 4 5 | initialState :: Input -> State initialState = const unit -- Could also be written this way: initialState = id |
render¶
The render
function describes how to go from the component's State
type to some HTML, where that HTML can include any of the components listed in the ChildQuery
type. You'll use plenty of code from these modules when writing render functions:
1 2 3 | import Halogen.HTML as HH import Halogen.HTML.Events as HE import Halogen.HTML.Properties as HP |
We're going to spend a lot of time writing render functions in the following tutorials. You can refer to the Halogen guide's section on rendering for more information.
For now we won't render anything to the page, represented by an empty div.
1 2 | render :: State -> H.ParentHTML Query ChildQuery ChildSlot m render st = HH.div_ [] |
eval¶
The eval
function describes what to do when one of the queries from the component's query algebra is called. There are various ways a query can be triggered:
- The parent component can trigger a child component query using the
query
function - A user can trigger a query with an event in HTML, like
onClick
- The
eval
function can recursively call itself while evaluating a query
The eval
function is where you get to actually define what all your queries do. Unlike the render function, you can actually perform all kinds of side effects here, like make API calls, update state, trigger queries in child components, raise messages, and more.
As usual, our starter component won't do much in its eval
. When it receives the NoOp
constructor, it will do nothing and return the contents of the query, which in this case will always be unit
.
1 2 3 | eval :: Query ~> H.ParentDSL State Query ChildQuery ChildSlot Message m eval = case _ of NoOp next -> pure next |
receiver¶
The receiver
function describes what to do when a parent component sends in new Input
. Its type signature looks like this:
1 | receiver :: Input -> Maybe (Query Unit) |
Once a Halogen component has been mounted, the only way to send it new input is via its receiver
function. When its parent re-renders, it will automatically send the child component's input type again, and it's up to the receiver
function to decide what to do with it.
This function can either provide a query to call, or Nothing
if you'd like to ignore new input. If you elect to provide a query then you unlock all the power available in the eval
function and can describe all sorts of things to do on new input, like making API calls or updating state.
In our case, we don't care about new input, so we'll ignore the input and return Nothing
.
1 2 3 | { ... , receiver: const Nothing } |