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:

counter app

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:

  1. When our Elm app starts up, we pass in the current value of localStorage via flags. Initially, this will pass in null, because no data has been stored yet.
  1. We subscribe to the save port for events from Elm, which we'll wire up on the Elm side shortly.
  1. 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 the load 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 to Storage.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