How to create a SPA application in Elm 0.18 (from scratch)

Basic routing

The new Elm version introduces some breaking changes in the syntax and the navigation libraries that simplifies a bit the creation of SPA apps using Elm.

I’ve been working with Python and Django since 2007, and lately, functional languages caught my eye. I’m very interested in real time applications under high loads. Even though Django is great, it wasn’t designed under this paradigm. That’s why I’m investing time on learning Elixir and Elm. Both projects are really exciting, I didn’t have this feeling since I discovered Django. I never liked nodejs to be honest, and perhaps the main reason is that I don’t like javascript.

This is where Elm comes into action. I’m new in Elm, and the change of paradigm from OO to functional is not easy. Also, Elm lacks the maturity of other languages and frameworks where you can find tons of examples. The good point is that once you grasp some basic concepts you don’t want to go back again.

In order to learn more about Elixir, Phoenix and Elm, I decided to rewrite a medium-sized product that was written in Django. I’m happy with the decision, but some things like SPAs took me some time to learn.

Here I will explain a method to create SPAs, using ELM. Something very basic that could be easily extended.

All the code lives here: https://github.com/AdrianRibao/elm-spa-example and I will tag commits with each step of the process.

So starting from a basic elm structure like this, we are creating a basic SPA with some concepts I learned in the way.

module SPA exposing (..)

import Html exposing (..)


main =
Html.program
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
}


type alias Model =
{}


init : ( Model, Cmd Msg )
init =
( Model, Cmd.none )



-- UPDATE


type Msg
= Noop


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Noop ->
( model, Cmd.none )



-- VIEW


view : Model -> Html Msg
view model =
div []
[ text "Hello world!"
]



-- SUBSCRIPTIONS


subscriptions : Model -> Sub Msg
subscriptions model =
Sub.none

SPAs means that we have several pages, so the view function can render the appropriate page each time. First thing is to know which page is currently active, so we need to track it in the model.

We will define also a Page union type with the pages that will conform our application, for example:

type Page
= Home
| Login
| About

and update our model to track the current Page:

type alias Model =
{ currentPage : Page
}

We need to change also the init function to return the initial current page (Home in this case):

init : ( Model, Cmd Msg )
init =
( Model Home, Cmd.none )

So now let’s change our view function so it shows the content for each selected page:

view : Model -> Html Msg
view model =
div []
[ h1 [] [ text "SPA application" ]
, render_page model
]

where the function render_page is:

render_page : Model -> Html Msg
render_page model =
let
page_content =
case model.currentPage of
Home ->
text "Home"
                Login ->
text "Login"
                About ->
text "About"
in
div [] [ page_content ]

We have to take care of all the pages defined in the Page union type, otherwise, the compiler will complain with a nice error message: “This `case` does not have branches for all possibilities.” This is one of the reasons that make Elm so great. The compiler will remind us if we forget something.

So now the only thing to do is to change from page to page. This is done sending messages to the update function

Let’s add a menu in the view

view : Model -> Html Msg
view model =
div []
[ h1 [] [ text "SPA application" ]
, render_menu model
, render_page model
]
render_menu : Model -> Html Msg
render_menu model =
div []
[ button [ onClick GoHome ] [ text "Home" ]
, button [ onClick GoLogin ] [ text "Login" ]
, button [ onClick GoAbout ] [ text "About" ]
]

Here we have added three messages:

  • GoHome
  • GoLogin
  • GoAbout

so let’s add them to the Msg union type:

type Msg
= GoHome
| GoLogin
| GoAbout

Each time the buttons are pressed, the update function will receive the messages and will update the model:

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
GoHome ->
( { model | currentPage = Home }, Cmd.none )
        GoLogin ->
( { model | currentPage = Login }, Cmd.none )
        GoAbout ->
( { model | currentPage = About }, Cmd.none )

At this point, we already have a view that is rendering different content based on the current page defined in the model, but the code could be improved in several ways yet.

Besides, we need to take care also of the url changes. It’s expected that if we enter the path /app#login in the browser, the app must render the login page. And every time we click the button, the URL should change in the browser accordingly. This will be our next step.

You can find the code done so far here: https://github.com/AdrianRibao/elm-spa-example/tree/step1


Managing URL changes

Now it’s time to manage changes in URL four our SPA. We will be using the Navigation package: http://package.elm-lang.org/packages/elm-lang/navigation/latest/Navigation

This package allow us to work with URL Locations. Every time the location changes, it will send it to a function, where we have to manage it and return a Msg that will be intercepted in the update function.

We need to update our main function to use Navigation.program, which takes an extra argument that is the function to be called every time the url changes. Also, the init function now takes the location value so we can set the currentPage based on the location value.

main =
Navigation.program locFor
{ init = init
, update = update
, view = view
, subscriptions = subscriptions
}

Here the locFor function is taking the location and returning a message every time the location changes.

locFor : Location -> Msg
locFor location =
case location.hash of
"#home" ->
GoHome
        "#login" ->
GoLogin
        "#about" ->
GoAbout
        _ ->
GoHome

And let’s update the init function so it sets the currentPage every time we enter the app:

init : Location -> ( Model, Cmd Msg )
init location =
let
page =
case location.hash of
"#home" ->
Home
                "#login" ->
Login
                "#about" ->
About
                _ ->
Home
in
( Model page, Cmd.none )

We are repeating a lot of code but we will take care of this later.

At this point if you are using elm reactor, you can go to any of this URLs the correct page will be rendered:

  • http://localhost:8000/main.elm?#home
  • http://localhost:8000/main.elm?#login
  • http://localhost:8000/main.elm?#about

