MVC in a Reactive World
The history of front-end JavaScript frameworks has witnessed many implementations and variations on the Model‑View‑Controller (MVC) application architecture. During that history, MVC has been lauded as a panacea, decried as snake oil, and everything in between. But for the most part, MVC has managed to stay out of the pillory because, generally speaking, people find it useful to follow its guidelines. Unfortunately, many of those guidelines aren’t always clear, resulting in the aforementioned variations that mostly focus on clarifying role and structure of the Controller. For example, Ember.js is one take on MVC directly, Knockout.js tries to further break the problem down using Model View ViewModel, and Angular.js takes delight in defining the controller as whatever is left over after you define the model and the view.
On first glance the rise of reactive systems based on frameworks or languages seems to make the task of clearly defining and implementing MVC irrelevant; this is after all a new paradigm of programming that has its own philosophy about how these things should be done. But oddly enough, those reactive systems provide the abstractions that are necessary to clearly define MVC. In short, the model is information, and the view and controller are computations. While the view function is simple to describe, the controller computation requires certain reactive concepts to characterize it concisely. What follows is a description of the model as information, and the view and controller as computations using the Elm programming language as the reactive system.
The Model Is Information
The model describes the entire state of the application, all in one place. It has a concrete type m
that the user defines. While bits of state may be strewn about your application, the model is the authoritative record of state. In other words, the user should not read the random bits strewn across the application.
As a running example, consider an application to create, modify, and destroy an arbitrary number of counter widgets. The interface includes a button that the user can click to create a new widget. Each widget displays its count along with buttons to increment and decrement its count, as well as a button to delete itself. If you assign a unique integer or index to each counter you create, then the entire application state can be recorded as an integer representing the last counter index that was used together with a mapping from indexes to counts. Described as a record type, it would look like this:
type Model = {
curr_index : Int,
counters : Dict Int Int
}
The View Is Computation
To display an interface, you have to produce some representation of the interface that the underlying system can use to render it. That representation has a concrete type r
. HTML is one such representation in prevalent use today. So is SVG. In fact if you view this blog post as a (very simple) application, it is using HTML as its representation type. But the representation is not the view. The view is a function that can map a value of the model type m
to a value of the representation type r
. In other words, a view is a function of type m -> r
.
Element
is Elm’s representation type. It’s the type that the runtime knows how to render to the screen. The standard library provides many functions for creating and composing Elements
. But in addition there are several open-source libraries that extend the Element
type with the ability to construct and render UI elements in different ways. This blog post will use the elm-d3 library for view examples. elm-d3 replaces the default Elm renderer with one based on D3.js.
The rendered representation is what the user will ultimately use to interact with your application. That representation therefore must also describe how to translate user interactions into high-level events in the application’s conceptual domain. For example in the counter application, you can represent its high-level events using a data type declaration like this one.
data Event
= Create
| Increment Int
| Decrement Int
| Remove Int
Each of the four variants of this data type represent the four high-level events of the counter application: creating a new counter; incrementing an existing counter; decrementing an existing counter; and removing an existing counter. These high-level events are ultimately caused by low-level DOM events. In particular, they’re caused by the user clicking on certain parts of the interface.
The way that elm-d3 exposes low-level DOM events requires you to transform them into your higher-level Event
type and insert them into a stream of Event
s for later processing. The code snippet below shows how to turn a <div>
element into a button that, when clicked, will generate a Click
event and place it into the events
stream. The function that does this low-to-high-level event translation is given the DOM mouse event as e
, the datum that’s bound to the current DOM element as d
, and the index i
of that datum in the array that was bound to this level of the DOM tree.
creator : Selection a
creator =
static "div" <.> str attr "class" "box creator" -- * create the <div> element
|. text (\d i -> "create counter") -- * set the inner text of the <div>
|. click events (\e d i -> Create) -- * translate a DOM click event into an Event
-- and place into the event stream
The Selection a
type is the type that elm-d3 uses to represent view computations that can map a model of type a
to a representation of type Element
, i.e., the representation type that the Elm runtime uses. elm-d3 exports the render
function that allows you to transform a Selection a
into a function of type a -> Element
.
render : Int -> Int -> Selection a -> a -> Element
The first two arguments are the width and height of the render area and the third is the elm-d3 view computation. After you partially apply the function to its first three arguments, you get a view. Eliding the code for rendering the counters themselves—which you can find here— the view code for the counters application will look something like this:
d3_view : Selection Model
d3_view =
let counters' =
static "div" <.> str attr "class" "counters"
|. embed counters
in
sequence creator counters'
view : Model -> Element
view = render 800 600 d3_view
The Controller Is Computation
The controller mediates user interactions and model updates. It handles Event
s by updating the Model
accordingly. That’s a fancy way of saying that the controller is an event loop. That’s what a controller is and that’s what it always has been. What makes the controller difficult at times to pin down is that it is an effectful computation. Whereas the view can be characterized by a pure function of type Model -> Element
, the controller requires more sophisticated types in order to express that it is effectful. Despite this all controllers can be modeled and implemented as though they have a pure core: the event handler itself.
handler : Event -> Model -> Model
handler e m = case e of
Create -> let next' = m.next + 1 in
{ curr_index = next', counters = Dict.insert next' 0 m.counters }
Increment i -> { m | counters <- increment i m.counters }
Decrement i -> { m | counters <- decrement i m.counters }
Remove i -> { m | counters <- Dict.remove i m.counters }
A high-level event is in essence a compact encoding of an operation on your model that your application can understand, decode, and execute. The handler
is the function that does this decoding. This fact is even expressed in handler
‘s type. If you partially apply handler
to an event, you get a function that will perform that operation on your model.
change : Model -> Model
change = handler Change
increment1 : Model -> Model
increment1 = handler (Increment 1)
The controller is then the computation in your application that receives events, decodes them, and then executes the decoded operation on the model. By receiving an event, the controller is asking for input, which is an effectful process. In elm-d3 events from the view are inserted into a stream of events whose type is Stream e
, which the controller can then iterate over using the function folde
:
folde : (e -> m -> m) -> m -> Stream e -> Signal m
folde
is an event loop constructor. It takes an event handler as its first argument and an initial value for the model as its second argument. The result after providing these first two argument is a function that turns a stream of events—or Stream e
—into a time-varying model—or Signal m
. This function type characterizes a controller.
controller : Stream Event -> Signal Model
controller = folde handler { curr_index = 0, counters = Dict.empty }
A “Complete” MVC Application
The model is the information-as-data that the view and the controller use to communicate. The view is a pure computation that turns the model into a representation that the underlying system knows how to render to the screen. Finally, the controller is an effectful computation that turns a stream of high-level events into time-varying model value. What’s left is to figure out how to compose the view and controller computations to produce a complete application. The missing composition operator is lift
.
lift : (a -> b) -> Signal a -> Signal b
lift
takes a pure function as an argument and turns it into a function that does the same thing as the original function, except you can apply it to time-varying values. More specifically, it applies the original function to every specific value a
that the Signal a
takes on, producing a series of values b
that vary over time, or a Signal b
. By applying lift to the view
, produces a function that will create a time-varying value of Element
from the time-varying value of Model
.
view' : Signal Model -> Signal Element
view' = lift view
You can construct the complete MVC application by composing the lifted view with the controller as functions, and then applying the result to the event stream. More concretely you can define the entry point of the application like this.
main : Signal Element
main = view' (controller events)
This application will render an interface to the screen based on the initial value of the model. As the user interacts with the interface it will generate high-level events that the controller will process to update the model. The application then passes the updated model to the view to create a new interface representation. Given an initial model m0
and a stream of events e0, e1,
...
, an application execution will have the following form:
view m0 = r0
view (m1 = handler e0 m0) = r1
view (m2 = handler e1 m1) = r2
…
The Elm runtime will detect every time that the application generates a new interface representation in the form of an Element
. When that happens the runtime will call the elm-d3 renderer to efficiently update the interface.