Quantcast
Channel: NoRedInk
Viewing all articles
Browse latest Browse all 193

Walkthrough: Introducing Elm to a JS Web App

$
0
0

When we introduced Elm to our production front-end at NoRedInk, we knew what to expect: the productivity benefits of an incredibly well-built language, and the intrinsic risk of integrating a cutting-edge technology for the first time. Knowing these risks, we came up with a minimally invasive way to introduce Elm to our stack, which let us get a sense of its benefits without having to make a serious commitment.

Since several people have asked me about this, I’d like to share how we did it!

This walkthrough assumes only a beginner level of Elm familiarity. I won’t be spending much time on syntax (though I will link to the syntax guide as a reference) or functional programming staples like map and filter, so I can go into more depth on introducing Elm to an existing JS codebase.

Let’s dive in!

Elm for…business logic?

Elm is known for its UI-crafting capabilities (both as an improvement over React as well as for game programming), but building out an entire UI in a new language was more commitment than we wanted to tackle as a starting point. We didn’t want a drink from the fire hose; we just wanted a little sip.

Fortunately, it turns out Elm is also a phenomenal language for writing business logic.

Besides making it easy to write clear, expressive code, Elm also makes it easy to write code that’s rock-solid reliable. How reliable? Here are some numbers:

  • Students answer over 2.5 million questions per day on NoRedInk. Needless to say, edge cases happen.
  • 3% of our code base (so far!) is Elm code. Not a ton, but a substantial chunk.
  • We have yet to see our production Elm code cause a single runtime exception.

This is not because we sheltered it; our production Elm code implements some of the most complicated parts of our most mission-critical features. Despite that level of exposure, every runtime browser exception we’ve ever encountered—in the history of the company—has been fixed by changing CoffeeScript or Ruby code. Never by changing Elm code.

This is also not because we have some particularly unusual code base; rather, it’s because Elm’s compiler is astonishingly helpful.

Let’s get a taste for that by introducing Elm to an existing JavaScript code base!

Adding a hint of Elm to a JS TodoMVC app

We’ve been using React and Flux at NoRedInk since they came out in 2014.

Flux stores are a convenient place to introduce Elm to your code base, because they’re all about business logic and are not concerned with UI rendering. Porting a single Flux store to Elm proved a great starting point for us; it was enough to start seeing real benefits, but not such a big commitment that we couldn’t have changed our minds and turned back if we’d wanted to.

Let’s walk through doing the same thing: porting a store from the Flux TodoMVC example to Elm.

First, grab Facebook’s Flux repo. (I’m linking to a fork of their repo, in case theirs has changed since I wrote this.) and cd into its directory. At this point you should be able to follow the instructions for building the TodoMVC example.

git clone git@github.com:rtfeldman/flux.git
cd examples/flux-todomvc
npm install
npm start

Once you have npm start running, you should be able to open the index.html file in your current directory and see a working app.

If you’re not familiar with the Flux architecture (it’s conceptually similar to The Elm Architecture), now might be a good time to read about it. You don’t need a ton of experience with it to follow along, but having a general idea what’s going on should help.

Now you’ll want to grab yourself a copy of Elm. Either use npm install -g elm or visit elm-lang.org/install to get an installer. When you’re finished, run elm make --help in your terminal and make sure it says Elm Platform 0.15.1 at the top!

Integrating Elm at a basic level requires three things:

  1. A .elm file containing our Elm code.
  2. An elm-package.json file specifying our dependencies.
  3. A JavaScript command to invoke our Elm code.

Let’s create examples/flux-todomvc/TodoStore.elm with the following code:

module TodoStore where

port dispatchCreate : Signal String

Now let’s build it by running this command in the examples/flux-todomvc/ directory:

$ elm make TodoStore.elm --output elm-todomvc.js

The first time you build, you should see this prompt:

Some new packages are needed. Here is the upgrade plan.

 Install:

 elm-lang/core 2.1.0

Do you approve of this plan? (y/n)

