DEV Community

Jasper Woudenberg
Jasper Woudenberg

Posted on

Modelling a save button in Elm

One common piece of functionality in web apps is the save button. I recently had to implement one for an Elm app and was reasonably pleased with the result, so I'd like to share the approach in this post.

I believe the approach would work regardless of what we're trying to save, whether it's garden designs or sheet music. In this post I'm going to use a blogging app as an example, but really we could be saving anything.

Our blogging app is going to be super simple: It's just a text area with a save button. We'd like the save button to behave in the following way:

  1. If any unsaved changes exist, a 'Save' button will appear.
  2. Clicking the 'Save' button will send an HTTP request with the current contents of the post.
  3. The 'Save' button should be replaced with a spinner while the request is underway.
  4. If the request fails an alert should be displayed.
  5. The user can continue editing the post while it is being saved.

First lets decide on a type for our blog post. As I mentioned before it doesn't matter a ton what we're saving, so for the purpose of this example let's just take the simplest blog type we can think of: A single string.

type Blog
    = Blog String
Enter fullscreen mode Exit fullscreen mode

Now let's take a look at our first requirement: showing a save button if the there are any unsaved changes.

Detecting changes

We need to distinguish between a post that was saved and a post that contains unsaved changes. One approach would be to define a type with a constructor for either scenario:

type MaybeSaved doc
    = Saved doc
    | HasUnsavedChanges doc doc
Enter fullscreen mode Exit fullscreen mode

The HasUnsavedChanges takes two constructors so we can store both the last saved post and the current version of a post. There's a problem with this type though: it allows an impossible state:

blog : MaybeSaved Blog
blog =
    HasUnsavedChanges
        (Blog "# Bears")
        (Blog "# Bears")
Enter fullscreen mode Exit fullscreen mode

Does blog contain changes or not? The HasUnsavedChanges constructor suggests it does, but the last changed and current version of the post are identical. We can write logic to automatically turn a HasUnsavedChanges doc doc into a Saved doc if it is detected both docs are the same, but it would be nicer if type didn't allow the invalid state in the first place.

Luckily we can make an easy fix to our type to remove this impossible state:

type MaybeSaved doc
    = MaybeSaved doc doc


{-| Get the doc containing the latest changes.
-}
currentValue : MaybeSaved doc -> doc
currentValue (MaybeSaved _ current) =
    current


{-| Check if there are any unsaved changes.
-}
hasUnsavedChanges : MaybeSaved doc -> Bool
hasUnsavedChanges (MaybeSaved old new) =
    old /= new


{-| Update the current value but do not touch the saved value.
-}
change : (doc -> doc) -> MaybeSaved doc -> MaybeSaved doc
change changeFn (MaybeSaved lastSaved current) =
    MaybeSaved lastSaved (changeFn current)


{-| Call this after saving a doc to set the saved value.
-}
setSaved : doc -> MaybeSaved doc -> MaybeSaved doc
setSaved savedDoc (MaybeSaved _ current) =
    MaybeSaved savedDoc current
Enter fullscreen mode Exit fullscreen mode

In this type we always store two versions of the post and use a function compare the two. If there's differences we know there are unsaved changes and we need to show our 'Save' button.

This approach is the basis for the NoRedInk/elm-saved library.

Cool, one requirement down, four to go! Let's take a stab at implementing the save request.

Hitting 'Save'

When we save our post we will send out an HTTP request. We can use a separate property on our model to track this request:

type SaveRequest
      -- There's currently no save request underway
    = NotSaving
      -- A save request has been sent and we're waiting for the response
    | Saving
      -- A save request failed
    | SavingFailed Http.Error
Enter fullscreen mode Exit fullscreen mode

Do you notice how there's no place to store a blog in this type? There doesn't need to be because storing the blog is the responsibility of our MaybeSaved a type. Of our SaveRequest type we only ask that it tracks the status of a request, not its result.

If you worked with the krisajenkins/remotedata library before our SaveRequest type might look familiar to you: It's like RemoteData e a without a Success variant. We don't really need that Success variant to meet our requirements, but your situation may be different.

Putting it all together

With our two types in place, lets construct our Model, Msg, and update function.

module BlogApp exposing (..)

import Blog exposing (Blog)
import Http
import Json.Decode
import MaybeSaved exposing (MaybeSaved)


type alias Model =
    { blog : MaybeSaved Blog
    , saveRequest : SaveRequest
    }


