DEV Community

Cover image for React Developer's Crash Course into Elm
Jesse Warden
Jesse Warden

Posted on • Originally published at jessewarden.com

React Developer's Crash Course into Elm

Learning Functional Programming has a high learning curve. However, if you have something familiar to base it off of, it helps a lot. If you know React & Redux, this gives you a huge head start. Below, we’ll cover the basics of Elm using React & Redux/Context as a basis to help make it easier to learn.

The below deviates a bit from the Elm guide, both in recommendations and in attitude. Elm development philosophy is about mathematical correctness, learning & comprehending the fundamentals, and keeping things as brutally simple as possible. I’m impatient, don’t mind trying and failing things 3 times to learn, and immersing myself in complexity to learn why people call it complex and don’t like it. I’m also more about getting things done quickly, so some of the build recommendations follow more familiar toolchains React, Angular, and Vue developers are used too which is pretty anti-elm simplicity.

Docs

To learn React, most start at the React documentation. They are _really_ good. They cover the various features, where they’re recommended, and tips/caveats a long the way. For Redux, I hate the new docs despite them working extremely hard on them. I preferred the original egghead.io lesson by Dan Abramov on it.

To learn Elm, most recommend starting at the Official Guide. It starts at the very beginning by building a simple app and walks you through each new feature. It focuses (harps?) on ensuring you know and comprehend the fundamentals before moving on to the next section.

Tools

To build and compile, and install libraries for React apps, you install and use Node.js. It comes with a tool called npm (Node Package Manager) which installs libraries and runs build and other various commands.

For Elm, you install the elm tools. They’re available via npm, but given the versions don’t change often, it’s easier to just use the installers. They come with a few things, but the only ones that really matter day to day are the elm compiler and the elm REPL to test code quickly, like you’d do with the node command.

Developing

The easiest, and most dependable long term way to build & compile React applications is create-react-app. Webpack, Rollup, and bundlers are a path of pain, long term technical debt maintenance burdens… or adventure, joy, and efficient UI’s based on your personality type. Using create-react-app, you’ll write JavaScript/JSX, and the browser will update when you save your file. Without create-react-app, you’d manually start React by:

ReactDOM.render(
  <h1>Hello, world!</h1>,
  document.getElementById('root')
)
Enter fullscreen mode Exit fullscreen mode

Elm recommends you only use the compiler until your application’s complexity grows enough that you require browser integration. Elm Reactor currently sucks, though, so elm-live will give you the lightest weight solution to write code and have the browser automatically refresh like it does in create-react-app. It’s like nodemon or the browser-sync days of old. The story here isn’t as buttoned up as create-react-app. You install elm-live, but still are required to finagle with html and a root JavaScript file. Same workflow though; write some elm code in Main.elm and when you save your file, it refreshes the browser automatically. Starting Elm on your page is similar to React:

Elm.Main.init({
    node: document.getElementById('myapp')
})
Enter fullscreen mode Exit fullscreen mode

Building

When you’re ready to deploy your React app, you run npm run build. This will create an optimized JavaScript build if your React app in the build folder. There are various knobs and settings to tweak how this works through package.json and index.html modifications. Normally, the build folder will contain your root index.html file, the JavaScript code you wrote linked in, the vendor JavaScript libraries you reference, and various CSS files. You can usually just upload this folder to your web server.

The Elm compiler makes a single JavaScript file from an elm file running elm make. This includes the Elm runtime, your Elm code compiled to JavaScript, and optionally optimized (but not uglified). Like React, you initialize it with calling an init function and passing in a root DOM node. Unlike create-react-app, you need to do this step yourself in your HTML file or another JavaScript file if you’re not using the basic Elm app (i.e. browser.sandbox ).

Language

React is based on JavaScript, although you can utilize TypeScript instead. While React used to promote classes, they now promote functions and function components, although they still utilize JavaScript function declarations rather than arrow functions.

// declaration
function yo(name) {
  return `Yo, ${name}!`
}

// arrow
const yo = name => `Yo, ${name}!`
Enter fullscreen mode Exit fullscreen mode

TypeScript would make the above a bit more predictable:

const yo = (name:string):string => `Yo, ${name}`
Enter fullscreen mode Exit fullscreen mode

Elm is a strongly typed functional language that is compiled to JavaScript. The typings are optional as the compiler is pretty smart.

yo name =
  "Yo, " ++ name ++ "!"