After you answer y, Elm will install your dependencies. When it’s done, you should see this message:

Successfully generated elm-todomvc.js.

So far, so good!

Your directory should now contain an elm-package.json file and the compiled elm-todomvc.js file generated by elm make, as well as an an elm-stuff/ folder which holds downloaded packages and the like. I always add elm-stuff/ to .gitignore.

Next we need to add our compiled Elm code to the page.

If we’d incorporated gulp-elm into our project’s Gulpfile (see also grunt-elm, elm-webpack-loader, and this Gist for Sprockets integration), then our compiled Elm code would already be included as part of bundle.js. However, we’re avoiding build tool plugins for this example, so we need to tell our index.html file about elm-todomvc.js by adding a new <script> tag above the one that imports "js/bundle.js" like so:

...

  <script src="elm-todomvc.js"></script>
  <script src="js/bundle.js"></script>
</body>

...

All done! Now our Elm code is compiled and available on the page; we’re ready to start porting.

Porting Over The Wiring

(Note: If you’re more interested in business logic than wiring, feel free to skip this part and grab the completed wiring from this Gist so you can move on to the next section. If you’re curious about the details of where this code came from, you can always return here later!)

We’ve written some Elm code, compiled it, and added the compiled code to our page. Now let’s start porting TodoStore.js to Elm.

I’ve found it’s easiest to start by porting over the wiring. That gives us a foundation for a basic runnable app, which makes the rest of the process go more smoothly.

We’ll start with TodoConstants.js, which represents the different types of Actions our program can use. We don’t need a whole separate file for this, nor do we need the keyMirror library; Elm’s built-in union types are perfect for representing Actions, as they let us incorporate payload information along with the Action’s name.

That means we go from this code in TodoConstants.js…

module.exports = keyMirror({
  TODO_CREATE: null,
  TODO_COMPLETE: null,
  TODO_DESTROY: null,
  TODO_DESTROY_COMPLETED: null,
  TODO_TOGGLE_COMPLETE_ALL: null,
  TODO_UNDO_COMPLETE: null,
  TODO_UPDATE_TEXT: null
});

…to this code in TodoStore.elm, right after the  module TodoStore where part:

type Action
    = Create String
    | Complete TodoId
    | Destroy TodoId
    | DestroyCompleted
    | ToggleCompleteAll
    | UndoComplete TodoId
    | UpdateText TodoId String

Note that each of these specifies not only the name of the Action type, but also what kind of payload the action requires. Create requires a String for the text of the item to be created. Complete takes the ID of the TodoItem to mark as completed. We can have as many or as few payload parameters as we like; DestroyCompleted has no payload, and UpdateText takes two values.

Whereas String is a built-in type, TodoId is a custom one I just made up. Let’s define it right below our type Action declaration:

type alias TodoId = Int

This just says “TodoId is equivalent to Int, so anywhere you see a TodoId, treat it as an Int.” This isn’t necessary, but it lets us write more self-documenting code; it’s certainly easier to tell what kind of payload Destroy TodoId accepts than if we’d written Destroy Int instead! This is also handy because if it means later we want TodoId to be a String, we can change this one line instead of having to change it in several places in our Action declaration.

Next we need to define our API for Elm-to-JS communications. We’ll want to define an inbound port for each of the store’s methods, like so:

port dispatchCreate : Signal String
port dispatchComplete : Signal TodoId
port dispatchDestroy : Signal TodoId
port dispatchDestroyCompleted : Signal ()
port dispatchToggleCompleteAll : Signal ()
port dispatchUndoComplete : Signal TodoId
port dispatchUpdateText : Signal (TodoId, String)

Note that these line up very closely with the Action definition we just gave. That’s not a coincidence! We did that because we want to translate these raw inbound JavaScript values into Action instances, so we can work with them more easily later. Let’s do that:

