19 KiB
ryannhg/elm-app
a way to build single page apps with Elm.
installing
elm install ryannhg/elm-app
motivation
Every time I try and create a single page application from scratch in Elm, I end up repeating a few first steps from scratch:
-
Routing - Implementing
onUrlChange
andonUrlRequest
for myupdate
function. -
Page Transitions - Fading in the whole app on load and fading in/out the pages (not the persistent stuff) on route change.
-
Wiring up pages - Every page has it's own
Model
,Msg
,init
,update
,view
, andsubscriptions
. I need to bring those together at the top level. -
Sharing app model - In addition to updating the page model, I need a way for pages to update the shared app model used across pages (signed in users).
This package is an attempt to create a few abstractions on top of Browser.application
to make creating single page applications focus on what makes your app unique.
is it real?
-
A working demo is available online here: https://elm-app-demo.netlify.com/
-
And you can play around with an example yourself in the repo: https://github.com/ryannhg/elm-app/tree/master/examples/basic around with the files in
examples/basic/src
and change things!
examples are helpful!
Let's walk through the package together, at a high-level, with some code!
src/Main.elm
our-project/
elm.json
src/
Main.elm ✨
This is the entrypoint to the application, and connects all the parts of our Application
together:
module Main exposing (main)
import Application
main =
Application.create
{ routing = -- TODO
, layout = -- TODO
, pages = -- TODO
}
As you can see, Application.create
is a function that takes in a record
with three properties:
-
routing - handles URLs and page transitions
-
layout - the app-level
init
,update
,view
, etc. -
pages - the page-level
init
,update
,view
, etc.
routing
module Main exposing (main)
import Application
import Route ✨
main =
Application.create
{ routing =
{ fromUrl = Route.fromUrl ✨
, toPath = Route.toPath ✨
, transitionSpeed = 200 ✨
}
, layout = -- TODO
, pages = -- TODO
}
The record for routing
only has three properties:
-
fromUrl - a function that turns a
Url
into aRoute
-
toPath - a function that turns a
Route
into aString
used for links. -
transitionSpeed - number of milliseconds it takes to fade in/out pages.
The implementation for fromUrl
and toPath
don't come from the src/Main.elm
. Instead we create a new file called src/Route.elm
, which handles all this for us in one place!
We'll link to that in a bit!
layout
module Main exposing (main)
import Application
import Route
import Components.Layout as Layout ✨
main =
Application.create
{ routing =
{ fromUrl = Route.fromUrl
, toPath = Route.toPath
, transitionSpeed = 200
}
, layout =
{ init = Layout.init ✨
, update = Layout.update ✨
, view = Layout.view ✨
, subscriptions = Layout.subscriptions ✨
}
, pages = -- TODO
}
The layout
property introduces four new pieces:
-
init - how to initialize the shared state.
-
update - how to update the app-level state (and routing commands).
-
view - the app-level view (and where to render our page view)
-
subscriptions - app-level subscriptions (regardless of which page we're on)
Just like before, a new file src/Components/Layout.elm
will contain all the functions we'll need for the layout, so that Main.elm
is relatively focused.
pages
module Main exposing (main)
import Application
import Route
import Components.Layout as Layout
import Pages ✨
main =
Application.create
{ routing =
{ fromUrl = Route.fromUrl
, toPath = Route.toPath
, transitionSpeed = 200
}
, layout =
{ init = Layout.init
, update = Layout.update
, view = Layout.view
, subscriptions = Layout.subscriptions
}
, pages =
{ init = Pages.init ✨
, update = Pages.update ✨
, bundle = Pages.bundle ✨
}
}
Much like the last property, pages
is just a few functions.
The init
and update
parts are fairly the same, but there's a new property that might look strange: bundle
.
The "bundle" is a combination of view
, title
, subscriptions
that allows our new src/Pages.elm
file to reduce a bit of boilerplate! (There's a better explanation in the src/Pages.elm
section of the guide.)
that's it for Main.elm!
As the final touch, we can update our import statements to add in a type annotation for the main
function:
module Main exposing (main)
import Application exposing (Application) ✨
import Flags exposing (Flags) ✨
import Global ✨
import Route exposing (Route) ✨
import Components.Layout as Layout
import Pages
main : Application Flags Route Global.Model Global.Msg Pages.Model Pages.Msg ✨
main =
Application.create
{ routing =
{ fromUrl = Route.fromUrl
, toPath = Route.toPath
, transitionSpeed = 200
}
, layout =
{ init = Layout.init
, update = Layout.update
, view = Layout.view
, subscriptions = Layout.subscriptions
}
, pages =
{ init = Pages.init
, update = Pages.update
, bundle = Pages.bundle
}
}
Instead of main being the traditional Program Flags Model Msg
type, here we use Application Flags Route Global.Model Global.Msg Pages.Model Pages.Msg
, which is very long and spooky!
This is caused by the fact that our Application.create
needs to know more about the Flags
, Route
, Global
, and Pages
types so it can do work for us.
But enough of that– let's move on to routing next!
src/Route.elm
our-project/
elm.json
src/
Main.elm
Route.elm ✨
in our new file, we need to create a custom type to handle all the possible routes.
module Route exposing (Route(..))
type Route
= Homepage
| SignIn
For now, there is only two routes: Homepage
and SignIn
.
We also need to make fromUrl
and toPath
so our application handles routing and page navigation correctly!
For that, we need to install the official elm/url
package:
elm install elm/url
And use the newly installed Url
and Url.Parser
modules like this:
module Route exposing
( Route(..)
, fromUrl ✨
, toPath ✨
)
import Url exposing (Url) ✨
import Url.Parser as Parser exposing (Parser) ✨
type Route
= Homepage
| SignIn
fromUrl : Url -> Route ✨
-- TODO
toPath : Route -> String ✨
-- TODO
fromUrl
Let's get started on implementing fromUrl
by using the Parser
module:
type Route
= Homepage
| SignIn
| NotFound ✨ -- see note #2
fromUrl : Url -> Route
fromUrl url =
let
router =
Parser.oneOf ✨ -- see note #1
[ Parser.map Homepage Parser.top
, Parser.map SignIn (Parser.s "sign-in")
]
in
Parser.parse router url
|> Maybe.withDefault NotFound ✨ -- see note #2
Notes
-
With
Parser.oneOf
, we match/
toHomepage
and/sign-in
toSignIn
. -
Parser.parse
returns aMaybe Route
because it not find a match in ourrouter
. That means we need to add aNotFound
case (good catch, Elm!)
toPath
It turns out toPath
is really easy, its just a case expression:
toPath : Route -> String ✨
toPath route =
case route of
Homepage -> "/"
SignIn -> "/sign-in"
NotFound -> "/not-found"
that's it for Route.elm!
here's the complete file we made.
module Route exposing
( Route(..)
, fromUrl
, toPath
)
import Url exposing (Url)
import Url.Parser as Parser exposing (Parser)
type Route
= Homepage
| SignIn
| NotFound
fromUrl : Url -> Route
fromUrl url =
let
router =
Parser.oneOf
[ Parser.map Homepage Parser.top
, Parser.map SignIn (Parser.s "sign-in")
]
in
Parser.parse router url
|> Maybe.withDefault NotFound
toPath : Route -> String
toPath route =
case route of
Homepage -> "/"
SignIn -> "/sign-in"
NotFound -> "/not-found"
You can learn how to add more routes by looking at:
-
the
elm/url
docs - https://package.elm-lang.org/packages/elm/url/latest -
the example in this repo -https://github.com/ryannhg/elm-app/blob/master/examples/basic/src/Route.elm
src/Flags.elm
our-project/
elm.json
src/
Main.elm
Route.elm
Flags.elm ✨
For this app, we don't actually have flags, so we return an empty tuple.
module Flags exposing (Flags)
type alias Flags = ()
So let's move onto something more interesting!
src/Global.elm
our-project/
elm.json
src/
Main.elm
Route.elm
Flags.elm
Global.elm
Ports.elm ✨
The purpose of Global.elm
is to define the Model
and Msg
types we'll share across pages and use in our layout functions:
module Global exposing ( Model, Msg(..) )
type alias Model =
{ isSignedIn : Bool
}
type Msg
= SignIn
| SignOut
| Log String
Here we create a simple record to keep track of the user's sign in status.
Let's see an example of Global.Model
and Global.Msg
being used in our layout:
src/Ports.elm
our-project/
elm.json
src/
Main.elm
Route.elm
Flags.elm
Global.elm
Ports.elm ✨
If you need to use ports, create a file called src/Ports.elm
port module Ports exposing (log)
port log : String -> Cmd msg
We'll use them in the layouts component up next!
src/Components/Layout.elm
our-project/
elm.json
src/
Main.elm
Route.elm
Flags.elm
Global.elm
Ports.elm
Components/
Layout.elm ✨
To implement an app-level layout, we'll need a new file:
module Components.Layout exposing (init, update, view, subscriptions)
import Global
import Route exposing (Route)
-- ...
This file needs to export the following four functions:
init
init :
{ flags : Flags
, route : Route
, navigateTo : Route -> Cmd msg
}
-> ( Global.Model, Cmd Global.Msg, Cmd msg )
init _ =
( { isSignedIn = False }
, Cmd.none
, Cmd.none
)
Initially, our layout has access to a record with three fields:
-
flags - the initial JSON passed in with the app.
-
route - the current route
-
navigateTo - allows programmatic navigation to other pages.
For our example, we set isSignedIn
to False
, don't perform any Global.Msg
side effects, nor use messages.navigateTo
to change to another page.
update
import Ports ✨
update :
{ flags : Flags
, route : Route
, navigateTo : Route -> Cmd msg
}
-> Global.Msg
-> Global.Model
-> ( Global.Model, Cmd Global.Msg, Cmd msg )
update { navigateTo } msg model =
case msg of
Global.SignIn ->
( { model | isSignedIn = True }
, Cmd.none
, navigateTo Route.Homepage
)
Global.SignOut ->
( { model | isSignedIn = False }
, Cmd.none
, navigateTo Route.SignIn
)
Log message ->
( model
, Cmd.none
, Ports.log message
)
In addition to the record we saw earlier with init
, our layout's update
function take a Global.Msg
and Global.Model
:
That allows us to return an updated state of the app, and programmatically navigate to different pages!
view
view :
{ flags : Flags
, route : Route
, viewPage : Html msg
, toMsg : Global.Msg -> msg
}
-> Global.Model
-> Html msg
view { viewPage, toMsg } model =
div [ class "layout" ]
[ Html.map toMsg (viewNavbar model)
, viewPage
, viewFooter
]
Instead of navigateTo
, our view
function takes in a record with two other properties:
-
viewPage - where we want the rendered page to show up in our layout
-
toMsg - a way to convert from
Global.Msg
tomsg
, so that components can send global messages, but still returnHtml msg
.
The viewNavbar
function is an example of where we would use Html.map toMsg
to turn Html Global.Msg
into Html msg
:
viewNavbar : Global.Model -> Html Global.Msg
viewNavbar model =
header
[ class "navbar" ]
[ a [ href (Route.toPath Route.Homepage) ]
[ text "Home" ]
, if model.isSignedIn then
button
[ Events.onClick SignOut ]
[ text "Sign out" ]
else
button
[ Events.onClick SignIn ]
[ text "Sign in" ]
]
The viewFooter
function doesn't send messages, so Html.map toMsg
isn't necessary!
viewFooter : Html msg
viewFooter =
footer
[ class "footer" ]
[ text "Build with Elm in 2019" ]
If you'd like, you can update the view to use components in folders like this:
our-project/
elm.json
src/
Main.elm
Route.elm
Flags.elm
Global.elm
Ports.elm
Components/
Layout.elm
Navbar.elm ✨
Footer.elm ✨
import Components.Navbar as Navbar ✨
import Components.Footer as Footer ✨
-- ...
view :
{ flags : Flags
, route : Route
, viewPage : Html msg
, toMsg : Global.Msg -> msg
}
-> Global.Model
-> Html msg
view { viewPage, toMsg } model =
div [ class "layout" ]
[ Html.map toMsg (Navbar.view model) ✨
, viewPage
, Footer.view ✨
]
Moving Components.Layout.viewNavbar
into Components.Navbar.view
subscriptions
subscriptions :
{ navigateTo : Route -> Cmd msg
, route : Route
, flags : Flags
}
-> Global.Model
-> Html Global.Msg
subscriptions _ model =
Sub.none
That's the entire file! Here it is
src/Pages.elm
our-project/
elm.json
src/
Main.elm
Route.elm
Flags.elm
Global.elm
Ports.elm
Pages.elm ✨
Components/
Layout.elm
Navbar.elm
Footer.elm
module Pages exposing (init, update, bundle)
import Pages.Homepage
import Pages.SignIn
import Pages.NotFound
type Model
= HomepageModel ()
| SignInModel Pages.SignIn.Model
| NotFoundModel ()
type Msg
= HomepageMsg Never
| SignInMsg Pages.SignIn.Msg
| NotFoundMsg Never
pages = -- TODO
init = -- TODO
update = -- TODO
bundle = -- TODO
Here we define a top level Model
and Msg
, so we can easily implement init
, update
, and bundle
.
pages
import Application.Page as Page ✨
pages =
{ homepage =
Page.static
{ title = Pages.Homepage.title
, view = Pages.Homepage.view
, toModel = HomepageModel
}
, signIn =
Page.page
{ title = Pages.SignIn.title
, init = Pages.SignIn.init
, update = Pages.SignIn.update
, subscriptions = Pages.SignIn.subscriptions
, view = Pages.SignIn.view
, toModel = SignInModel
, toMsg = SignInMsg
}
, notFound =
Page.static
{ title = Pages.NotFound.title
, view = Pages.NotFound.view
, toModel = NotFoundModel
}
}
The Page
type is the important abstraction that allows us to make our init
function take in the same shape.
init
import Application ✨
import Flags exposing (Flags) ✨
import Global ✨
import Route exposing (Route) ✨
init :
Route
-> Application.Update Flags Route Global.Model Global.Msg Model Msg
init route =
case route of
Route.Homepage ->
Application.init
{ page = pages.homepage
}
Route.SignIn ->
Application.init
{ page = pages.signIn
}
Route.NotFound ->
Application.init
{ page = pages.notFound
}
update
update :
Msg
-> Model
-> Application.Update Flags Route Global.Model Global.Msg Model Msg
update appMsg appModel =
case ( appModel, appMsg ) of
( HomepageModel model, HomepageMsg msg ) ->
Application.update
{ page = pages.homepage
, msg = msg
, model = model
}
( HomepageModel _, _ ) ->
Application.keep appModel
( SignInModel model, SignInMsg msg ) ->
Application.update
{ page = pages.signIn
, msg = msg
, model = model
}
( SignInModel _, _ ) ->
Application.keep appModel
( NotFoundModel model, NotFoundMsg msg ) ->
Application.update
{ page = pages.notFound
, msg = msg
, model = model
}
( NotFoundModel _, _ ) ->
Application.keep appModel
bundle
bundle :
Model
-> Application.Bundle Flags Route Global.Model Msg
bundle appModel =
case appModel of
HomepageModel model ->
Application.bundle
{ page = pages.homepage
, model = model
}
SignInModel model ->
Application.bundle
{ page = pages.signIn
, model = model
}
NotFoundModel model ->
Application.bundle
{ page = pages.notFound
, model = model
}
Like with the last two examples, Application.bundle
makes our case expression consistent. Behind the scenes, bundle
is used to provide view
, subscriptions
, and title
.
The alternative would look super repetitive:
-- AN IMPROVEMENT ON
view appModel =
case appModel of
HomepageModel model -> Application.view { ... }
SignInModel model -> Application.view { ... }
NotFoundModel model -> Application.view { ... }
title appModel =
case appModel of
HomepageModel model -> Application.title { ... }
SignInModel model -> Application.title { ... }
NotFoundModel model -> Application.title { ... }
subscriptions appModel =
case appModel of
HomepageModel model -> Application.subscriptions { ... }
SignInModel model -> Application.subscriptions { ... }
NotFoundModel model -> Application.subscriptions { ... }
The bundle
abstraction gives us the ability to only write one case expression at the top level for all three of these things 😎
You can find src/Pages/*.elm
examples in the [basic example]. All those pages are really just normal Elm init/update/view
things!
that's it!
Thanks for reading this huge README, I hope this package helps you build great single page apps with Elm! 😄