Where we’ve been
If you haven’t done so already, be sure to read the introductory post and the source code that we left off with last time.
Where we’re going
If you recall, I promised we would make our view functions more composable. Let’s take a look at a small subset of the views:
const renderClicky = (state, dispatch) => html`
<div>
You've clicked ${state.clicks} times
</div>
<button onclick=${() => dispatch("clicked")}>
Click Me
</button>
`;
// ...other views
const content = (state, dispatch) => html`
<div class="content">
${renderRefresh(state, dispatch)}
${renderDecorations()}
${renderClicky(state, dispatch)}
</div>
`;
Notice that the content
view dutifully passes along state
and dispatch
to two out of the three sub-views it contains. Surely we can make that better.
A second point: renderClicky
doesn’t need access to the entire application state. It just cares about the clicks
property. It’s irresponsible to give that view access to all of our secret data! In addition, what if we move clicks
to a different part of the state tree?
These two problems will take a few steps to solve, but fear not! Smarter people than I have told me how to solve them.
Step 1: Not everything needs everything
Let’s work on a way to make our views work with only the data they need, rather than the entire application state. Not only does this reduce temptation to build giant components, but it isolates where the data resides from how to display that data.
Reduce the amount of state
Let’s refactor renderClicky
.
const renderClicky = (clicks, dispatch) => html`
<div>
You've clicked ${clicks} times
</div>
<button onclick=${() => dispatch("clicked")}>
Click Me
</button>
`;
All we did was change state
to clicks
in the function parameters, and state.clicks
to clicks
in the HTML body. Now we have to refactor the content
view to only pass clicks
.
const content = (state, dispatch) => html`
<div class="content">
${renderRefresh(state, dispatch)}
${renderDecorations()}
${renderClicky(state.clicks, dispatch)}
</div>
`;
Our bottom-most view, renderClicky
, now doesn’t require the entire application state! This is great! But what if we wanted a more reusable way of mapping global state to smaller bits?
Let’s introduce a new type, View
, to help us do that.
const View = render => ({
render; })
A View
object just takes a render
function and returns an object with a property render
. This is silly, you say? I agree, for now. Let’s see what our code looks like now.
const renderClicky = View((clicks, dispatch) => html`
<div>
You've clicked ${clicks} times
</div>
<button onclick=${() => dispatch("clicked")}>
Click Me
</button>
`);
const content = (state, dispatch) => html`
<div class="content">
${renderRefresh(state, dispatch)}
${renderDecorations()}
${renderClicky.render(state.clicks, dispatch)}
</div>
`;
Eew. We had to say renderClicky.render(state.clicks, dispatch)
, which is a lot of typing. Why did we do this? Well, by introducing lazy evaluation - render
won’t be called until we call it - we give ourselves opportunity to do some trickery with the View
itself.
I’m going to define a function, withAdapter
, that does nothing for the moment. Hang with me.
const View = render => ({
withAdapter: () => View(render)
render; })
Now we can call
renderClicky.withAdapter()
.withAdapter()
.withAdapter()
infinitely and it will always return a new view with the exact same render function. Before you give up on me entirely, let’s introduce one argument to withAdapter
.
const View = render => ({
withAdapter: (adapterFn) => View((state, dispatch) =>
render(adapterFn(state), dispatch)),
render; })
What have we here? We can now supply an adapter function that runs on the input state before the view gets rendered. Can you feel the power? We can now isolate the renderClicky.render(state.clicks, dispatch)
code like so:
const content = (state, dispatch) => html`
<div class="content">
${renderRefresh(state, dispatch)}
${renderDecorations()}
${renderClicky.withAdapter(s => s.clicks).render(state, dispatch)}
</div>
Now this may seem like a lot of extra boilerplate, but let me point out one thing: our renderRefresh
view and our renderClicky
view (after .withAdapter
) now have very similar type signatures (state, dispatch) => HTML
and View((state, dispatch) => HTML)
. This is very important, trust me.
Refactoring renderRefresh
Why? Didn’t I just say it was important that our sibling views had the same type signature? Sure, but renderRefresh
still greedily takes the entire global state instead of just state.lastUpdated
, which is the only piece of information it needs.
const renderRefresh = View((lastUpdated, dispatch) => html`
<div>
Last updated at ${lastUpdated.toLocaleString()}.
</div>
<button onclick=${() => dispatch("update")}>
Click To Update
</button>
`);
const renderDecorations = View((state, dispatch) => html`
<div class="decoration">
insert decoration here
</div>
<span class="decoration"> 🦄🦄🦄🦄🦄🦄 </span>
`);
//...other views
const content = (state, dispatch) => html`
<div class="content">
${renderRefresh.withAdapter(s => s.lastUpdated).render(state, dispatch)}
${renderDecorations.render(state, dispatch)}
${renderClicky.withAdapter(s => s.clicks).render(state, dispatch)}
</div>
`;
If you don’t see how this refactor worked, take a minute and review the previous section. You’ll get it.
Note: renderDecorations
doesn’t require state
nor dispatch
. We could call renderDecorations.withAdapter(s => "HAHAHAHAHAHA")
and it wouldn’t matter. But the code runs all the same.
Now we have three sibling View
functions that all have the same signature.
Scary math
By using withAdapter
we’ve now defined what’s called a Contravariant Functor
. I’ll leave a write up of this idea for another day, but suffice it to say I believe strongly in calling things by their proper name. As developers, we google everything, so let’s take advantage of years of math in our google searches, and rename withAdapter
to contramap
.
const View = render => ({
contramap: (adapterFn) => View((state, dispatch) =>
render(adapterFn(state), dispatch)),
render; })
Renaming all instances of withAdapter
should be straightforward as an exercise.
Step 2: concat
to the rescue
With sibling View
functions all with the same type, there’s got to be some way to get rid of the .render(state, dispatch)
boilerplate, right? Let’s talk about combining things.
Semigroups
Very loosely defined, a Semigroup
is simply something of some Type that knows how to combine with another thing of the same Type. We use them all the time.
1,2,3].concat([4,5,6]); // [1,2,3,4,5,6] [
Hopefully the above code makes sense. Now, let’s use our imaginations a bit:
1 + 2; // 3
1 * 2; // 2
What if I told you those two operations were both eerily similar to concat
? In this case, the operator *
or +
acts as the .concat
method. Let’s move it to a type-based implementation to make it more clear.
const Sum = number => ({
,
numberconcat: otherSum => Sum(number + otherSum.number)
;
})
const Product = number => ({
,
numberconcat: otherProduct => Product(number * otherSum.number)
; })
We’ve just defined two semigroups; numbers form the Semigroup
Sum
over addition and the Semigroup
Product
over multiplication.
Many operators can be viewed as a Semigroup
with enough imagination.
Back to View
How can we make two View
functions concat
-able? One thing is for sure; we have to concat
before calling .render
, otherwise they’re not of the type View
anymore.
const View = render => ({
contramap: (adapterFn) => View((state, dispatch) => render(adapterFn(state), dispatch)),
concat: otherView => undefined // ????
render; })
What can we return from concat
? Semigroups
must be closed when concatting. That is, v1.concat(v2)
has to return View
, just as Sum(1).concat(Sum(2))
returns a Sum
, Sum(3)
.
One thing we didn’t cover yet is that View
secretly has type parameters. That is, we assume the inputs to two View
function that are combined accept the same input state. Concretly, the type signature for concat
within View
is concat :: View a -> View a -> View a
(if this type signature looks like the gibberish of a toddler, don’t worry! Check out Brian Lonsdorf’s excellent book or ask me for help). The type parameters must match. Armed with this knowledge, what if we just created a new View
that called both View
functions?
const View = render => ({
contramap: (adapterFn) => View((state, dispatch) => render(adapterFn(state), dispatch)),
concat: otherView => View((state, dispatch) => html`
${render(state, dispatch)}
${otherView.render(state, dispatch)}
`),
render; })
We simply create a view (waiting for state
and dispatch
, of course) that calls both views and puts them next to each other. Now we can combine views willy-nilly.
Combining views willy-nilly
const myAwesomeView = renderRefresh.contramap(s => s.lastUpdated)
.concat(renderDecorations)
.concat(renderClicky.contramap(s => s.clicks));
const content = (state, dispatch) => html`
<div class="content">
${myAwesomeView.render(state, dispatch)}
</div>
`;
Let that sink in. We have a way of combining View
functions, as long as they take the same inputs.
Reducing boilerplate
Notice that we chain a bunch of `.concat` methods. Seems like something we can improve.
Let’s pause quick to write an `empty` view.
.empty = View(() => html``); View
This is a neat little identity. This means that View.empty.concat(anythingOtherView)
is equivalent to anyOtherView
. Similarly anyOtherView.concat(View.empty)
is equivalent to anyOtherView
.
This gives us a great seed for a reduce.
const myAwesomeView = [
.contramap(s => s.lastUpdate),
renderRefresh,
renderDecorations.contramap(s => s.clicks)
renderClicky.reduce(
], current) => soFar.concat(current),
(soFar.empty
View; )
Using ramda, we can make this even more terse without sacrificing readability.
const myAwesomeView = [
.contramap(s => s.lastUpdate),
renderRefresh,
renderDecorations.contramap(s => s.clicks)
renderClicky.reduce(concat, View.empty); ]
To add a new child view, we can just insert it into the appropriate location in the array.
This post is too long
My fingers are tired, and I’m sure your eyes are, too. Please get a cup of coffee and think for a few minutes about the power contramap
and concat
give us. Then try to combine as many of our original view functions as you can, using View
and the provided concat
and contramap
methods.
When you feel like you’ve done enough, or you want to cheat, take a look at the branch in github that implements the ideas in this article.
Next time…
We’ll look at removing that pesky dispatch
argument.