actions =
    Signal.mergeMany
        [
            Signal.map Create dispatchCreate,
            Signal.map Complete dispatchComplete,
            Signal.map Destroy dispatchDestroy,
            Signal.map (always DestroyCompleted) dispatchDestroyCompleted,
            Signal.map (always ToggleCompleteAll) dispatchToggleCompleteAll,
            Signal.map UndoComplete dispatchUndoComplete,
            Signal.map (\(id, text) -> UpdateText id text) dispatchUpdateText
        ]

Here we’re using Signal.mergeMany and Signal.map to go from seven inbound port Signals, each containing raw JavaScript values, to one Signal of Actions, which will be much easier to work with!

Translating JS requests to Elm Actions

Most of these conversions look the same; for example, Signal.map Create dispatchCreate takes dispatchCreate (an inbound port which is represented as a Signal String) and passes its String payload to Create, which gives us back an Action with the appropriate name and payload.

Since DestroyCompleted doesn’t have a payload and therefore doesn’t take an argument, we use always to throw away the argument Signal.map will try to pass it from dispatchDestroyCompleted. Similarly, since UpdateText takes two arguments but dispatchUpdateText produces a single tuple, we use an anonymous function to destructure that tuple and pass its components as separate arguments to UpdateText.

We’re almost there! Now all we need is an initial model and a way to send updates to that model out to TodoStore.js. To do that, let’s add this code right above our current port declarations:

update action model =
    model

initialModel =
    {
        todos = [],
        uid = 1
    }

modelChanges =
    Signal.foldp update initialModel actions

port todoListChanges : Signal (List TodoItem)
port todoListChanges = Signal.dropRepeats (Signal.map .todos modelChanges)

This connects everything together.

Now we have an initial model with an empty list of TodoItem instances (we’ll come back to uid in the next section), we have a modelChanges Signal that represents changes to that model over time, and we have an update function which translates one model to another using an Action it received from all those JS ports we merged together and mapped over.

Finally, we add a single outbound port, todosChanged, which sends a revised todos list out to TodoStore.js every time that part of the model changes.

Onward to the business logic!

Porting the “Create Item” Feature

Now that we have a basic TodoStore.elm with a bunch of wiring ported over, we can start porting logic from TodoStore.js over to TodoStore.elm.

Our goals will be:

  1. Replace all the code in each branch of the AppDispatcher.register handler’s switch statement with one-liner calls to invoke the appropriate Elm code.
  2. Confine our changes entirely to TodoStore.js, which means: a. Don’t alter any other files besides TodoStore.js (except for adding the new TodoStore.elm) b. Don’t change the public-facing API of TodoStore.js, including event-emitting logic. c. End up with the rest of the app continuing to work exactly as it used to

This was what we did when we first introduced Elm at NoRedInk. Having the peace of mind that only one JS file was changing, and that its public-facing API would remain exactly the same, meant that introducing Elm was a very low-risk undertaking.

To do this, let’s start by invoking our Elm code. Add the following to TodoStore.js, right above the call to AppDispatcher.register:

var defaultValues = {
  dispatchCreate: "",
  dispatchComplete: 0,
  dispatchDestroy: 0,
  dispatchDestroyCompleted: [],
  dispatchToggleCompleteAll: [],
  dispatchUndoComplete: 0,
  dispatchUpdateText: [0, ""]
};

var ports = Elm.worker(Elm.TodoStore, defaultValues).ports;

ports.todoListChanges.subscribe(function(updatedTodoList) {
  // Convert from the flat list we're using in Elm
  // to the keyed-by-id object the JS code expects.
  _todos = {};

  updatedTodoList.forEach(function(item) {
    _todos[item.id] = item;
  });

  TodoStore.emitChange();
});

ports.dispatchCreate.send("Gotta port more JS to Elm!");

