Hello folks!
I’ve been using Elmish (An F# implementation of Elm) to develop a few rather large applications over the last three years. Each one uses data fetched and cached from a server, and contains multiple pages (with routing between them) and about a dozen re-usable UI components (a table, a collapsible tree with drag-drop, a grid view with a data display, etc).
Things are going pretty well but I’ve come up against a design issue that is a bit philosophical, and I wanted to get some input from others. The issue is best described with a basic example.
Say you have web page with some stuff on it, including a graph on one side. The graph is implemented as a reusable component – a module with its own model, view, and update.
The graph depends on a set of data which is fetched from the server. That data takes a while to fetch. It changes on a regular basis, and the graph has to redraw whenever it changes. Several other parts of the page need to redraw too.
The question is this: What’s the clearest way to get that changed data into the graphing component?
Approach 1:
Act as though the data doesn’t “belong” to the component. Instead of putting it in the model, pass it into the view function, taking it from some enclosing model. The component’s model never sees it. How stale it is, is someone else’s problem. (The parent’s.)
Issues:
- The component’s model no longer contains all the state the component needs to work, gathered in one place. This is potentially confusing.
- The user of the component now needs to deal with a custom view function.
- If we want to write any logic in the update function that uses this data, we need to pass it in as part of a message. This may introduce some skew between the data we rendered in the view, and the data we’re reacting to in “update”.
Approach 2:
Have the enclosing component alter the graph’s model directly, swapping in the new data, somewhere in the enclosing component’s update function.
Issues:
- The graph component’s update function used to be THE ONLY code that manipulated its own model. Now that’s no longer true. A developer reading this code can no longer understand how the module works just by looking at the model, update, view.
- Any developer working with this component now needs to place additional code in the enclosing component. How will they know when and where? We’ll just have to rely on some README document or code comment.
Approach 3:
Write a function within the graphing module, publicly exposed, with the name “updateSourceData”, which accepts a model and the new data, and emits the model with the new data embedded. Write commentary on the function, explaining to the developer when and where they need to call it. Now at least it’s clear how to get new data into the module.
Issues:
- Like approach 2, this introduces manipulation of the model outside the update function. But, at least there is a clear way to do it, controlled more-or-less by the module.
- The flow of data is confusing: The graphing module’s model is contained in a parent module. The parent is already passing that model down into the graphing module’s “update” function to handle wrapped messages, and possibly receive “external” messages back. Now the parent is passing that model to some other function inside the graphing module as well, in a separate operation. (Do we do that before we call “update”, or after? Better read the docs.)
Approach 4:
Along with the usual set of messages that are handled internally by the graphing component to do its business, declare an “IncomingMessage” type. Above that, declare a handful of sub-types of “IncomingMessage”, including one called “UpdateSourceData x” where x is the data type. Add commentary explaining when to send it. Handle these messages in the update function like all the others. In the parent module, whenever you want to give new data to the graph, generate a wrapped message for it, of type “IncomingMessage (UpdateSourceData x)”. Users of this component no longer need to directly manipulate the model, for any reason. All the code that alters the model is contained inside the graphing module’s update function, in fitting with the philosophy of Elm.
Issues:
- Hmm, messages in and messages out. We appear to have re-created Objective C.
So, what approach do you folks like? Or is there one I haven’t described here?
Personally, I’ve come around to favoring approach 4, because it results in the smallest amount of code outside the standard Elm template. We send down wrapped messages, right alongside the ones wrapped for the component itself, and we only need to call “update” and “view” like usual. The model carries everything the component needs to render.
An additional note about rendering performance:
Approach 1 and 2 have problems when updating the view is expensive. In approach 1, the component has no idea whether the data has actually changed relative to the last render. In approach 2, the component might possibly be able to compare the new data with an older copy stored in the model, but it would have to make that comparison every time, and just the comparison itself might be expensive. (Unless you want to rely on some sort of pointer comparison witchcraft.) With approach 3 and 4, it’s clear to the module when the data is expected to be changed, and it can do something like set an internal flag to force a refresh, if that’s necessary.
This may not matter, depending on what framework you’re rendering with and the complexity of what you’re rendering, but let’s say in the case of this graph example, it does matter: If we were to try and let something like React automatically handle refreshes, we’d still need to process the data (perhaps 100,000 records) into HTML or SVG and hand it to React with every call to “view” – a huge waste of CPU time. (In my code, this problem is solved by creating a small custom React component inside the graph module, designed to update only when a counter in the model changes relative to its own counter, and the counter is incremented when handling the “UpdateSourceData” message.)