Shifting from OOP Web Components to FP: A brief look at Elm
Published
A couple of months ago, someone asked on the JavaScript subreddit how functional programmers handle dealing with unavoidably immutable actions, like DOM manipulation. I gave my $0.02, saying that imposing functional programming constraints on your DOM-specific code is more trouble than it’s worth, and to leverage FP on the boundaries of your code that don’t interact with it. I ended with a recommendation for Elm, which is a neat little functional language that compiles to JavaScript, if someone is fully intent on writing more of their application in a functional style.
Recently I’ve spent some time playing more with Elm, and I’ve actually been enjoying it quite a lot! The documentation has improved immensely over the last few years and it is a lot more friendly getting on-boarded. I learned functional programming about 6 years ago at university – learning Haskell for the FP course and Principles of Programming Languages course) – but never spent time applying it in a UI context, and still found the documentation/supplementary material on Elm a bit lacking (until recently). So today I thought I’d outline some of the principle differences in building UIs between the component-based model (which we see in the likes of React, Vue.js, etc…) and Elm.
*Note: this article will assume knowledge of JSX for the component examples, as well as and basic knowledge of Elm’s syntax for the Elm code samples.*
The Component Model and Object Orientation
When building modern web components, we use a distinctly Object Oriented model. We decompose the application into a collection of components, which represent a fragment of the overall user interface and handle some part of the application’s state. We wire these components together by structuring them in a tree – as we do with HTML – and then allow the components to communicate with each other via a defined interface. In OOP this interface is the publicly exposed object methods, and with web components this is the publicly subscribable component events. This can be accessed programatically (e.g. elem.addEventListener()
or, more commonly via passing callback functions as attributes to the respective elements (e.g. <elem onSomeEvent={myCallback}/>
)). We pass data from parents to children via attributes (also called props
in some frameworks), and from children to parents via callbacks; updating local state where necessary, and re-rendering the subtree where necessary.
In the case of a basic todo-list application, you’d probably have a <Todo>
component which represents an individual item which you can “tick” off as done, change the description for, or delete. You’d also have a <TodoList>
component which renders a list of Todo
components based on its internal list of todo objects. Then in your top level <App>
, you might have a <form>
that users can type text into, and when they submit the form it adds the typed text as a new todo.
class App extends React.Component {
// omitted other methods for brevity...
render() {
return <div>
<form onSubmit={this.addTodo}>
<input value={this.inputText} onChange={this.handleInputText}/>
</form>
<TodoList todos={this.state.todos}/>
</div>;
}
}
class TodoList extends React.Component {
// ...
render() {
return <ul>
{this.state.todos.map(todo => (<li>
<Todo
id={todo.id}
description={todo.description}
done={todo.done}
onChange={this.handleChange}
onDelete={this.handleDelete}
/>
<li>))}
</ul>;
}
}
class Todo extends React.Component {
constructor(props) {
super(props);
this.state = {
mode: "view",
description: props.description,
}
// ...
}
// ...
render() {
switch (this.state.mode) {
case "edit":
return <div class="edit-mode">
<input value={this.state.description} onChange={this.handleChange}/>
</div>;
default:
return <div>
<input type="checkbox" checked={this.props.done} onChange={this.props.onChange}/>
<span>{this.props.description}</span>
<a href="#" onClick={this.enableEditMode}>Edit</a>
<a href="#" onClick={this.props.onDelete}>Delete</a>
</div>;
}
}
}
This model is great for reducing the complexity of a massive user interface because it allows us to think of it in terms of smaller – but still useful – “blocks”, which slot together to form the overall application. You don’t need to have the whole application “in your head” in order to build it - you can build it out in isolated pieces, then wire together the desired pieces. You can (read: should!) write tests on this “wiring” to ensure that the components behave together as expected, but this isn’t a massive trade-off to make when it can hugely increase the cognitive ease of understanding the application.
That trade-off however comes with an acknowledgement that understanding the full state-space in a predictable, reliable fashion, is almost impossible beyond very simple applications. In some cases, this can be quite undesirable. For instance, if there is an entire class of bugs that can arise from my application entering particular states, how can I safely prevent the user from ever being able to enter these states, when it requires synchronisation across so many component boundaries?
Hello, Functional Programming; Goodbye Local State
The fundamental constraint in functional programming is that you cannot re-assign variables. Local state is only possible if a component can internally re-assign variables, so…no local state!
This means that we actually can’t meaningfully build up components in the OO sense any more. We don’t lose the ability to modularise our code however – more on that later – we instead are forced to rethink how we divvy up state.
Think of the entire application state as existing in a JavaScript object. Let’s call it model
, with a type Model
. Suppose that the HTML output of our application is a pure function, which takes model
as an input.
function view(model: Model) : HTML
So if I have models m1
and m2
, and these two models have the same values (i.e. m1 == m2
), then they will output the exact same HTML (i.e. view(m1) == view(m2)
).
How do we have state changes then?
Well, suppose that in our rendered HTML, we have event handlers which map events into messages that tell us how the state should change. So there is a type Msg
, which tells me all the different ways that the state can change, and a function update
, which takes a Msg
and a Model
and gives me a new model (again, a pure function). So:
function update(msg: Msg, model: Model): Model
When my HTML nodes dispatch their events, they map to a msg
; that msg
and current model
are given to update()
, and I get my next piece of state.
Then to bootstrap the application, I just need to have an initial model
to kickstart the process.
This is what Elm does for you! You define:
Model
for your valid statesMsg
for your state-deltasupdate
to explicitly define how to transition from onemodel
to anotherview
for how the output should look, and to map DOM events toMsg
sinit
to define an initial state
then hand them off to Elm’s compiler, and the outputted JavaScript will handle the DOM updates.
Back to our todo example, we can give definitions like the following:
-- Main.elm
module Main exposing (main)
import Html -- used for rendering HTML nodes
import Html.Attributes -- used for providing HTML attributes
import Html.Events -- used for binding to DOM events
-- BOOTSTRAP
main = Browser.sandbox
{ init =
{ inputText = "Type your todo here..."
, todos = []
}
, update = update
, view = view
}
-- MODEL
type alias Model =
{ inputText : Description
, todos : List Todo
}
-- Some aliases to improve the readability of the Msg below...
type alias Todo =
{ id : TodoId
, description : Description
, status : TodoStatus
, mode : ViewMode
}
type alias TodoId = String
type alias Description = String
type alias TodoStatus = Bool
type ViewMode
= View
| Edit Description
-- MSG
type Msg
= ChangeInputText Description
| AddNewTodo Description
| ChangeTodoStatus TodoId TodoStatus
| EnableEditMode TodoId
| ChangeEditModeText Description
| ChangeTodoDescription Description
| DeleteTodo TodoId
-- UPDATE
update : Msg -> Model -> Model
update msg model =
case msg of
ChangeInputText desc ->
...
...
DeleteTodo id ->
...
-- VIEW
view : Model -> Html.Html Msg
view model =
Html.div []
[ Html.h1 [] [ Html.text "Todo List" ]
, Html.form [ Html.Events.onSubmit (AddNewTodo model.inputText) ]
[ Html.input [ Html.Events.onInput ChangeInputText, Html.Attributes.value model.inputText ] [] ]
, viewTodos model.todos
]
viewTodos : TodoList -> Html.Html Msg
viewTodos todos = ...
The massive benefit of this is that we have a deterministic, fully predicable state-space. We can effectively draw a directed graph of all the state classes that can be entered, just based on the Msg
, Model
and update
. Was it obvious from the component-based version of the app that these were all of the available state transitions? It would have been easy to end up in a situation where we had invalid state, simply because we couldn’t reliably tell what the state-space was.
We still want to break code down into smaller pieces though, since cognitively it is quite a lot to have in one head. I’ve also deliberately left out pieces of the implementation because I wanted to leave that for the next section.
Modularisation around Data Structures
Moving away from a component/class based model, we instead create modules based around pure data structures, and ways of manipulating said data.
So if we were to break up the todo list, we wouldn’t break it up into the sorts of files you’d associate with components, such as Components/Todo.elm
, Components/TodoList.elm
, where each has the view/render logic as well as local state (since again, no local state). Instead, we think of just the data that we’re trying to manipulate, and provide an API for doing so.
Imagine that there was a Todo
module, which handled the different data transitions for a todo-list. How might it be integrated into the previous code?
-- Main.elm
module Main exposing (main)
import Todo -- hypoethetical module
import Html
import Html.Attributes
import Html.Events
-- BOOTSTRAP IS THE SAME
main = ...
-- MODEL
type alias Model =
{ inputText : Todo.Description
, todos : Todo.List
}
-- MSG
type Msg
= ChangeInputText Todo.Description
| AddNewTodo Todo.Description
| ChangeTodoStatus Todo.Id Todo.Status
| EnableEditMode Todo.Id
| ChangeEditModeText Todo.Description
| ChangeTodoDescription Todo.Description
| DeleteTodo Todo.Id
This module will include methods for manipulating lists to todos. So update
will use them for creating new model
s:
-- UPDATE
update : Msg -> Model -> Model
update msg model =
case msg of
ChangeInputText text ->
{ model | inputText = text }
AddNewTodo desc ->
{ model | todos = Todo.add desc todos }
ChangeTodoStatus id status
{ model | todos = Todo.changeStatus id status todos }
EnableEditMode id ->
{ model | todos = Todo.enableEditMode id todos }
ChangeEditModeText text ->
{ model | todos = Todo.changeEditModeText id text todos }
ChangeTodoDescription desc ->
{ model | todos = Todo.changeDescription id desc todos }
DeleteTodo id ->
{ model | todos = Todo.delete id todos }
Since it encapsulates lists, we’ll also expose a method for iterating through the list so that we can render the output.
-- Render a list of todos
viewTodos : Todo.List -> Html.Html Msg
viewTodos todos =
Html.ul [] Todo.map viewTodo todos
-- Render a single todo
viewTodo : Todo.Todo -> Html.Html Msg
viewTodo todo =
Html.li []
[ case todo.mode of
Todo.View -> viewTodoInViewMode todo
Todo.Edit text -> viewTodoInEditMode todo text
]
-- Render the "view" mode of a todo
viewTodoInViewMode : Todo.Todo -> Html.Html Msg
viewTodoInViewMode todo =
Html.div []
[ viewTodoCheckbox todo
, Html.span [] [ Html.text todo.description ]
, viewTodoEditButton todo
, viewTodoDeleteButton todo
]
-- Render the "done" checkbox of a todo
-- Also maps changes in the textbox to `ChangeTodoStatus` messages
viewTodoCheckbox : Todo.Todo -> Html.Html Msg
viewTodoCheckbox todo =
Html.input
[ Html.Attributes.type_ "checkbox"
, Html.Attributes.value todo.status
, Html.Events.onChange (ChangeTodoStatus todo.id)
] []
-- Render the "edit" button of a todo
-- Also maps button clicks to `EnableEditMode` messages
viewTodoEditButton : Todo.Todo -> Html.Html Msg
viewTodoEditButton todo =
Html.a [ Html.Events.onClick (EnableEditMode todo.id)] [ Html.text "Edit" ]
-- Render "delete" button for a todo
-- Also maps button clicks to `DeleteTodo` messages
viewTodoDeleteButton : Todo.Todo -> Html.Html Msg
viewTodoDeleteButton todo =
Html.a [ Html.Events.onClick (DeleteTodo todo.id)] [ Html.text "Delete" ]
-- Render "edit mode" for a todo
-- Also maps changes in the description (drafted or completed) to `ChangeEditModeText` and `ChangeTodoDescription` messages respectively
viewTodoInEditMode : Todo.Todo -> Todo.Description -> Html.Html Msg
viewTodoInEditMode todo draft =
Html.form [ Html.Events.onSubmit (ChangeTodoDescription todo.id draft)]
[ Html.input
[ Html.Attributes.value draft, Html.Events.onInput ChangeEditModeText ] []
]
Notice how we’ve broken the view into several “helper” functions, rather than turning them into separate components/modules. They are still relatively small and easy to understand (once the syntax is gotten used to!)
The Todo
module can then expose the relevant functions and data structures:
module Todo exposing (..) -- exposing everything for the sake of this example
import List
-- DATA STRUCTURES
type alias Id = String
type alias Description = String
type alias Status = Bool
type Mode = View | Edit Description
type alias Todo =
{ id : Id
, description : Description
, status : Status
, mode : Mode
}
type alias List = List.List Todo
-- FUNCTIONS
add : Description -> List -> List
changeStatus : Id -> Status -> List -> List
enableEditMode : Id -> Mode -> List -> List
changeEditModeText : Id -> Description -> List -> List
changeDescription : Id -> Description -> List -> List
delete : Id -> List -> List
map : (Todo -> a) -> List -> List.List a
A brief aside on side effects
There are certain scenarios where we cannot operate with truly pure functions. If I make an HTTP request, I need to wait for the response from the network: this is asynchronous, and hence stateful. If I want to check the current time, that requires checking the system’s clock, which means accessing global state. Same goes for accessing localStorage
.
Commands and Subscriptions allow us to abstract away side-effect specific code and treat it as though it were pure. The update
and init
functions change to now return a pair of Model
and Cmd
, so that on the next state change the runtime knows which commands it needs to execute.
Elm bakes some top-level support for some actions in this way, such as HTTP requests. Where it isn’t supported, we effectively we create “interfaces” for the side-effecting code (called ports
) – similar to how we deal with the repository pattern – then use Subscriptions and Commands to hook it into the event loop of Elm (translating the event responses into Msgs
). This is a topic that deserves its own article(s) though, so I’ll leave it at that for now.
Summary
In OOP and component-based design, we organise state and methods into objects/components, which manage fragments of local state and communicate to each other through a public API.
In FP, we define data structures and functions which fully map out the intended state-space (from the top->down), and create modules for managing data types (statelessly).