// Register callback to handle all updates
AppDispatcher.register(function(action) {

If you bring up the app, the page should render with no JavaScript errors. We haven’t wired up any interaction yet, but if the page renders with no errors, we know we’ve connected the ports successfully. If we had (for example) forgotten to specify a default value for one of our ports, we would see an empty Todo list and an error message in the console.

Now let’s finish up Create.

Besides making our code more self-documenting, type alias can also save us from writing boilerplate; the type alias we defined earlier for TodoItem gets us a “constructor function” for free, which is roughly equivalent to the following JavaScript function:

function TodoItem(id, text, complete) {
    return {
        id: id,
        text: text,
        complete: complete
    };
}

So now instead of writing this:

{ id = 42, text = "pick up groceries", complete = False }

…we can write this:

TodoItem 42 "pick up groceries" False

Notice that this automatically-generated function has a lot in common with the handwritten create function in TodoStore.js; so much, in fact, that porting over create wouldn’t really get us much. (It’s not like we needed to keep that funky way of simulating a server-generated id!)

We do, however, need to port over the dispatcher logic. We’ll do that inside our update function from The Elm Architecture, which we will now expand from just returning model to doing the following:

update action model =
    case action of
        Create untrimmedText ->
            let
                text =
                    String.trim untrimmedText
            in
                if String.isEmpty text then
                    model
                else
                    { model |
                        todos <- model.todos ++ [TodoItem model.uid text False],
                        uid <- model.uid + 1
                    }
        _ ->
            model

Since this code uses String.trim, we’ll need to import String below the first line of the file, like so:

module TodoStore where

import String

There are a few syntactic differences between the Elm version of this logic and the JS version - let instead of var, case instead of switch, and record update syntax for the whole { model | todos <- … } part - but the Elm code is accomplishing the same thing as the JS code did.

Note that because we’re using the Action union type we defined earlier, case can not only match on Create, but also extract its payload (recall that we defined Create to have a String payload), and name that payload untrimmedText. Handy!

The main logical change from the Flux version is that (like all Elm functions) update is a stateless function; it neither mutates any shared state nor has any side effects. All it does is accept a model as an argument and return a new one. So rather than mutating the _todos collection when we create a new item, we instead return a new model that has the new item appended. (Note that Elm uses persistent data structures to store its immutable data, which makes operations like this fast and efficient.)

As for uid, it’s our replacement for the original simulate-an-id code. In a production application we’d get our new id values from a server, but since we don’t have a server here, we need to generate them ourselves. It doesn’t matter what our id values are, so long as they’re unique, so we just store a counter called uid in our model and increment it every time we use its current value to represent a unique id.

Finally, the _ -> model at the end provides a default handler for our case expression. Currently Create untrimmedText -> is the only other case we’ve defined, and this essentially says “if we didn’t match any of the previous cases, then just return the original model.”

Now that we’ve specified what a Create action does, we need to replace the current JS implementation with our Elm one. To make that happen, we replace the handler code for TODO_CREATE with a call to send the relevant data to an Elm port instead:

case TodoConstants.TODO_CREATE:
  ports.dispatchCreate.send(action.text);
  break;

We have now completely delegated the logic for creating todo items to Elm. Try it out! You will be able to add todo items to the list.

Just for fun, let’s verify that we’re using TodoStore.elm instead of TodoStore.js for our logic. Comment out this line in TodoStore.elm, and replace it with the one below:

-- todos <- model.todos ++ [TodoItem model.uid text False],
todos <- model.todos ++ [TodoItem model.uid "Hodor" False],

Run elm-make TodoStore.elm --output elm-todomvc.js again to compile the Elm code, and reload index.html. Now whenever you try to enter a todo item, it will disregard your input and add “Hodor” instead.

Note that at this point, the rest of the app continues to function as normal. We’ve replaced one piece of logic with Elm code, but we haven’t broken anything to do so. This is by design! We set out to introduce Elm incrementally to this code base, and that’s exactly what we’re doing.

Let’s revert the “Hodor” line of code to the way it used to be, and while we’re at it, comment out this line that we just used to confirm things were wired up properly:

// ports.dispatchCreate.send("Gotta port more JS to Elm!");

With this foundation in place, we’re ready to port the rest of the store logic to Elm!

Porting the Remaining Features

With this foundation in place, we’re ready to port the rest of the store logic to Elm!

Let’s do Destroy next. Here’s what we need to add to our case expression, right above the _ -> model clause at the end:

Destroy id ->
    let
        todosWithoutId =
            List.filter (\item -> item.id /= id) model.todos
    in
        { model | todos <- todosWithoutId }

And then we replace the implementation in TodoStore.js with a call to a port:

case TodoConstants.TODO_DESTROY:
  ports.dispatchDestroy.send(action.id);
  break;

Pretty simple, right? If you compile your Elm code, the delete button should work as before.

DestroyCompleted can be a one-liner. Here it is on the Elm side:

DestroyCompleted ->
    { model | todos <- List.filter (\item -> not item.complete) model.todos }

…and then on the JS side:

case TodoConstants.TODO_DESTROY_COMPLETED:
  ports.dispatchDestroyCompleted.send([]);
  break;

Note that ports must always accept a value, but since this port has no meaningful information to send, we declare it as a Signal () on the Elm side and then always send [] on the JS side.

Since Complete and UndoComplete share common logic, we benefit from a helper function. Add this to TodoStore.elm, above the case expression:

withComplete complete id item =
    if item.id == id then
        { item | complete <- complete }
    else
        item

This lets us add case clauses for Complete and UndoComplete as quick one-liners:

Complete id ->
    { model | todos <- List.map (withComplete True id) model.todos }

UndoComplete id ->
    { model | todos <- List.map (withComplete False id) model.todos }

See if you can wire these up in TodoStore.js yourself. (Spoiler: it’s the same procedure we used for Destroy!) Afterwards, run elm-make TodoStore.elm --output elm-todomvc.js again and verify that checking and unchecking todo items still works. You should also see a “Clear completed” button appear after you’ve checked one or more items, and clicking it should delete the checked ones.

At this point, all we have left to port are UpdateText and ToggleCompleteAll. Neither of them is doing anything we haven’t seen before, so I’ll just link to their implementations in the final TodoStore.elm, along with the requisite dispatcher code:

case TodoConstants.TODO_TOGGLE_COMPLETE_ALL:
  ports.dispatchToggleCompleteAll.send([]);
  break;

case TodoConstants.TODO_UPDATE_TEXT:
  ports.dispatchUpdateText.send([action.id, action.text]);
  break;

All done!

Takeaways

Here’s the final diff of what we did.

We kept our changes entirely localized to TodoStore.js, and we did not touch a single other .js file. Even better, the app as a whole continued functioning as we were porting over features, so this was a really low-risk change!

Compared to the original TodoStore.js, the final TodoStore.elm has several benefits:

  • Our wiring got more useful:
  • We removed the need for bespoke event emitting. Now we only call emitChange in one place, and Elm’s Signal.dropRepeats ensures we emit change events exactly when we ought to: every time the todo list actually changes, and at no other times.
  • Everything is immutable, so we no longer have to track down accidental-mutation bugs.
  • There are no side effects, so it’s extremely clear which functions can affect which others.
  • The code is now rock-solid. The compiler will catch so many mistakes for us, it’s now extremely unlikely that we’ll accidentally let a runtime exception slip into production.

This is the same procedure we used to port our first Flux store to Elm at NoRedInk. That code has been running in production for months now, and has yet to cause a runtime exception!

Getting that first low-risk change into production gave us the confidence (and motivation!) to expand our use of Elm on future projects. The next major UI feature we shipped was Elm through and through, with elm-html and The Elm Architecture completely replacing React and Flux. It’s been great! That code is so easy to refactor, it gets more casual polish than any other part of our front-end code base; stay tuned for a future post about it.

By the way, if working on Elm code in production appeals to you…we’re hiring!

Discuss this post on Hacker News


Richard Feldman
@rtfeldman
Engineer at NoRedInk

Thanks to Aaron VonderHaar, Charles Comstock, Mike Clark, Jessica Kerr, and
Texas Toland for reading drafts of this.


Viewing all articles
Browse latest Browse all 193

Trending Articles