[005.4] TodoMVC Part 7: LocalStorage

Implementing custom JSON encoders and Decoders in order to store our custom types in LocalStorage for our TodoMVC implementation.

Subscribe now

TodoMVC Part 7: LocalStorage [05.13.2016]

In the last episode we saw how to store our Counter model via LocalStorage using Ports. Today we'll just implement the same thing, but for our TodoMVC application.

Project

I've already brought the project up to the naive implementation you'd end up on after the previous episode. Let's walk through the changes really quickly:

  • Added a SetModel Model message
  • Switched update to return (Model, Cmd Msg)
  • Switched to program from beginnerProgram
  • Added storage output port and storageInput input port.
  • Added index.html file

So that's pretty similar to our previous episode on localStorage.

That should pretty much resolve everything. However, there is a problem. Let's try to compile the code:

./build
-- PORT ERROR --------------------------------------------------------- Main.elm

Port `storage` is trying to communicate an unsupported type.

290| port storage : Model -> Cmd msg
     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The specific unsupported type is:

    Main.FilterState

The types of values that can flow through in and out of Elm include:

    Ints, Floats, Bools, Strings, Maybes, Lists, Arrays, Tuples, Json.Values,
    and concrete records.

-- PORT ERROR --------------------------------------------------------- Main.elm

Port `storageInput` is trying to communicate an unsupported type.

283| port storageInput : (Model -> msg) -> Sub msg
     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The specific unsupported type is:

    Main.FilterState

The types of values that can flow through in and out of Elm include:

    Ints, Floats, Bools, Strings, Maybes, Lists, Arrays, Tuples, Json.Values,
    and concrete records.

Detected errors in 1 module.

The problem here is that Ports don't support sending custom Types through them raw, and our FilterState is a custom type. We could modify it so that it used a String to choose the state, but that's being lazy. Instead, we'll send Json.Encode.Values through, and handle serialization and deserialization ourselves:

import Json.Decode as Decode
-- We also want to use Json.Encode
import Json.Encode

-- We'll change our update to encode our model before sending to storage each
-- time we mutate it, via a `sendToStorage` function we'll define momentarily.
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        Add ->
            let
                newModel =
                    { model
                        | todos = model.todo :: model.todos
                        , todo = { newTodo | identifier = model.nextIdentifier }
                        , nextIdentifier = model.nextIdentifier + 1
                    }
            in
                ( newModel
                , sendToStorage newModel
                )

        Complete todo ->
            let
                updateTodo thisTodo =
                    if thisTodo.identifier == todo.identifier then
                        { todo | completed = True }
                    else
                        thisTodo

                newModel =
                    { model
                        | todos = List.map updateTodo model.todos
                    }
            in
                ( newModel
                , sendToStorage newModel
                )

        Delete todo ->
            let
                newModel =
                    { model | todos = List.filter (\mappedTodo -> todo.identifier /= mappedTodo.identifier) model.todos }
            in
                ( newModel
                , sendToStorage newModel
                )

        UpdateField str ->
            let
                todo =
                    model.todo

                updatedTodo =
                    { todo | title = str }

                newModel =
                    { model | todo = updatedTodo }
            in
                ( newModel
                , sendToStorage newModel
                )

        Filter filterState ->
            let
                newModel =
                    { model | filter = filterState }
            in
                ( newModel
                , sendToStorage newModel
                )

        Clear ->
            let
                newModel =
                    { model
                        | todos = List.filter (\todo -> todo.completed == False) model.todos
                    }
            in
                ( newModel
                , sendToStorage newModel
                )

        SetModel newModel ->
            ( newModel
            , Cmd.none
            )


-- We'll just map the storage input value into our model
subscriptions : Model -> Sub Msg
subscriptions model =
    storageInput mapStorageInput


-- We'll define how to encode our Model to Json.Encode.Values
encodeJson : Model -> Json.Encode.Value
encodeJson model =
    -- It's a json object with a list of fields
    Json.Encode.object
        -- The `todos` field is a list of encoded Todos, we'll define this encodeTodo function later
        [ ( "todos", Json.Encode.list (List.map encodeTodo model.todos) )
          -- The current todo is also going to go through encodeTodo
        , ( "todo", encodeTodo model.todo )
          -- The filter gets encoded with a custom function as well
        , ( "filter", encodeFilterState model.filter )
          -- And the next identifier is just an int
        , ( "nextIdentifier", Json.Encode.int model.nextIdentifier )
        ]


-- We'll define how to encode a Todo
encodeTodo : Todo -> Json.Encode.Value
encodeTodo todo =
    -- It's an object with a list of fields
    Json.Encode.object
        -- The title is a string
        [ ( "title", Json.Encode.string todo.title )
          -- completed is a bool
        , ( "completed", Json.Encode.bool todo.completed )
          -- editing is a bool
        , ( "editing", Json.Encode.bool todo.editing )
          -- identifier is an int
        , ( "identifier", Json.Encode.int todo.identifier )
        ]


