Storage
Source code: GitHub
Let's start by creating a new project with the elm-spa CLI:
elm-spa new
Creating a stateful page
Let's create a simple interactive app, based on the official Elm counter example. The elm-spa add
command will make this a breeze:
elm-spa add / sandbox
This will stub out the init
, update
, and view
function for us, and wire them together with Page.sandbox
like this:
-- src/Pages/Home_.elm page : Shared.Model -> Request -> Page.With Model Msg page = Page.sandbox { init = init , update = update , view = view }
Let's add in the implementation from the counter example to get a working app!
init
-- src/Pages/Home_.elm type alias Model = { counter : Int } init : Model init = { counter = 0 }
update
-- src/Pages/Home_.elm type Msg = Increment | Decrement update : Msg -> Model -> Model update msg model = case msg of Increment -> { model | counter = model.counter + 1 } Decrement -> { model | counter = model.counter - 1 }
view
-- src/Pages/Home_.elm view : Model -> View Msg view model = { title = "Homepage" , body = [ Html.h1 [] [ Html.text "Local storage" ] , Html.button [ Html.Events.onClick Increment ] [ Html.text "+" ] , Html.p [] [ Html.text ("Count: " ++ String.fromInt model.counter) ] , Html.button [ Html.Events.onClick Decrement ] [ Html.text "-" ] ] }
After these functions are in place, we can spin up our server with the elm-spa CLI:
elm-spa server
And this is what we should see at http://localhost:1234:
Playing with the counter
As we click the "+" and "-" buttons, the counter value should be working great. When we refresh the page, the counter value is 0 again.
Let's use local storage to keep the counter value around!
The JS side
To do this, we'll be using flags and ports, a typesafe way to work with JavaScript without causing runtime errors in our Elm application!
Let's edit public/index.html
as a starting point:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> </head> <body> <script src="/dist/elm.js"></script> <!-- EDIT THIS LINE --> <script src="/main.js"></script> </body> </html>
Here we replace the inline Elm.Main.init()
script generated by the elm-spa new
command with a reference to a new file we'll create in public/main.js
// public/main.js const app = Elm.Main.init() // ...
At this point, nothing has changed yet, but we now have access to app
– which will allow us to interact with our Elm app from the JS file!
Let's add in some ports like this:
// public/main.js const app = Elm.Main.init({ flags: JSON.parse(localStorage.getItem('storage')) }) app.ports.save.subscribe(storage => { localStorage.setItem('storage', JSON.stringify(storage)) app.ports.load.send(storage) })
This JS code is doing a few things:
- When our Elm app starts up, we pass in the current value of
localStorage
via flags. Initially, this will pass innull
, because no data has been stored yet.
- We subscribe to the
save
port for events from Elm, which we'll wire up on the Elm side shortly.
- When Elm sends a
save
event, we'll store the data in localStorage (making it ready for the next time the app starts up!) as well as send a message back to Elm via theload
port.
The Elm side
None of this code is working yet, because we need to define these save
and load
ports on the Elm side too!
Let's create a new file at src/Storage.elm
that defines the ports referenced on the JS side:
port module Storage exposing (..) import Json.Decode as Json port save : Json.Value -> Cmd msg port load : (Json.Value -> msg) -> Sub msg
Above, we've created a port module
that defines our save
and load
ports. Next, we'll describe the data we want to store, as well as how to convert it to and from JSON:
port module Storage exposing ( Storage, fromJson, onChange , increment, decrement ) import Json.Encode as Encode -- ... port definitions from before ... type alias Storage = { counter : Int } -- Converting to JSON toJson : Storage -> Json.Value toJson storage = Encode.object [ ( "counter", Encode.int storage.counter ) ] -- Converting from JSON fromJson : Json.Value -> Storage fromJson value = value |> Json.decodeValue decoder |> Result.withDefault initial decoder : Json.Decoder Storage decoder = Json.map Storage (Json.field "counter" Json.int) initial : Storage initial = { counter = 0 }
If this decoder stuff is new to you, please check out the JSON section of the Elm guide. It will lay a solid foundation for understanding decoders and encode functions!
Sending data to JS
For this example, we're going to define increment
and decrement
as side-effects because they change the state of the world. We'll be using the save
port to send these events to JS:
-- src/Storage.elm increment : Storage -> Cmd msg increment storage = { storage | counter = storage.counter + 1 } |> toJson |> save decrement : Storage -> Cmd msg decrement storage = { storage | counter = storage.counter - 1 } |> toJson |> save
This should look pretty similar to how our homepage handled the Increment
and Decrement
messages, but this time we use toJson
and save
to send an event for JS to handle.
( As a last step, we'll revisit Home_.elm
and swap out the old behavior with the new )
Listening for data from JS
We're going to add one final function to Storage.elm
that will allow us to subscribe to events from the load
port, that use's our fromJson
function to safely parse the message we get back:
onChange : (Storage -> msg) -> Sub msg onChange fromStorage = load (\json -> fromJson json |> fromStorage)
Here, the onChange
function will allow the outside world to handle the load
event without having to deal with raw JSON values by hand.
That's it for this file- now we're ready to use our Storage
module in our app!
Wiring up the shared state
Let's eject Shared.elm
by moving it from .elm-spa/defaults
into our src
folder. This will allow us to make local changes to it, as explained in the shared state section of the guide.
Our first step is to add Storage
to our Shared.Model
, so we can access storage
from any page in our application:
-- src/Shared.elm import Storage type alias Model = { storage : Storage }
The Shared.init
function is the only place we have access to Flags
, which is how JS passed in our initial value earlier. We can use Storage.fromJson
to convert that raw JSON into our nice Storage
type.
-- src/Shared.elm init : Request -> Flags -> ( Model, Cmd Msg ) init _ flags = ( { storage = Storage.fromJson flags } , Cmd.none )
Now let's listen for those load
events from JS, so we can update the Shared.Model
as soon as we get them. This code will use the Storage.onChange
function we made to send a Shared.Msg
to our Shared.update
function:
-- src/Shared.elm subscriptions : Request -> Model -> Sub Msg subscriptions _ _ = Storage.onChange StorageUpdated type Msg = StorageUpdated Storage update : Request -> Msg -> Model -> ( Model, Cmd Msg ) update _ msg model = case msg of StorageUpdated storage -> ( { model | storage = storage } , Cmd.none )
That's all for src/Shared.elm
. The last step is to upgrade our homepage to send side-effects instead of changing the data locally.
Upgrading Home_.elm
To gain access to Cmd msg
, we'll start by using Page.element
instead of Page.sandbox
. The signature of our init
and update
functions will need to change to handle the new capabilities:
Our Model
no longer needs to track the state of the application. This means the Home_.init
function won't be doing much at all:
-- src/Pages/Home_.elm type alias Model = {} init : ( Model, Cmd Msg ) init = ( {}, Cmd.none )
This time around, the update
function will need access to the current Storage
value and use Storage.increment
and Storage.decrement
to send commands to the JS side.
-- src/Pages/Home_.elm type Msg = Increment | Decrement update : Storage -> Msg -> Model -> ( Model, Cmd Msg ) update storage msg model = case msg of Increment -> ( model , Storage.increment storage ) Decrement -> ( model , Storage.decrement storage )
When the load
event comes in from JS, it triggers our Storage.onChange
subscription. This updates the storage
for us, meaning the storage.counter
we get in our view
will be the latest counter value.
-- src/Pages/Home_.elm view : Storage -> Model -> View Msg view storage _ = { title = "Homepage" , body = [ Html.h1 [] [ Html.text "Local storage" ] , Html.button [ Html.Events.onClick Increment ] [ Html.text "+" ] , Html.p [] [ Html.text ("Count: " ++ String.fromInt storage.counter) ] , Html.button [ Html.Events.onClick Decrement ] [ Html.text "-" ] ] }
We can use Page.element
to wire all these things up, and even pass Storage
into our view
and update
functions, which depend on the current value to do their thing:
-- src/Pages/Home_.elm page : Shared.Model -> Request -> Page.With Model Msg page shared _ = Page.element { init = init , update = update shared.storage , view = view shared.storage , subscriptions = \_ -> Sub.none }
Here, I've stubbed out
subscriptions
with an inline function, we won't be needing it, because Shared.subscriptions listens toStorage.onChange
for us.
Hooray!
In the browser, we now have a working counter app that persists on refresh. Even if you close the browser and open it up again, you'll see your previous counter value on the screen.
As a reminder, all the source code for this example is available on GitHub
Next up: User Authentication