Julian Jelfs
15 Apr 2019
•
5 min read
Disclosure — I don’t have all the answers to the problems I am posing here — as ever, there is more than one way!
Elm manages state using a the single state atom pattern that will be familiar to users of React with redux. The state is initialized and then “modified” in the update function as actions occur. The state is typically passed into the view function to be used in building the UI.
So far so good, but The Elm Architecture is also fractal in nature. This means that it is typical to compose multiple instance of this patterns together in a tree. This usually means that our state becomes hierarchical too.
For example in a typical SPA we might have a structure like this:
RootComponent
PageOne
PageTwo
PageThree
This represents the whole App and then components for each page (related to each route in the app). In this situation it is typical to create a top-level Model that looks something like this:
type alias Root =
{ pageOne: PageOne.Model
, pageTwo: PageTwo.Model
, pageThree: PageThree.Model
}
This is a really useful pattern because it enables us to hide the structure and initialization logic of the page level Models within the page components.
But who owns this data? And what does that mean? I will define ownership as follows: a component owns data, if that data is updated solely in the components update function. So the RootComponent’s update function might look like this:
update: Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
PageOneMsg sub ->
let
(subModel, subCmd) =
PageOne.update sub model.pageOne
in
( { model | pageOne = subModel }
, Cmd.map PageOneMsg subCmd )
...
We recognise this as pure boilerplate i.e. we are simply delegating to the PageOne component’s update function and we know that it is responsible for modifying its model in response to the action.
But what if it’s not that simple (and it never is)? Let’s say that we have the concept of a user and this is generally a root level concern. This may change our RootComponent’s Model to something like this.
type alias Root =
{ user: User
, pageOne: PageOne.Model
, pageTwo: PageTwo.Model
, pageThree: PageThree.Model
}
And it would be quite typical for us to want to access information about the user at all levels of the app. So how do we achieve this? PageOne’s view function is passed a PageOne.Model so it does not have access to the RootComponent.Model’s user property. Our natural inclination is to push the user property into the PageOne.Model as well.
initialiase: User -> Model
initialise user =
{ pageOne = PageOne.initialise user
, pageTwo = PageTwo.initialise user
, pageThree = PageThree.initialise user
}
This is fine as long as the user never changes. If there are events in the app’s lifecycle that can change that data then you have created a problem for yourself that you might not have noticed yet.
The problem is one of ownership. You now have two versions of the truth. RootComponent’s Model and PageOne’s Model. Let’s assume that our user signs out. Who handles this type of action? Most likely the RootComponent:
update: Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
SignOut ->
( { model | user = signOut model.user }
, Cmd.none )
...
So we would say in this case that the RootComponent owns the user state. The problem with the code above is that it does not update the copy of the user that we also added to the PageOne Model. We have two source's of the truth and they are out of sync all of sudden. Hopefully, we notice this.
Assuming that we did notice, what do we do?
What we seem to be saying is that when the SignOut action occurs in RootComponent we need to update the model of certain child components as well. One option is to simply do that in the update function of the RootComponent:
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
SignOut ->
let
pageOne =
model.pageOne
signedOut =
signOut model.user
in
( { model
| user = signedOut
, pageOne = { pageOne | user = signedOut }
}
, Cmd.none
)
What’s wrong with that? A couple of things. Firstly, we have broken our encapsulation. The RootComponent now needs to know the structure of the PageOne Model. This makes the PageOne Model more difficult to change. Secondly, it’s all too easy to forget to do it. It won’t be immediately obvious that we have a problem.
We can perhaps fix the first problem by delegating how to update PageOne’s model to a PageOne utility function.
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
SignOut ->
let
signedOut =
signOut model.user
in
( { model
| user = signedOut
, pageOne = PageOne.updateUser signedOut
}
, Cmd.none
)
This is better. We have regained encapsulation. But the more serious problem remains. There is nothing stopping us from forgetting to do this.
That function PageOne.updateUser
looks a lot like a specialised form of a normal update function, doesn’t it? Would it be more palatable if we were just calling the normal update function?
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
SignOut ->
let
signedOut =
signOut model.user
( pageOne, pageOneCmd ) =
PageOne.update
(PageOneMsg PageOne.signOut) model.pageOne
in
( { model
| user = signedOut
, pageOne = pageOne
}
, Cmd.map PageOneMsg pageOneCmd
)
You might prefer this, but really it’s the same at the end of the day. I still cannot think of a way to make this cascade of updates automatic (or at least enforced by the compiler).
Maybe we can add the user to the child component’s models and just have a convention that it must be replaced with the parent’s copy on each call to update / view. So RootComponent’s update function might look something like this:
update: Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
PageOneMsg sub ->
let
(subModel, subCmd) =
PageOne.update sub model.pageOne
in
( { model | pageOne = updateUser model.user subModel
, Cmd.map PageOneMsg subCmd )
...
-- a general util that can be used from anywhere
updateUser : User -> { a | user: User} -> { a | user: User}
updateUser user model =
{ model | user = user }
Now we are not going to get out of sync, but only if everyone remembers that this is the pattern to use. With this approach, we will continue to get caught out from time to time when we forget.
We must conclude that we simply cannot allow there to be two copies of any volatile state. But this takes us back to the problem, how does the PageOne component access the user. The answer is to ensure that any such state is passed in from the parent component along with the child component’s model, but not as part of if. So PageOne’s update signature might end up looking like:
update: Msg -> User -> Model -> (Model, Cmd Msg)
And if we needed this state in the view we would want to make a similar adjustment to the view signature. Notice that this is quite similar to the approach taken in React where we have props (which is data passed from above that I do not own) and state which is the data that I am in control of myself.
The big advantage of this approach is that we cannot fail to pass it in from above every time and we do not have two copies of the data anymore. Nothing can get out of sync which is very valuable. The downside is that you have to be careful if you are going to make this scale. What happens when I discover another piece of state that is really owned by an ancestor component? I have to add that to the signature as well. All the way down.
It can add to feeling that there is already a bit too much boilerplate for comfort. But in my view, it is the only real solution. Please let me know if you think there is another option!
If you're passionate about functional programming and want to work with it, check out our job-board here!
Julian Jelfs
See other articles by Julian
Ground Floor, Verse Building, 18 Brunswick Place, London, N1 6DZ
108 E 16th Street, New York, NY 10003
Join over 111,000 others and get access to exclusive content, job opportunities and more!