type Msg
    = MakeChange Blog
    | Save
    | ReceivedSaveResponse Blog (Result Http.Error ())


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        -- The user changes their post, for example by typing some words.
        MakeChange newBlog ->
            ( { model | blog = MaybeSaved.change (\_ -> newBlog) model.blog }
            , Cmd.none
            )

        -- The user presses 'save'.
        Save ->
            let
                blogToSave =
                    MaybeSaved.currentValue model.blog
            in
            ( { model | saveRequest = Saving }
            , Http.post "/my-blog"
                (Http.jsonBody (Blog.encode blogToSave))
                (Json.Decode.succeed ())
                |> Http.send (SaveResponse blogToSave)
            )

        -- We successfully saved the blog post!
        ReceivedSaveResponse savedBlog (Ok ()) ->
            ( { model
                | saveRequest = NotSaving
                , blog = MaybeSaved.setSaved savedBlog model.blog
              }
            , Cmd.none
            )

        -- We tried to save the blog post, but it failed :-(.
        ReceivedSaveResponse _ (Err e) ->
            ( { model | saveRequest = SavingFailed e }
            , Cmd.none
            )
Enter fullscreen mode Exit fullscreen mode

Are we done?

To wrap up, let's make another pass through our requirements to see how we did.

If any unsaved changes exist, a 'Save' button will appear.

We can use MaybeSaved.hasUnsavedChanges in our view function to check for changes, and render a button if one exists.

Clicking the 'Save' button will send an HTTP request with the current contents of the post.

Check! The button can send a Save message. We wrote logic in our update function to send the request.

The 'Save' button should be replaced with a spinner while the request is underway.

The view logic can case on the saveRequest property. If it has the value Saving a request is underway.

If the request fails an alert should be displayed.

The view logic can case on the saveRequest property and check for an error. Maybe you'll want to add some logic to make the error disappear after a certain time, or when dismissed by the user.

The user can continue editing the post while it is being saved.

Yep! The save functionality does not get in the way of continued editing.
If the user makes changes while a save request is pending, those will be marked as 'unsaved changes' after the save succeeds.

Nice, full marks! That's all!

Let me know what you think of this approach, and other solutions to the problem you know. I'd love to hear about them!

Top comments (9)

Collapse
 
yadalis profile image
Suresh Yadali • Edited

Great article. I am new to Elm, could you please provide the "View" code for this sample ? or maybe full working github code ?

Collapse
 
jwoudenberg profile image
Jasper Woudenberg

Hi Suresh,

Thank you! Unfortunately I never wrote any view code for this sample, though I can see how that would might make a handy reference. Is there any aspect in particular you are curious about?

I did write a previous blog post about a subject more related to views which might be of interest: dev.to/jwoudenberg/a-type-for-view...

Cheers!

Jasper

Collapse
 
yadalis profile image
Suresh Yadali

Thanks for the reply.

I am trying to use this code to build the view function and I am stuck at hooking the "MakeChange newBlog ->" Msg to onInput event, it complaints that onInput only accepts String, so wondering how to handle this? I am thinking of changing MakeChange Blog to MakeChange String, not sure if that is the way.

Thread Thread
 
jwoudenberg profile image
Jasper Woudenberg • Edited

Ah, I have one idea what might be going on. onInput takes as an argument a function String -> msg right? To turn a string into our Msg type we need to wrap it in two layers: First into the Blog type we defined, then in the MakeChange constructor. So if you pass onInput a function like this, I think it should work out:

onInput (\newContents -> MakeChange (Blog newContents))

-- Or, alternatively, if you're fan of this sort of thing
onInput (MakeChange << Blog)

Also happy to take a look at your code if you have it in ellie or something!

I am thinking of changing MakeChange Blog to MakeChange String, not sure if that is the way.

That would also work! You'd probably want to change the Blog type from being a 'custom type' to a 'type alias', like this:

type alias Blog = String

Now anywhere where you need a Blog you can simply pass a String and vice versa!

Thread Thread
 
yadalis profile image
Suresh Yadali

Awesome, I got it working with this code. Here is the ellie link ellie-app.com/3jDVmS9GVq3a1 thanks for the help. Also, just wondering about "Blog.encode", is encode is available by default in any type?

Thread Thread
 
jwoudenberg profile image
Jasper Woudenberg • Edited

Hey, this is great! Thanks for sharing!

With regards to Blog.encode, the Blog in there refers to the module called Blog, not the type Blog (confusing, I know). So Blog.encode refers to the encode function in the Blog module. In Elm types never have 'methods', like you would see in an object oriented language, so when you see CapitalizedThing.anything you can be sure CapitalizedThing is referring to a module.

When a type has a bunch of helper functions doing stuff with it, these are often put together in a module named after the type. In my post I hypothesize such a module Blog exists, through the line import Blog exposing (Blog) in the example, but don't show the implementation of that module. The encode function defined in there might look something like this:

import Json.Encode exposing (Value)

encode : Blog -> Value
encode (Blog blog) = Json.Encode.string blog

Json.Encode comes form the elm/json package.

Thread Thread
 
yadalis profile image
Suresh Yadali

Wonderful. Understood your explanation and totally make sense now :).
Thank you very much for taking the time to help me out. I am getting my head around it quite nicely on Elm language. Elm rocks!!!

Also, I have a different version of CHANGE function since I am not sure what I would gain by passing a function and a blog, so tried the below function and it works, but not sure if I am missing any Elm standards here, but just a different approach.

buildUpdatedBlog : doc -> MaybeSaved doc -> MaybeSaved doc
buildUpdatedBlog newBlog (MaybeSaved lastSaved current) =
MaybeSaved lastSaved newBlog

here is the latest ellie link, ellie-app.com/3jHs2wFCmhXa1, any suggestions would be great.

Hope to see you at Elm Conf 2018.

Thread Thread
 
jwoudenberg profile image
Jasper Woudenberg

Looking great!

Also, I have a different version of CHANGE function since I am not sure what I would gain by passing a function and a blog.

Yeah, that's a good simplification!

The version of change in the post only comes in handy when you want to make a change that somehow depends on the previous value. Because the onInput handler for our text area always gives us the entire text of the blog we're not really interested in the old value of the blog. But if your blog app will for example have separate inputs for the blog title and the blog body the version of change in my post will come in handy.

Hope to see you at Elm Conf 2018.

I have to miss it unfortunately, I won't be in the US. Hope you enjoy it!

Collapse
 
josephthecoder profile image
Joseph Stevens

Very cool! I can't remember how many times I've had issues with this before in other languages, excited to see Elms approach on this :):)