Enter fullscreen mode Exit fullscreen mode

Like TypeScript, it can infer a lot; you don’t _have_ to add types on top of all your functions.

yo : String -> String
yo name =
  "Yo, " ++ name ++ "!"
Enter fullscreen mode Exit fullscreen mode

Notice there are no parenthesis, nor semi-colons for Elm functions. The function name comes first, any parameters if any come after, then equal sign. Notice like Arrow Functions, there is no return keyword. All functions are pure with no side effects or I/O, and return _something_, so the return is implied.

Both languages suffer from String abuse. The TypeScript crew are focusing on adding types to template strings since this is an extremely prevalent to do in the UI space: changing strings from back-end systems to show users. Most fans of types think something with a String is untyped which is why they do things like Solving the Boolean Identity Crisis.

Mutation

While much of React encourages immutability, mutation is much easier for many people to understand. This is why tools like Immer are so popular for use in Redux. In JavaScript, if you want to update some data on a Person Object, you just set it.

person = { name : "Jesse" }
person.name = "Albus"
Enter fullscreen mode Exit fullscreen mode

However, with the increase in support for immutable data, you can use Object Destructuring Assignment to not mutate the original object:

personB = { ...person, name : "Albus" }
Enter fullscreen mode Exit fullscreen mode

In Elm, everything is immutable. You cannot mutate data. There is no var or let, and everything is a const that is _actually_ constant (as opposed to JavaScript’s const myArray = [] which you can still myArray.push to). To update data, you destructure a similar way.

{ person | name = "Albus" }
Enter fullscreen mode Exit fullscreen mode

HTML

React uses JSX which is an easier way to write HTML with JavaScript integration that enables React to ensure your HTML and data are always in sync. It’s not HTML, but can be used inside of JavaScript functions, making the smallest React apps just 1 file. All JSX is assumed to have a root node, often a div if you don’t know semantic HTML like me. Just about all HTML tags, attributes, and events are supported. Here is an h1 title:

<h1>Hello, world!</h1>
Enter fullscreen mode Exit fullscreen mode

Elm uses pure functions for everything. This means html elements are also functions. Like React, all HTML tags, attributes, and events are supported. The difference is they are imported from the HTML module at the top of your main Elm file.

h1 [] [ text "Hello, world!" ]
Enter fullscreen mode Exit fullscreen mode

Components

In React, the draw is creating components, specifically function components. React is based on JavaScript. This means you can pass dynamic data to your components, and you have the flexibility on what those Objects are and how they are used in your component. You can optionally enforce types at runtime using prop types.

