Skip to content

🦄 Learn how to build web apps using the Elm Architecture in "vanilla" JavaScript (step-by-step TDD tutorial)!

License

Notifications You must be signed in to change notification settings

dwyl/learn-elm-architecture-in-javascript

Repository files navigation

Learn Elm Architecture in Plain JavaScript

Learn how to build web applications using the Elm ("Model Update View") Architecture in "plain" JavaScript.

Build Status test coverage dependencies Status devDependencies Status contributions welcome HitCount

We think Elm is the future of Front End Web Development
for all the reasons described in: github.com/dwyl/learn-elm#why
However we acknowledge that Elm is "not everyone's taste"!

What many Front-End Developers are learning/using is React.js.
Most new React.js apps are built using Redux which "takes cues from"
(takes all it's best ideas/features from) Elm:
redux-borrows-elm

Therefore, by learning the Elm Architecture, you will intrinsically understand Redux
which will help you learn/develop React apps.

This step-by-step tutorial is a gentle introduction to the Elm Architecture,
for people who write JavaScript and want a functional, elegant and fast
way of organizing their JavaScript code without having the learning curve
of a completely new (functional) programming language!

Why?

simple-life

Organizing code in a Web (or Mobile) Application is really easy to over-complicate,
especially when you are just starting out and there are dozens of competing ideas all claiming to be the "right way"...

When we encounter this type of "what is the right way?" question,
we always follow Occam's Razor and ask: what is the simplest way?
In the case of web application organization, the answer is: the "Elm Architecture".

When compared to other ways of organizing your code, "Model Update View" (MUV) has the following benefits:

  • Easier to understand what is going on in more advanced apps because there is no complex logic, only one basic principal and the "flow" is always the same.
  • Uni-directional data flow means the "state" of the app is always predictable; given a specific starting "state" and sequence of update actions, the output/end state will always be the same. This makes testing/testability very easy!
  • There's no "middle man" to complicate things (the way there is in other application architectures such as Model-view-Presenter or "Model-View-ViewModel" (MVVM) which is "overkill" for most apps).

Note: don't panic if any of the terms above are strange or even confusing to you right now. Our quest is to put all the concepts into context. And if you get "stuck" at any point, we are here to help! Simply open a question on GitHub: github.com/dwyl/learn-elm-architecture-in-javascript/issues

Who? (Should I Read/Learn This...?)

everybodys-gotta-learn-sometime

Anyone who knows a little bit of JavaScript and wants to learn how to organize/structure
their code/app in a sane, predictable and testable way.

Prerequisites?

all-you-need-is-less

No other knowledge is assumed or implied. If you have any questions, please ask:
github.com/dwyl/learn-elm-architecture-in-javascript/issues

What?

image

A Complete Beginner's Guide to "MUV"

Start with a few definitions:

  • Model - or "data model" is the place where all data is stored; often referred to as the application's state.
  • Update - how the app handles actions performed by people and updates the state, usually organised as a switch with various case statements corresponding to the different "actions" the user can take in your App.
  • View - what people using the app can see; a way to view the Model (in the case of the first tutorial below, the counter) as HTML rendered in a web browser.

elm-muv-architecture-diagram


If you're not into flow diagrams, here is a much more "user friendly" explanation of The Elm Architecture ("TEA"):

In the "View Theatre" diagram, the:

  • model is the ensamble of characters (or "puppets")
  • update is the function that transforms ("changes") the model (the "puppeteer").
  • view what the audience sees through "view port" (stage).

If this diagram is not clear (yet), again, don't panic, it will all be clarified when you start seeing it in action (below)!

How?

1. Clone this Repository

git clone https://github.com/dwyl/learn-elm-architecture-in-javascript.git && cd learn-elm-architecture-in-javascript

2. Open Example .html file in Web Browser

Tip: if you have node.js installed, simply run npm install! That will install live-server which will automatically refresh your browser window when you make changes to the code! (makes developing faster!)

When you open examples/counter-basic/index.html you should see:

elm-architecture-counter

Try clicking on the buttons to increase/decrease the counter.

3. Edit Some Code

In your Text Editor of choice, edit the initial value of the model (e.g: change the initial value from 0 to 9). Don't forget to save the file!

elm-architecture-code-update

4. Refresh the Web Browser

When you refresh the your Web Browser you will see that the "initial state" is now 9 (or whichever number you changed the initial value to):

update-initial-model-to-9

You have just seen how easy it is to set the "initial state" in an App built with the Elm Architecture.

5. Read Through & Break Down the Code in the Example

You may have taken the time to read the code in Step 3 (above) ...
If you did, well done for challenging yourself and getting a "head start" on reading/learning!
Reading (other people's) code is the fastest way to learn programming skills and the only way to learn useful "patterns".
If you didn't read through the code in Step 3, that's ok! Let's walk through the functions now!

As always, our hope is that the functions are clearly named and well-commented,
please inform us if anything is unclear please ask any questions as issues:
github.com/dwyl/learn-elm-architecture-in-javascript/issues

5.1 mount Function Walkthrough

The mount function "initializes" the app and tells the view how to process a signal sent by the user/client.

function mount(model, update, view, root_element_id) {
  var root = document.getElementById(root_element_id); // root DOM element
  function signal(action) {          // signal function takes action
    return function callback() {     // and returns callback
      model = update(model, action); // update model according to action
      view(signal, model, root);     // subsequent re-rendering
    };
  };
  view(signal, model, root);         // render initial model (once)
}

The mount function receives the following four arguments:

  • model: "initial state" of your application (in this case the counter which starts at 0)
  • update: the function that gets executed when ever a "signal" is received from the client (person using the app).
  • view: the function that renders the DOM (see: section 5.3 below)
  • root_element_id is the id of the "root DOM element"; this is the DOM element
    where your app will be "mounted to". In other words your app will be contained within this root element.
    (so make sure it is empty before mounting)

The first line in mount is to get a reference to the root DOM element;
we do this once in the entire application to minimize DOM lookups.

mount > signal > callback ?

The interesting part of the mount function is signal (inner function)!
At first this function may seem a little strange ...
Why are we defining a function that returns another function?
If this your first time seeing this "pattern", welcome to the wonderful world of "closures"!

What is a "Closure" and Why/How is it Useful?

A closure is an inner function that has access to the outer (enclosing) function's variables—scope chain. The closure has three scope chains: it has access to its own scope (variables defined between its curly brackets), it has access to the outer function's variables, and it has access to the global variables.

In the case of the callback function inside signal, the signal is "passed" to the various bits of UI and the callback gets executed when the UI gets interacted with. If we did not have the callback the signal would be executed immediately when the button is defined.
Whereas we only want the signal (callback) to be triggered when the button is clicked.
Try removing the callback to see the effect:

range-error-stack-exceeded

The signal is triggered when button is created, which re-renders the view creating the button again. And, since the view renders two buttons each time it creates a "chain reaction" which almost instantly exceeds the "call stack" (i.e. exhausts the allocated memory) of the browser!

Putting the callback in a closure means we can pass a reference to the signal (parent/outer) function to the view function.

Further Reading on Closures

5.1.1 mount > render initial view

The last line in the mount function is to render the view function for the first time, passing in the signal function, initial model ("state") and root element. This is the initial rendering of the UI.

5.2 Define the "Actions" in your App

The next step in the Elm Architecture is to define the Actions that can be taken in your application. In the case of our counter example we only have two (for now):

// Define the Component's Actions:
var Inc = 'inc';                     // increment the counter
var Dec = 'dec';                     // decrement the counter

These Actions are used in the switch (i.e. decide what to do) inside the update function.

Actions are always defined as a String.
The Action variable gets passed around inside the JS code
but the String representation is what appears in the DOM
and then gets passed in signal from the UI back to the update function.

One of the biggest (side) benefits of defining actions like this is that it's really quick to see what the application does by reading the list of actions!

5.3 Define the update Function

The update function is a simple switch statement that evaluates the action and "dispatches" to the required function for processing.

In the case of our simple counter we aren't defining functions for each case:

function update(model, action) {     // Update function takes the current model
  switch(action) {                   // and an action (String) runs a switch
    case Inc: return model + 1;      // add 1 to the model
    case Dec: return model - 1;      // subtract 1 from model
    default: return model;           // if no action, return current model.
  }                                  // (default action always returns current)
}

However if the "handlers" for each action were "bigger", we would split them out into their own functions e.g:

// define the handler function used when action is "inc"
function increment(model) {
  return model + 1
}
// define handler for "dec" action
function decrement(model) {
  return model - 1
}
function update(model, action) {     // Update function takes the current state
  switch(action) {                   // and an action (String) runs a switch
    case Inc: return increment(model);  // add 1 to the model
    case Dec: return decrement(model);  // subtract 1 from model
    default: return model;           // if no action, return current state.
  }                                  // (default action always returns current)
}

This is functionally equivalent to the simpler update (above)
But does not offer any advantage at this stage (just remember it for later).

5.4 Define the view Function

The view function is responsible for rendering the state to the DOM.

function view(signal, model, root) {
  empty(root);                                 // clear root element before
  [                                            // Store DOM nodes in an array
    button('+', signal, Inc),                  // create button (defined below)
    div('count', model),                       // show the "state" of the Model
    button('-', signal, Dec)                   // button to decrement counter
  ].forEach(function(el){ root.appendChild(el) }); // forEach is ES5 so IE9+
}

The view receives three arguments:

  • signal defined above in mount tells each (DOM) element how to "handle" the user input.
  • model a reference to the current value of the counter.
  • root a reference to the root DOM element where the app is mounted.

The view function starts by emptying the DOM inside the root element using the empty helper function.
This is necessary because, in the Elm Architecture, we re-render the entire application for each action.

See note on DOM Manipulation and "Virtual DOM" (below)

The view creates a list (Array) of DOM nodes that need to be rendered.

5.4.1 view helper functions: empty, button and div

The view makes use of three "helper" (DOM manipulation) functions:

  1. empty: empty the root element of any "child" nodes. Essentially delete the DOM inside whichever element's passed into empty.
function empty(node) {
  while (node.firstChild) { // while there are still nodes inside the "parent"
      node.removeChild(node.firstChild); // remove any children recursively
  }
}
  1. button: creates a <button> DOM element and attaches a "text node" which is the visible contents of the button the "user" sees.
function button(buttontext, signal, action) {
  var button = document.createElement('button');  // create a button HTML node
  var text = document.createTextNode(buttontext); // human-readable button text
  button.appendChild(text);                       // text goes *inside* button
  button.className = action;                      // use action as CSS class
  button.onclick = signal(action);                // onclick sends signal
  return button;                                  // return the DOM node(s)
}
  1. div: creates a <div> DOM element and applies an id to it, then if some text was supplied in the second argument, creates a "text node" to display that text. (in the case of our counter the text is the current value of the model, i.e. the count)
function div(divid, text) {
  var div = document.createElement('div'); // create a <div> DOM element
  div.id = divid;
  if(text !== undefined) { // if text is passed in render it in a "Text Node"
    var txt = document.createTextNode(text);
    div.appendChild(txt);
  }
  return div;
}

Note: in elm land all of these "helper" functions are in the elm-html package, but we have defined them in this counter example so there are no dependencies and you can see exactly how everything is "made" from "first principals".

Once you have read through the functions (and corresponding comments),
take a look at the tests.

Pro Tip: Writing code is an iterative (repetitive) process, manually refreshing the web browser each time you update some code gets tedious quite fast, Live Server to the rescue!

6. (Optional) Install "Live Server" for "Live Reloading"

Note: Live Reloading is not required, e.g. if you are on a computer where you cannot install anything, the examples will still work in your web browser.

Live Reloading helps you iterate/work faster because you don't have to
manually refresh the page each time.
Simply run the following command:

npm install && npm start

This will download and start live-server which will auto-open your default browser:
Then you can navigate to the desired file. e.g: http://127.0.0.1:8000/examples/counter-basic/

7. Read the Tests!

In the first example we kept everything in one file (index.html) for simplicity.
In order to write tests (and collect coverage), we need to separate out the JavaScript code from the HTML.

For this example there are 3 separate files:

test-example-files

Let's start by opening the /examples/counter-basic-test/index.html file in a web browser:
http://127.0.0.1:8000/examples/counter-basic-test/?coverage

counter-coverage

Because all functions are "pure", testing the update function is very easy:

test('Test Update update(0) returns 0 (current state)', function(assert) {
  var result = update(0);
  assert.equal(result, 0);
});

test('Test Update increment: update(1, "inc") returns 2', function(assert) {
  var result = update(1, "inc");
  assert.equal(result, 2);
});

test('Test Update decrement: update(3, "dec") returns 2', function(assert) {
  var result = update(1, "dec");
  assert.equal(result, 0);
});

open: examples/counter-basic-test/test.js to see these and other tests.

The reason why Apps built using the Elm Architecture are so easy to understand
(or "reason about") and test is that all functions are "Pure".

8. What is a "Pure" Function? (Quick Learning/Recap)

Pure Functions are functions that always return the same output for a given input.
Pure Functions have "no side effects", meaning they don't change anything they aren't supposed to,
they just do what they are told; this makes them very predictable/testable. Pure functions "transform" data into the desired value, they do not "mutate" state.

8.1 Example of an Impure Function

The following function is "impure" because it "mutates" i.e. changes the counter variable which is outside of the function and not passed in as an argument:

// this is an "impure" function that "mutates" state
var counter = 0;
function increment () {
  return ++counter;
}
console.log(increment()); // 1
console.log(increment()); // 2
console.log(increment()); // 3

see: https://repl.it/FIot/1

8.2 Example of an Pure Function

This example is a "pure" function because it will always return same result for a given input.

var counter = 0;
function increment (my_counter) {
  return my_counter + 1;
}
// counter variable is not being "mutated"
// the output of a pure function is always identical
console.log(increment(counter)); // 1
console.log(increment(counter)); // 1
console.log(increment(counter)); // 1
// you can "feed" the output of one pure function into another to get the same result:
console.log(increment(increment(increment(counter)))); // 3

see: https://repl.it/FIpV

8.3 Counter Example written in "Impure" JS

It's easy to get suckered into thinking that the "impure" version of the counter
examples/counter-basic-impure/index.html is "simpler" ...
the complete code (including HTML and JS) is 8 lines:

<button class='inc' onclick="incr()">+</button>
<div id='count'>0</div>
<button class='dec' onclick="decr()">-</button>
<script>
  var el = document.getElementById('count')
  function incr() { el.innerHTML = parseInt(el.textContent, 10) + 1 };
  function decr() { el.innerHTML = parseInt(el.textContent, 10) - 1 };
</script>

This counter does the same thing as our Elm Architecture example (above),
and to the end-user the UI looks identical:

counter-impure-665

The difference is that in the impure example is "mutating state" and it's impossible to predict what that state will be!

Annoyingly, for the person explaining the benefits of function "purity" and the virtues of the Elm Architecture
the "impure" example is both fewer lines of code (which means it loads faster!), takes less time to read
and renders faster because only the <div> text content is being updated on each update!
This is why it can often be difficult to explain to "non-technical" people that code which has similar output
on the screen(s) might not the same quality "behind the scenes"!

Writing impure functions is like setting off on a marathon run after tying your shoelaces incorrectly ...
You might be "OK" for a while, but pretty soon your laces will come undone and you will have to stop and re-do them.

To conclude: Pure functions do not mutate a "global" state and are thus predictable and easy to test; we always use "Pure" functions in Apps built with the Elm Architecture. The moment you use "impure" functions you forfeit reliability.

9. Extend the Counter Example following "TDD": Reset the Count!

As you (hopefully) recall from our Step-by-Step TDD Tutorial, when we craft code following the "TDD" approach, we go through the following steps:

  1. Read and understand the "user story" (e.g: in this case: issues/5) reset-counter-user-story
  2. Make sure the "acceptance criteria" are clear (the checklist in the issue)
  3. Write your test(s) based on the acceptance criteria. (Tip: a single feature - in this case resetting the counter - can and often should have multiple tests to cover all cases.)
  4. Write code to make the test(s) pass.

BEFORE you continue, try and build the "reset" functionality yourself following TDD approach!




9.1 Tests for Resetting the Counter (Update)

We always start with the Model test(s) (because they are the easiest):

test('Test: reset counter returns 0', function(assert) {
  var result = update(6, "reset");
  assert.equal(result, 0);
});

9.2 Watch it Fail!

Watch the test fail in your Web Browser:
reset-counter-failing-test

9.3 Make it Pass (writing the minimum code)

In the case of an App written with the Elm Architecture, the minimum code is:

  • Action in this case var Res = 'reset';
  • Update (case and/or function) to "process the signal" from the UI (i.e. handle the user's desired action)
case Res: return 0;

reset-counter-test-passing

9.4 Write View (UI) Tests

Once we have the Model tests passing we need to give the user something to interact with!
We are going to be "adventurous" and write two tests this time!
(thankfully we already have a UI test for another button we can "copy")

test('reset button should be present on page', function(assert) {
  var reset = document.getElementsByClassName('reset');
  assert.equal(reset.length, 1);
});

test('Click reset button resets model (counter) to 0', function(assert) {
  mount(7, update, view, id); // set initial state
  var root = document.getElementById(id);
  assert.equal(root.getElementsByClassName('count')[0].textContent, 7);
  var btn = root.getElementsByClassName("reset")[0]; // click reset button
  btn.click(); // Click the Reset button!
  var state = root.getElementsByClassName('count')[0].textContent;
  empty(document.getElementById(id)); // Clear the test DOM elements
});

9.5 Watch View/UI Tests Fail!

Watch the UI tests go red in the browser:

reset-counter-failing-tests

9.6 Make UI Tests Pass (writing the minimum code)

Luckily, to make both these tests pass requires a single line of code in the view function!

button('Reset', signal, Res)

reset-counter


10. Next Level: Multiple Counters!

Now that you have understood the Elm Architecture by following the basic (single) counter example, it's time to take the example to the next level: multiple counters on the same page!

Multiple Counters Exercise

Follow your instincts and try to the following:

1. Refactor the "reset counter" example to use an Object for the model (instead of an Integer)
e.g: var model = { counters: [0] }
where the value of the first element in the model.counters Array is the value for the single counter example.

2. Display multiple counters on the same page using the var model = { counters: [0] } approach.

3. Write tests for the scenario where there are multiple counters on the same page.

Once you have had a go, checkout our solutions: examples/multiple-counters
and corresponding writeup: multiple-counters.md


11. Todo List!

The ultimate test of whether you learned/understood something is
applying your knowledge to different context from the one you learned in.

Let's "turn this up to eleven" and build something "useful"!

GOTO: todo-list.md


Futher/Background Reading




tl;dr

Flattening the Learning Curve

The issue of the "Elm Learning Curve" was raised in: github.com/dwyl/learn-elm/issues/45
and scrolling down to to @lucymonie's list we see the Elm Architecture at number four ...
this seems fairly logical (initially) because the Elm Guide uses the Elm Language to explain the Elm Architecture: https://guide.elm-lang.org/architecture

elm-architecture

i.e. it assumes that people already understand the (Core) Elm Language...
This is a fair assumption given the ordering of the Guide however ... we have a different idea:

Hypothesis: Learn (& Practice) Elm Architecture before Learning Elm?

We hypothesize that if we explain the Elm Architecture (in detail) using a language
people are already familiar with (i.e JavaScript) before diving into the Elm Language
it will "flatten" the learning curve.

Note: Understanding the Elm Architecture will give you a massive headstart
on learning Redux which is the "de facto" way of structuring React.js Apps.
So even if you decide not to learn/use Elm, you will still gain great frontend skills!

Isn't DOM Manipulation Super Slow...?

DOM manipulation is the slowest part of any "client-side" web app.
That is why so many client-side frameworks (including Elm, React and Vue.js) now use a "Virtual DOM". For the purposes of this tutorial, and for most small apps Virtual DOM is total overkill!
It's akin to putting a jet engine in a go kart!

What is "Plain" JavaScript?

"Plain" JavaScript just means not using any frameworks or features that require "compilation".

The point is to understand that you don't need anything more than "JavaScript the Good Parts"
to build something full-featured and easy/fast to read!!

babel

If you can build with "ES5" JavaScript:
a) you side-step the noise and focus on core skills that already work everywhere!
(don't worry you can always "top-up" your JS knowledge later with ES6, etc!)
b) you don't need to waste time installing Two Hundred Megabytes of dependencies just to run a simple project!
c) You save time (for yourself, your team and end-users!) because your code is already optimized to run in any browser!

About

🦄 Learn how to build web apps using the Elm Architecture in "vanilla" JavaScript (step-by-step TDD tutorial)!

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published