Tom MacWright

tom@macwright.com

Undo and redo in Elm

Previously: Undo & Redo with Immutable.js, undo & redo with ClojureScript.

Recently I decided to learn some more about Elm, a new programming language, by implementing a little drawing application with undo & redo. I’ve written this exact application twice before - the links above are to the versions in JavaScript with Immutable.js and the version in ClojureScript.1

Elm

  • Elm’s syntax is unusual and very similar to Haskell
  • Data is immutable
  • Architecture is a lot like Redux
  • The way Elm deals with errors was robust

This was my third completed project with Elm. Elm is an ambitious language that makes bold claims: it aims for no runtime exceptions, is trying hard to be mainstream and common, and picks up syntax and ideas from languages like Haskell.

Elm looks a lot like Haskell, a purely functional language I was assigned to write for a short time in college. Haskell puzzled me and has fallen far short of “mainstream” - you might use Pandoc, a document converter written in Haskell, but for the most part it’s used by research laboratories or the rare cutting-edge usecase.

Elm’s syntax is much different than JavaScript’s, but programming a web application in Elm has a huge familiarity perk if you’re coming from modern web development: it’s basically Redux.

Elm has immutable datastructures by default: Lists, Arrays, and Records are all immutable. I use Immutable.js, a system for immutable values in JavaScript, all the time. It has entirely convinced me that immutable objects are an essential ingredient for web applications, but the disadvantages of having immutability as a third-party add-on to a language instead of part of the core are clear. As I mentioned when I implemented this application before, ClojureScript also has immutable objects in core, and so does Rust and Swift. I feel some jealousy.

Elm’s ambitious error strategy worked this time. Compile-to-JavaScript languages always have kind of a tough time with errors - even transpiling JavaScript to JavaScript with Babel has some problems. When you get a runtime error in a compiled script, it’s hard to map the source of the error in generated JavaScript to its location in source code and to provide a debugging story that comes anywhere near JavaScript’s amazing debugging tools.

In this experience with Elm, the error reporting lived up to the hype. Whenever I wrote an error or didn’t write enough type annotations for Elm to figure out what’s going on, the error was caught and presented with friendly messages that helped me keep going. At no point with the Elm compiler say that everything was okay, create the bundle, and trigger an exception in JavaScript.

Try it out

Click on the gray box to draw a point. Click a drawn point to delete it. Click undo & redo to move through history.

How it works (abridged)

You can read the original post about JavaScript + Immutable.js for more of the philosophy and strategy behind this approach. to avoid droning on about this, I’ll cut some of the details from this code walkthrough: the full source code is available on GitHub.

-- Much like C, you 'kick things off' in Elm by defining a 'main' function
-- that is automatically invoked.
main =
    Html.program
        { init = init
        , view = view
        , update = update
        , subscriptions = subscriptions
        }

-- MODEL
-- The model a list of lists of positions. Positions are x, y places
-- that have been clicked, and historyIndex tells you whether the user
-- has clicked undo and is viewing an earlier version
type alias Model =
    { historyIndex : Int
    , history : List (List Position)
    }

init : ( Model, Cmd Msg )
init =
    ( Model 0 [ [] ], Cmd.none )

-- these are like Actions in Redux, but way more succinct
-- they represent user intents
type Msg
    = Click Position
    | RemoveDot Position
    | Undo
    | Redo

-- a little helper module, basically the same as "newVersion"
-- in my JavaScript implementation. It creates a new point
-- in history and brings you there.
nextModel : Model -> List Position -> ( Model, Cmd Msg )
nextModel model next =
    ( { model
        | history = (List.take (model.historyIndex + 1) model.history) ++ [ next ]
        , historyIndex = model.historyIndex + 1
      }, Cmd.none)

-- this is basically the same as a reducer in Redux. It takes a
-- message and the previous application state, and gives you a new
-- application state.
update : Msg -> Model -> ( Model, Cmd Msg )
update msg ({ history, historyIndex } as model) =
    case msg of
        Click xy ->
            nextModel model ((getCurrent model) ++ [ xy ])
        RemoveDot xy ->
            nextModel model (List.filter ((/=) xy) (getCurrent model))
        Undo ->
            ( { model
                | historyIndex = (Basics.max 0 (historyIndex - 1))
              }, Cmd.none)
        Redo ->
            ( { model
                | historyIndex = (Basics.min ((List.length history) - 1) (historyIndex + 1))
              }, Cmd.none)

-- a convenience function because getting a specific element of a list
-- isn't super easy; Elm doesn't give you the equivalent of [i]
-- in JavaScript where you can pick an arbitrary index. i could have
-- used an Array instead or a module, but I didn't.
getCurrent : Model -> List Position
getCurrent model =
    withDefault []
        (model.history
            |> List.drop model.historyIndex
            |> List.head
        )

-- a syntax shortcut for making writing HTML & CSS easier.
(=>) = (,)

-- from here on is the HTML generation, which I don't think is that
-- interesting: if you've used React or virtual-dom, you'll have seen
-- something extremely similar in JavaScript: we specify tags, styles,
-- and event listeners, and define what gets printed to the page.
onClickStop : msg -> Attribute msg
onClickStop message =
    onWithOptions "click" { stopPropagation = True, preventDefault = False } (Json.succeed message)

onAdd : Attribute Msg
onAdd =
    on "click" (Json.map Click Mouse.position)

buttonStyle : Bool -> List ( String, String )
buttonStyle disabled =
    [ "border-radius" => "5px"
    , "margin" => "5px"
    , "border-width" => "0"
    , "background"
        => if disabled then
            "#aaa"
           else
            "#2969B0"
    , "color" => "#fff"
    ]

view : Model -> Html Msg
view model =
    div []
        [ (div
            [ onAdd
            , style
                [ "background-color" => "#eeeeee"
                , "width" => "600px"
                , "height" => "200px"
                ]
            ]
            (List.map
                (\dot ->
                    (div
                        [ (onClickStop (RemoveDot dot))
                        , style
                            [ "background-color" => "#EB6B56"
                            , "cursor" => "move"
                            , "width" => "20px"
                            , "height" => "20px"
                            , "margin-left" => "-10px"
                            , "margin-top" => "-10px"
                            , "border-radius" => "10px"
                            , "position" => "absolute"
                            , "left" => px dot.x
                            , "top" => px dot.y
                            ]
                        ]
                        []
                    )
                )
                (getCurrent model)
            )
          )
        , (button
            [ onClick Undo
            , style (buttonStyle (model.historyIndex == 0))
            ]
            [ text "Undo" ]
          )
        , (button [ onClick Redo, style (buttonStyle (model.historyIndex == (List.length model.history) - 1)) ] [ text "Redo" ])
        ]

Takeaways

The initial learning curve for Elm felt steeper than ClojureScript, but once I grasped the syntax and got past the first slew of compiler errors, this project was an absolute delight. Writing the ClojureScript version gave me a taste of what it’d be like to implement in a natively immutable language. Writing Elm gave me that feeling but also the best experience with typechecking I’ve had. Especially with respect to Redux and Immutable, which are unfortunately blind spots for Flow, having instant type checking of values all the way through - from an event to the action to the model updater - was amazing.

Footnotes