React Developer's Crash Course into Elm
Jesse Warden
Posted on October 17, 2020
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')
)
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')
})
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}!`
TypeScript would make the above a bit more predictable:
const yo = (name:string):string => `Yo, ${name}`
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 ++ "!"
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 ++ "!"
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"
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" }
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" }
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>
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!" ]
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}
/>
)
}
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 ]
[ ]
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
)
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
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
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
Or you can give them the whole thing:
view model =
app model
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")
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"
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} />
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
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)
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 ]
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) {...}
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
}
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
}
}
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 }
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:
- create a new component(s)
- add new hooks/action creators
- update your
mapDispatchToProps
- add a new reducer
- re-run test suite in hopes you didn’t break anything
To add a new feature to Elm, in order:
- create a new component(s)
- add a new
Msg
type - add that
Msg
type to your component’s click, change, etc - update your
update
function to include newMsg
- 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
…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.
Posted on October 17, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.