Warning
This post is not finalized. I’m not happy with the explanations. I fully expect to rewrite the post soon if the outline here cannot be salvaged.
What’s happening today?
We just got done using concat
to remove boilerplate for sibling views. We can now merge two View
functions with ease! This leads us to the question: what about parent-child relationships?
Nesting views
Our current state:
const children = [
.contramap((s) => s.lastUpdated),
renderRefresh.contramap((s) => s.clicks),
renderClicky.contramap((s) => undefined),
renderDecorations;
]
const refreshAndClicky = children.reduce(concat, View.empty);
const content = (state, dispatch) => html`
<div id="content" class="content">
${refreshAndClicky.render(state, dispatch)}
</div>
`;
const wholeApp = View(
, dispatch ) => html`<div id="app">
( state ${header(state, dispatch)} ${content(state, dispatch)}
</div>`
; )
Let’s refactor to make content
into a View
const content = View((state, dispatch) => html`
<div id="content" class="content">
${refreshAndClicky.render(state, dispatch)}
</div>
`);
const wholeApp = View(
, dispatch ) => html`<div id="app">
( state ${header(state, dispatch)} ${content.render(state, dispatch)}
</div>`
; )
We notice that content.render(state, dispatch)
looks similar to refreshAndClicky.render(state, dispatch)
. Can we come up with a way to pass our inputs to child components? Let’s imagine rendering the inner refreshAndClicky
view before rendering content
.
An aside
All good applications have blinking animations. It is just a fact of life. Because we want to fit in, let’s create a utility called blink
.
const blink = someElement => html`
<div style="animation: blink 300ms infinite;">
${someElement}
</div>
`;
Assuming we have the animation defined in our stylesheet somewhere, this looks great. Unfortunately, it is rather static. That is, if someElement
is backed by a View
, we have to pre-render it.
const blinkingElement = View((state, dispatch) =>
blink(content.render(state, dispatch)));
This works fine, but isn’t too composable. Let’s define a way to transform the output of our views.
const View = (render) => ({
render: render,
contramap: (adapterFn) =>
View((state, dispatch) => render(adapterFn(state), dispatch)),
concat: (otherView) =>
View(
, dispatch) => html`
(state ${render(state, dispatch)} ${otherView.render(state, dispatch)}
`
,
)map: (mapFn) => View((state, distach) => mapFn(render(state, dispatch))),
; })
In map
, we simply return a new view that, when called, will render
our view, and pass the output to the function given to us as an argument to map
.
const blinkingElementView = content
.map(renderedContent =>
html`<div style="animation: blink 300ms infinite;">
${renderedContent}
</div>`
; )
This is very similar to our original blink
use, but note it is still wrapped in a View
so we can compose with any other View
.
Back to our problem
Where we left off: we wanted to avoid manually calling .render
on content
within its parent component.
const wholeApp = View(
, dispatch) => html`<div id="app">
(state ${header(state, dispatch)} ${content.render(state, dispatch)}
</div>`
; )
Let’s try using .map
, above.
const wholeApp = content.map(
=> View(
renderedContent , dispatch) => html`<div id="app">
(state ${header(state, dispatch)} ${renderedContent}
</div>`
); )
To make things easier on our example (and the poor author of this not-well-planned article), we’ll make header
into its own View
, too. This will allow us to move it outside and concat
it with content
.
const header = View(() => html`<h1>World's best app</h1>`);
const wholeApp = header.concat(content).map(
=> View(
children , dispatch) => html`<div id="app">
(state ${children}
</div>`
); )
Now wholeApp
doesn’t require state
at all. We can drop the inner View
and use map
as defined in our aside, above.
const wholeApp = header.concat(content).map(
=> html`<div id="app">
children ${children}
</div>`
; )
Passing along state
We were lucky in our example that there was not any state requirements in wholeApp
. Imagining there were, let’s move back to the View
-based implementation and just put the entire state in a debug element.
const wholeApp = header.concat(content).map(
=>
children View((state, dispatch) => html`<div id="app">
${children}
<div class="debug">
${JSON.stringify(state)}
</div>
</div>`
); )
To render this in the impure part of our app, we would try
const rerender = (state, dispatch) =>
nanomorph(app, wholeApp.render(state, dispatch));
But this breaks! We see an empty screen. The reason is that wholeApp.render
no longer returns an HTML element - it returns a nested View
! In our map
implementation, we assumed that mapFn
returned an HTML element, but in reality it could return any type; the caller has total control. A strong type system could help this, but we’re in the wishy-washy land of JavaScript right now. We ourselves had occasion to not return an HTML element, but intstead another View
that requires the same inbound state.
To run our app successfully, we actually need to do:
const rerender = (state, dispatch) =>
nanomorph(app, wholeApp.render(state, dispatch).render(state, dispatch));
Note how the outer View
returned another View
, so we had to double-render.
Assuming our callers are responsible citizens (hopefully we can trust ourselves, at least), let’s provide a mechanism to call .render(state, dispatch).render(state, dispatch)
.
const View = (render) => ({
render: render,
contramap: (adapterFn) =>
View((state, dispatch) => render(adapterFn(state), dispatch)),
concat: (otherView) =>
View(
, dispatch) => html`
(state ${render(state, dispatch)} ${otherView.render(state, dispatch)}
`
,
)map: (mapFn) => View((state, distach) => mapFn(render(state, dispatch))),
join: () => View((state, dispatch) =>
render(state, dispatch).render(state, dispatch)
,
); })
Now we could define wholeApp
as
const wholeApp = header.concat(content).map(
=>
children View((state, dispatch) => html`<div id="app">
${children}
<div class="debug">
${JSON.stringify(state)}
</div>
</div>`
).join(); )
join
happily smashes two views down into one. Now, if we’re clever (or, in my case, if we stole ideas from someone else), we can make a utility function that will call .map(...).join()
together. We’ll call it chain
, though some languages call it bind
.
const View = (render) => ({
render: render,
contramap: (adapterFn) =>
View((state, dispatch) => render(adapterFn(state), dispatch)),
concat: (otherView) =>
View(
, dispatch) => html`
(state ${render(state, dispatch)} ${otherView.render(state, dispatch)}
`
,
)map: (mapFn) => View((state, distach) => mapFn(render(state, dispatch))),
join: () => View((state, dispatch) =>
render(state, dispatch).render(state, dispatch)
,
)chain: (otherViewFn) =>
View((state, dispatch) =>
otherViewFn(render(state, dispatch)).render(state, dispatch)
,
); })
Take some time and compare the implementations for map
, join
, and chain
. See if you can spot join
and map
hiding in the implementation of chain
.
Let’s use chain:
const wholeApp = header.concat(content).chain(
=>
children View((state, dispatch) => html`<div id="app">
${children}
<div class="debug">
${JSON.stringify(state)}
</div>
</div>`
; )
We simply changed map
to chain
, and were able to drop the call to join()
.
Wrap up?
Refactoring the application to be one giant View
is now possible. The “giant view,” however, is really just a bunch of tiny views that are trivially composable using a few functions (map
, concat
, contramap
, chain
). Compare this to the manually-stitched app I’m sure we’ve all built before. Try the refactor for yourself, then take a look at this branch in github.
Notice how we only need to call .render(state, dispatch)
one place in our entire application. Everything else can be accomplished using out composition utilities within View
.