function Avatar(props) {
  return (
    <img className="Avatar"
      src={props.user.avatarUrl}
      alt={props.user.name}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

In Elm, there are 2 ways of creating components. The first is a function. The other advanced way when your code gets larger is a separate file and exporting the function via Html.map. Elm is strictly typed, and types are enforced by the compiler, so there is no need for runtime enforcement. Thus there is no dynamic props, rather you just define function arguments. You don’t have to put a type definition above your function; Elm is smart enough to “know what you meant”.

avatar user =
  img
    [ class "Avatar"
    , src user.avatarUrl
    , alt user.name ]
    [ ]
Enter fullscreen mode Exit fullscreen mode

View

In React, your View is typically the root component, and some type of Redux wrapper, like a Provider.

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  rootElement
)
Enter fullscreen mode Exit fullscreen mode

In Elm, this is a root method called view that gets the store, or Model as it’s called in Elm as the first parameter. If any child component needs it, you can just pass the model to that function.

view model =
  app model
Enter fullscreen mode Exit fullscreen mode

mapStateToProps vs Model

In React, components that are connected use the mapStateToProps to have an opportunity to snag off the data they want, or just use it as an identity function and get the whole model. Whatever mapStateToProps returns, that is what your component gets passed as props.

const mapStateToProps = state => state.person.name // get just the name
const mapStateToProps = state => state // get the whole model
Enter fullscreen mode Exit fullscreen mode

In Elm, your Model is always passed to the view function. If your view function has any components, you can either give them just a piece of data:

view model =
  app model.person.name
Enter fullscreen mode Exit fullscreen mode

Or you can give them the whole thing:

view model =
  app model
Enter fullscreen mode Exit fullscreen mode

In React, you need to configure the connect function take this mapStateToProps function in when exporting your component.

In Elm, you don’t have to do any of this.

Action Creator vs Messages

In React, if you wish to update some data, you’re going to make that intent known formally in your code by creating an Action Creator. This is just a pattern name for making a function return an Object that your reducers will know what to do with. The convention is, at a minimum, this Object contain a type property as a String.

const addTodo = content =>
  ({
    type: ADD_TODO,
    content
  })
// Redux calls for you
addTodo("clean my desk")
Enter fullscreen mode Exit fullscreen mode

In Elm, you just define a type of message called Msg, and if it has data, the type of data it will get.

type Msg = AddTodo String
-- to use
AddTodo "clean my desk"
Enter fullscreen mode Exit fullscreen mode

In React, Action Creators were originally liked because unit testing them + reducers was really easy, and was a gateway drug to pure functions. However, many view them as overly verbose. This has resulted in many frameworks cropping up to “simplify Redux”, including React’s built-in Context getting popular again.

In Elm, they’re just types, not functions. You don’t need to unit test them. If you misspell or mis-use them, the compiler will tell you.

View Events

In React, if a user interacts with your DOM, you’ll usually wire that up to some event.

const sup = () => console.log("Clicked, yo.")

<button onClick={sup} />
Enter fullscreen mode Exit fullscreen mode

In Elm, same, except you don’t need to define the handler; Elm automatically calls the update function for you. You just use a message you defined. If the message doesn’t match the type, the compiler will yell at you.

type Msg = Pressed | AddedText String

button [] [ onClick Pressed ] -- works
input [] [ onChange Pressed ] -- fails to compile, input passes text but Pressed has no parameter
input [] [ onChange AddedText ] -- works because input changing will pass text, and AddedText has a String
Enter fullscreen mode Exit fullscreen mode

mapDispatchToProps vs Msg

In React Redux, when someone interacts with your DOM and you want that event to update your store, you use the mapDispatchToProps object to say that a particular event fires a particular Action Creator, and in your component wire it up as an event via the props. Redux will then call your reducer functions.

const increment = () => ({ type: 'INCREMENT' }) -- action creator
const mapDispatchToProps = { increment }
const Counter = props =>
( <button onClicked={props.increment} /> )

export default connect(
  null,
  mapDispatchToProps
)(Counter)
Enter fullscreen mode Exit fullscreen mode

In Elm, we already showed you; you just pass your message in the component’s event. Elm will call update automatically. The update is basically Elm’s reducer function.

type Msg = Increment
button [] [ onClick Increment ]
Enter fullscreen mode Exit fullscreen mode

Store vs Model

In Redux, you store abstracts over “the only variable in your application” and provides an abstraction API to protect it. It represents your application’s data model. The data it starts with is what the default value your reducer (or many combined reducers) function has since it’s called with undefined at first. There is a bit of plumbing to wire up this reducer (or combining reducers) which we’ll ignore.

const initialState = { name : 'unknown' }
function(state = initialState, action) {...}
Enter fullscreen mode Exit fullscreen mode

In Elm, you first define your Model’s type, and then pass it to your browser function for the init function or “the thing that’s called when your application starts”. Many tutorials will show an initialModel function, but for smaller models you can just define inline like I did below:

type alias Model = { name : String }

main =
    Browser.sandbox
        { init = { name = "Jesse" }
        , view = view
        , update = update
        } 
Enter fullscreen mode Exit fullscreen mode

There isn’t really a central store that you directly interact with in Redux. While it does have methods you can use before Hooks became commonplace, most of the best practices are just dispatching Action Creators from your components. It’s called store, but really it’s just 1 or many reducer functions. You can’t really see the shape of it until runtime, especially if you have a bunch of reducer functions.

In Elm, it’s basically same, but the Model DOES exist. It’s a single thing, just like your store is a single Object. That type and initial model you can see, both at the beginning of your app, and at runtime.

Reducers vs Update

The whole reason you use Redux is to ensure your data model is immutable and avoid a whole class of bugs that arise using mutable state. You also make your logic easier to unit test. You do that via pure functions, specifically, your reducer functions that make up your store. Every Action Creator that is dispatched will trigger one of your reducer functions. Whatever that function returns, that’s your new Store. It’s assumed you’re using Object destructuring, Immutablejs, or some other Redux library to ensure you’re not using mutation on your state. If you’re using TypeScript, you can turn on “use strict” in the compiler settings to ensure your switch statement doesn’t miss a possible eventuality.

const updatePerson = (state, action) => {
  switch(action.type) {
    case 'UPDATE_NAME':
      return {...state, name: action.newName }
    default:
      return state
  }
}
Enter fullscreen mode Exit fullscreen mode

Elm has no mutation, so no need to worry about that. Whenever a Msg is dispatched from your view, the Elm runtime will call update for you. Like Redux reducers, your job is to return the new Model, if any from that function. Like TypeScript’s switch statement strictness, Elm’s built in pattern matching will ensure you cannot possibly miss a case. Note that there is no need of a default because that can’t happen.

update msg model =
  case msg of
    UpdateName name ->
      { model | name = name }

Enter fullscreen mode Exit fullscreen mode

JavaScript, TypeScript, and Elm however can still result in impossible states. You should really think about using the types fully to ensure impossible states are impossible.

Thunk & Saga vs Elm

In React, as soon as you want to do something asynchronous in Redux, you need to reach for some way to have your Action Creators plumbing be async.

Thunks are the easiest; you offload the async stuff to the code in your Components and it’s just a normal Promise that pops out an Action Creators at various times: before, during, after success, after failure.

Saga’s are more advanced and follow the saga pattern. For situations where the back-end API’s are horrible, and you have to do most of the heavy lifting of orchestrating various services on the front-end, Saga’s offer a few advantages. They allow you to write asynchronous code in a pure function way. Second, they maintain state _inside_ the functions. Like closures, they persist this state when you invoke them again and still “remember” where you were. In side effect heavy code where you don’t always have a lot of idempotent operations, this helps you handle complex happy and unhappy paths to clean up messes and still inform the world of what’s going on (i.e. your Store). They even have a built in message bus for these Sagas to talk to each other with a reasonable amount of determinism. They’re hard to debug, a pain to test, verbose to setup, and a sign you need heavier investment on tackling your back-end for your front-end story.

Elm has no side effects. Calling http.get doesn’t actually make an HTTP XHR/fetch call; it just returns an Object. While you can do async things with Task, those are typically edge cases. So there is no need for libraries like Thunk or Saga. Whether the action is sync like calculating some data, or async like making an HTTP call, Elm handles all that for you using the same API. You’ll still need to create, at minimum, 2 Msg‘s; 1 for initiating the call, and 1 for getting a result back if the HTTP call worked or not.

Both React & Elm still have the same challenge of defining all of your states, and having a UI designer capable of designing for those. Examples include loading screens, success screens, failure screens, no data screens, unauthorized access screens, logged out re-authentication screens, effectively articulating to Product/Business why modals are bad, and API throttling screens.

No one has figured out race conditions.

Error Boundaries

React has error boundaries, a way for components to capture an error from children and show a fallback UI vs the whole application exploding. While often an after thought, some teams build in these Action Creators and reducers from the start for easier debugging in production and a better overall user experience.

Elm does not have runtime exceptions, so there is no need for this. However, if you utilize ports and talk to JavaScript, you should follow the same pattern in Redux, and create a Msg in case the port you’re calling fails “because JavaScript”. While Elm never fails, JavaScript does, and will.

Adding a New Feature

When you want to add a new feature to React Redux, you typically go, in order:

  1. create a new component(s)
  2. add new hooks/action creators
  3. update your mapDispatchToProps
  4. add a new reducer
  5. re-run test suite in hopes you didn’t break anything

To add a new feature to Elm, in order:

  1. create a new component(s)
  2. add a new Msg type
  3. add that Msg type to your component’s click, change, etc
  4. update your update function to include new Msg
  5. compiler will break, ensuring when it compiles, your app works again.

That #5 for Elm is huge. Many have learned about it after working with TypeScript for awhile. At first, battling an app that won’t compile all day feels like an exercise in futility. However, they soon realize that is a good thing, and the compiler is helping them a ton, quickly (#inb4denorebuilttscompilerinrust). When it finally does compile, the amount of confidence they have is huge. Unlike TypeScript, Elm guarantees you won’t get exceptions at runtime. Either way, this is a mindset change of expecting the compiler to complain. This eventually leads you to extremely confident massive refactoring of your application without fear.

Updating Big Models

React and Elm both suffer from being painful to update large data models.

For React, you have a few options. Two examples, just use a lens function like Lodash’ set which supports dynamic, deeply nested paths using 1 line of code… or use Immer.

For Elm, lenses are an anti-pattern because the types ensure you don’t have

undefined is not a function
Enter fullscreen mode Exit fullscreen mode

…which means everything has to be typed which is awesome… and brutal. I just use helper functions.

Testing

For React the only unit tests you need are typically around your reducer functions. If those are solid, then most bugs are caused by your back-end breaking, or changing the JSON contract on you unexpectedly. The minor ones, like misspelling a click handler, are better found through manual & end to end testing vs mountains of jest code. End to end / functional tests using Cypress can tell you quickly if your app works or not. If you’re not doing pixel perfect designs, then snapshot tests add no value and they don’t often surface what actually broke. The other myriad of JavaScript scope/closure issues are found faster through manual testing or Cypress. For useEffect, god speed.

For Elm, while they have unit tests, they don’t add a lot of value unless you’re testing logic since the types solve most issues. Unit tests are poor at validating correctness and race conditions. Typically, strongly typed functional programming languages are ripe for property / fuzz testing; giving your functions a bunch of random inputs with a single test. However, this typically only happens when you’re parsing a lot of user input for forms. Otherwise, the server is typically doing the heavy lifting on those types of things. Instead, I’d focus most of your effort on end to end tests here as well with unhappy paths to surface race conditions.

Conclusions

React and Elm both have components. In both languages, they’re functions. If you use TypeScript in React, then they’re both typed. Your Action Creators are a Msg type in Elm. If you use TypeScript, they’re a simpler discriminated union. In React, you have a Store, which is 1 big Object which represents your applications data model. Through Event Sourcing, it’s updated over time. In Elm, you have a single Model, and it’s updated over time as well. In React, through a ton of plumbing, your Action Creators are dispatched when you click things to run reducer functions. These pure functions return data to update your store. Elm is similar; clicking things in your view dispatches a Msg, and your update function is called with this message, allowing you to return a new model. Both require good UI designers to think about all the possible states, and both get good returns on investment in end to end / functional tests. For Elm, you don’t need to worry about error boundaries, or async libraries.

Oldest comments (8)

Collapse
 
mdxprograms profile image
Josh Waller

Excellent comparison. The elm community needs more of this in order to progress the ranks of languages/frameworks. I seem to come back to elm every other month and learn more each time. It's a great language to learn best practices and functional concepts well (see Haskell 😁)

Collapse
 
jesterxl profile image
Jesse Warden

seem to come back to elm every other month and learn more each time

So much this!

Collapse
 
johnkazer profile image
John Kazer

You can use Xstate state charts/machines with React, which is pretty significant, is there a similar library for Elm?

Collapse
 
jesterxl profile image
Jesse Warden • Edited

I never looked, but here's a few I found via their Elm package search:

package.elm-lang.org/packages/the-...

package.elm-lang.org/packages/ccap...

package.elm-lang.org/packages/folk...

And there is a way to verify no impossible states:
package.elm-lang.org/packages/stoe...

Collapse
 
xavierbrinonecs profile image
Xavier Brinon

Redux toolkit solved a lot of the pain point highlighted. You might want to refresh your practice with some modern redux ;)
I've been put off by the way the core team seems to handle their community: lukeplant.me.uk/blog/posts/why-im-... I can only hope it got better.

