Nesting views

Posted on June 28, 2020 by Bradley DeJong

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 = [
    renderRefresh.contramap((s) => s.lastUpdated),
    renderClicky.contramap((s) => s.clicks),
    renderDecorations.contramap((s) => undefined),
];

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(
    ( state, dispatch ) => html`<div id="app">
    ${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(
    ( state, dispatch ) => html`<div id="app">
    ${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(
      (state, dispatch) => html`
        ${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(
    (state, dispatch) => html`<div id="app">
    ${header(state, dispatch)} ${content.render(state, dispatch)}
  </div>`
);

Let’s try using .map, above.

const wholeApp = content.map(
  renderedContent => View(
    (state, dispatch) => html`<div id="app">
      ${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(
  children => View(
    (state, dispatch) => html`<div id="app">
      ${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(
  children => html`<div id="app">
      ${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(
      (state, dispatch) => html`
        ${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(
      (state, dispatch) => html`
        ${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.