-- The FilterState encoder takes a FilterState and returns a Json.Encode.Value
encodeFilterState : FilterState -> Json.Encode.Value
encodeFilterState filterState =
    -- We'll use toString to turn our FilterState into a string
    Json.Encode.string (toString filterState)


-- We will need to map the input we get from the inbound port into our
-- model...we'll deal with that later.
mapStorageInput : Decode.Value -> Msg
mapStorageInput modelJson =
    let
        model =
            initialModel

        -- we ultimately need to decode inbound json here but
        -- this will at least get us compiling...
    in
        SetModel model

-- Sending to storage now just needs to encode the model to JSON before
-- sending it out the port.
sendToStorage : Model -> Cmd Msg
sendToStorage model =
    encodeJson model |> storage


-- INPUT PORTS
-- our input port gets Decode.Values into it
port storageInput : (Decode.Value -> msg) -> Sub msg


-- OUTPUT PORTS
-- We have an outbound port of Json.Encode.Values now - notice we aren't dealing
-- with them as raw string representations ever in our Elm code.  They're still
-- typed.
port storage : Json.Encode.Value -> Cmd msg

So with that we should be effectively encoding our model to JSON and sending it over the wire. We can load it up in the browser and see if that appears to be happening:

./build
servedir
# Visit http://localhost:9091, interact a bit, check
# `localStorage.getItem("todos")`

OK, so that works for storing it. Now all that's left is to load that model into the application on load. The problem we have is that we don't presently know how to decode the JSON back into our model. It's all pretty easy - we essentially just implement the inverse of our encoding functions:

-- We'll add a NoOp in case our decoding fails
type Msg
    -- ...
    | NoOp

-- and handle it in the update
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        -- ...
        NoOp ->
            ( model
            , Cmd.none
            )

-- we're going to be decoding JSON and producing a message.  The JSON could be
-- malformed or otherwise inappropriate, so `Decode` functions will return
-- a `Result` type that can be either `Ok Model`  or `Err String`.
-- If we get an error, we send a NoOp, otherwise we send a SetModel message
mapStorageInput : Decode.Value -> Msg
mapStorageInput modelJson =
    case (decodeModel modelJson) of
        Ok model ->
            SetModel model

        Err errorMessage ->
            let
                -- If there's an error decoding it, we can show it in the
                -- console
                _ =
                    Debug.log "Error in mapStorageInput:" errorMessage
            in
                NoOp


-- The actual Decode function takes the modelJson and returns a result.  Here we
-- invoke the `decodeValue` function, where the first argument is a Decoder and
-- the second is the JSON string we're decoding
decodeModel : Decode.Value -> Result String Model
decodeModel modelJson =
    Decode.decodeValue modelDecoder modelJson


-- This brings us to the Decoder.  This returns a Decoder for a Model.  It uses
-- the `map4` function, which just decodes an object with 4 keys in it.  We
-- tell it to produce a Model, and then we describe the mapping for each of the
-- fields.  The first argument is actually a function that should take 4
-- arguments - in this case, it's the `Model` constructor function that the
-- record alias type produces for us, but it could be any 4-arity function.
modelDecoder : Decode.Decoder Model
modelDecoder =
    Decode.map4 Model
        -- For todos, we return a list passed through a new todoDecoder.
        -- We use `Decode.field` to specify each field we're decoding.
        (Decode.field "todos" (Decode.list todoDecoder))
        -- The active todo also goes through the todoDecoder
        (Decode.field "todo" todoDecoder)
        -- The filter gets decoded as a string, then mapped to a FilterState
        (Decode.field "filter" (Decode.string |> Decode.map filterStateDecoder))
        -- Then nextIdentifier just uses the builtin int decoder
        (Decode.field "nextIdentifier" Decode.int)


-- Our todoDecoder is another `map4` for a Todo data structure.
todoDecoder : Decode.Decoder Todo
todoDecoder =
    Decode.map4 Todo
        -- Our title is a string
        (Decode.field "title" Decode.string)
        -- completed is a bool
        (Decode.field "completed" Decode.bool)
        -- editing is a bool
        (Decode.field "editing" Decode.bool)
        -- and identifier is an int
        (Decode.field "identifier" Decode.int)


-- Now all that's left is the filterStateDecoder.  For this we'll just use
-- `Json.Decode.map` to map through a function that turns a String into a
-- FilterState.
filterStateDecoder : String -> FilterState
filterStateDecoder string =
    case string of
      "All" -> All
      "Active" -> Active
      "Completed" -> Completed
      _ ->
          let
              _ = Debug.log "filterStateDecoder" <|
                      "Couldn't decode value " ++ string ++
                      " so defaulting to All."
          in
              All

That's sufficient to decode the data coming from the browser. Let's try it out.

And it works.

Summary

In today's episode, we saw how to use features from the Json.Encode and Json.Decode modules to implement custom JSON storage for our model via localStorage. It shouldn't be difficult for you to generalize from this to how you would deal with custom JSON for some arbitrary API endpoint. I hope you learned something useful. See you soon!

Resources