So the next step is to update the url when clicking on the buttons.

First, let’s add a new Msg named LinkTo

type Msg
= GoHome
| GoLogin
| GoAbout
| LinkTo String

and the update function that will take care of this new Msg.

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
GoHome ->
( { model | currentPage = Home }, Cmd.none )
        GoLogin ->
( { model | currentPage = Login }, Cmd.none )
        GoAbout ->
( { model | currentPage = About }, Cmd.none )
        LinkTo path ->
( model, newUrl path )
Note: The newUrl function comes from the Navigation Package

and the view function that will use LinkTo

render_menu : Model -> Html Msg
render_menu model =
div []
[ button [ onClick (LinkTo "#home") ] [ text "Home" ]
, button [ onClick (LinkTo "#login") ] [ text "Login" ]
, button [ onClick (LinkTo "#about") ] [ text "About" ]
]

That’s it, now the URL is changing on every button click. Great! We can copy the url from the browser, paste it in other browser and the page will be rendered correctly. Thanks to elm, we are sure that all possibilities are managed, and a route that is not managed explicitly in the code will render the Home page.

You can find the code written so far here: https://github.com/AdrianRibao/elm-spa-example/tree/step2

This works, but we can do it better. We can try to simplify the code and manage parameters in the urls, for example for a path like: /posts/<post_id>/


Simplify messages

The next step is to remove all the GoHome, GoLogin… messages and replace with a single GoTo Message. This way it will be easier to add pages to our application.

type Msg
= GoTo Page
| LinkTo String

And the update function can be reduced to:

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
GoTo page ->
( { model | currentPage = page }, Cmd.none )
        LinkTo path ->
( model, newUrl path )

and the locFor function is now:

locFor : Location -> Msg
locFor location =
case location.hash of
"#home" ->
GoTo Home
        "#login" ->
GoTo Login
        "#about" ->
GoTo About
        _ ->
GoTo Home

It looks better now.

url-parser

Now we will be adding the package url-parser to manage routes and it’s parameters. The documentation for url-parser lives in http://package.elm-lang.org/packages/evancz/url-parser/latest

Let’s define our routes using UrlParser:

route : Parser (Page -> a) a
route =
oneOf
[ UrlParser.map Home (UrlParser.s "home")
, UrlParser.map Login (UrlParser.s "login")
, UrlParser.map About (UrlParser.s "about")
]

and the locFor function now will be using this route function:

locFor : Location -> Msg
locFor location =
parseHash route location
|> GoTo

There is one more thing we need to do now. The Navigation.parseHash function returns a Maybe Page and we are expecting a Page so let’s manage this new data:

type Msg
= GoTo (Maybe Page)
| LinkTo String

and of course we need to modify the update function:

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
GoTo maybepage ->
case maybepage of
Nothing ->
( { model | currentPage = Home }, Cmd.none )
                Just page ->
( { model | currentPage = page }, Cmd.none )
        LinkTo path ->
( model, newUrl path )

This is saying that if no route is found it returns Nothing and we will render Home view.

And the last thing is to update the init function to use the parseHash function:

init : Location -> ( Model, Cmd Msg )
init location =
let
page =
case parseHash route location of
Nothing ->
Home
                Just page ->
page
in
( Model page, Cmd.none )

With this changes, we have removed some duplicated code, and adding pages it’s now easier.

Here is the code: https://github.com/AdrianRibao/elm-spa-example/tree/step3

This has been a long post, but before ending it, I’d like to add another route to the SPA, one that shows posts based on the ids.

We want to show a Post page and we receive the id of the post we want to render. The Page would be like:

type Page
= Home
| Login
| About
| PostShow Int

add to the routes:

route : Parser (Page -> a) a
route =
oneOf
[ UrlParser.map Home (UrlParser.s "home")
, UrlParser.map Login (UrlParser.s "login")
, UrlParser.map About (UrlParser.s "about")
, UrlParser.map PostShow (UrlParser.s "post" </> int)
]

and if you compile you’ll se Elm asking us to manage this new Page in the view, so let’s do it:

render_page : Model -> Html Msg
render_page model =
let
page_content =
case model.currentPage of
Home ->
text "Home"
                Login ->
text "Login"
                About ->
text "About"
                PostShow postid ->
text ("Render the post with id: " ++ toString postid)
in
div [] [ page_content ]

Let’s test if it works adding a new button that will take us to post with id 17:

render_menu : Model -> Html Msg
render_menu model =
div []
[ button [ onClick (LinkTo "#home") ] [ text "Home" ]
, button [ onClick (LinkTo "#login") ] [ text "Login" ]
, button [ onClick (LinkTo "#about") ] [ text "About" ]
, button [ onClick (LinkTo "#post/17") ] [ text "Go to post 17" ]
]

We can also try to navigate to an url with other ids: /main.elm?#post/9 and you’ll see the post_id being shown correctly.

What happens if we try to access /main.elm?#post/whatever ?

As expected, elm covers all the options. No runtime errors. This route won’t match any route defined in the route function and will return Nothing which in our case just renders the Home page.

The final code: https://github.com/AdrianRibao/elm-spa-example/tree/step4

Next steps could involve creating a reverse function that returns a location given a certain page, like:

urlFor (Post 12)
returns: #posts/12

is a simple task but I will leave it as an exercise.

In this post I described the process that led me to implement SPA app, but as I said, I’m just learning Elm, so comments are welcome!

One clap, two clap, three clap, forty?

By clapping more or less, you can signal to us which stories really stand out.