Elm Architecture for React
An Experiment in React App Architecture
Disclaimer: This is just an experiment. I’m not advocating this approach for real-world applications. It’s neither reactive nor does it actually work anything like Elm at all.
In this post I will try to explain an experiment in making the Elm architecture work for plain React applications, without any of the FRP aspects. While it doesn’t really work like Elm at all it may still be useful to understand the general architectural pattern. As the Elm architecture tutorial itself states:
The basic pattern is useful whether you are writing your front-end in Elm or JS or whatever else.
A Simple Counter
We’ll start with the most basic Elm Architecture example: a simple counter. This is the same example you’ll probably be familiar with from Redux.
Next are the actions and update function:
Since our actions are functions that take a model value and return a new model value we can just apply them directly in the update function. We could of course also use plain objects to describe our actions and use a switch statement, like you would do in Redux.
You get the idea. Only the update function needs to know how to execute the actions so you can use whatever representation makes sense. For brevity’s sake I’ll stick to functions for my examples.
Now the only thing missing is the view.
Lets try a simple function. We’ll call it signal and it will take an action and return a function (a.k.a. callback) that we can pass to onClick etc. We’ll figure out how to make it work in a bit.
To summarize, the basic idea of the Elm architecture is that a component defines three things: a model, an update function and a view function. The model can be any value that makes sense for the component. The update function takes an action and the current model value and returns a new model value. The view function takes a signal (address) and the current model value and returns a DOM representation.
So now that we understand the basic idea, lets try to actually make this work. Our goal is to write a generic React container that will work with any component. We want to use it like this:
So our component takes three props: model, update and view. This part is pretty easy:
We just call the view function to render the component and we pass our signal function and the current model value.
So the big question is, how do we implement signal? This function is supposed to return a callback so we can use it directly in onClick etc. and it takes an action as its only argument.
All we have to do is return a function that applies the given action when called and updates the component state with the new model value. Note that we don’t need to know anything about the action, it could be anything, we just pass it to the update function.
And this is all we have to do to get the simple counter example working. But of course this is not yet enough for a real application. Next we’ll look at how we can nest components.
The next example in the Elm architecture tutorial is a pair of counters. Here is the original Elm code.
First is the init function. It takes two integers to initialize the counters.
You might be wondering where Counter.init comes from. More complex components usually define init to initialize the model value. We skipped that in the previous example because the model is just a simple integer anyway. The init function for the Counter is just the identity function:
Next are again the actions and update function:
Reset is pretty straightforward to translate:
But what happens with the Top and Bottom actions? Those are parameterized with a Counter action and will just delegate to the Counter.update function.
To parameterize the CounterPair actions with the Counter actions we make them higher order functions. You can also think of Top and Bottom as action factories. They take a Counter action and return a CounterPair action. Another way to put it is that Reset is an action of our CounterPair component, but Top and Bottom are not, they’re functions that will create a CounterPair action when given a Counter action.
Now the only thing left is the view function:
Now we have a problem. We need to implement some equivalent to Signal.forwardTo. If we assume a working implementation then our view function could look like this:
How can we implement this forward function? It takes our signal function and an action factory function. The result must be a function that takes an action and returns a callback that calls signal with the result of the factory function applied to the given action.
Now this might be a little abstract, so lets think through it with a concrete example. If we were not using forward and instead pass signal directly to Counter.view we would call CounterPair.update with an action from Counter but the CounterPair model. This obviously cannot end well.
What we want is to create a Top or Bottom action which will take care of delegating to Counter.update. So when we use forward we pass the result of calling forward(signal, Action.Top) to the Counter.view function. The result is a function that expects an action. Counter.view calls this function (it becomes its signal function) and passes a Counter action, i.e. Increment. This returns a callback that when executed calls Action.Top(Increment) which returns a CounterPair action. We then just call the original signal function with this action and immediately invoke the resulting callback.
One noteworthy aspect of this architectural pattern is that components don’t have to define actions. To make this clear, consider the reverse text example from Elm:
As you can see we don’t define any actions. Instead we just use the new model value in place of the action parameter. Or put another way, we only have one action here, which is a just string that is also the new model value.
To make the view function look a little more like the Elm example we can define two helper functions:
Then we can write it like this:
We now have all the necessary ingredients for infinitely nestable components. This is still not the complete picture though and in the next post we’ll figure out how to deal with side effects like HTTP requests.
But so far we can already see some interesting properties of this pattern. Most notable is that actions are local to their components and really only an implementation detail of the component.
Another important aspect is that we naturally end up with all the application state in a central place. This makes it easy to implement global undo/redo, serialize the application state and so on. And at the same time every component is independently usable and only has to concern itself with its own state.
If you have any questions or comments feel free to contact me on Twitter.