Collapse
 
_gdelgado profile image
Gio

There's a lot more to elm than just the comparison to Redux. It goes way beyond UX improvements over redux.

Here's an example of how forms are a "solved problem" in elm:

korban.net/posts/elm/2018-11-27-bu...

In terms of the core team criticisms, Luke's blog post has merit (there is definitely room for improvement), but he's also part of the vocal minority, and most people are actually quite happy of how Elm is progressing (myself included).

You'll also notice on the elm discourse server (discourse.elm-lang.org/) that Evan is quite active and contributes to conversations all of the time.

Collapse
 
wolfadex profile image
Wolfgang Schuster • Edited

For starting and building Elm there are also things like github.com/halfzebra/create-elm-app. This is much more comparable to create-react-app. Using elm reactor or elm-live are more for getting the ball rolling when just getting started and more comparable to setting up React manually. There's also elm-spa which handles breaking up your code and handling running it in dev mode.

Collapse
 
nickwalt profile image
nick walton • Edited

I think one of the most significant aspects of Elm, which isn't conveyed in this first-look, is the depth of semantic domain modelling possible with Elm's type system. How it works so profoundly with functions and data.

Types give birth to living entities in both your data and in the functions that those typed domain entities flow through.

Elm's type system takes domain modelling to a whole new level that is profoundly liberating.