TRANSCODE Explorations into the Code Transcendental.

Peanut Butter and Chocolate Handlebars

While working on my Shomen documentation viewers, I converted templates to Handlebars and ran into a familiar argument: how much logic belongs in a view?

The appeal of “logic-less” templates is easy to understand. Markup becomes hard to follow when it also fetches data, applies business rules, and decides what the application should do. But a view still has decisions to make. Should an empty section appear? How should a heading be formatted? Which items belong together?

Removing every decision from the template can merely move the problem. The template gets shorter while a helper somewhere else starts assembling HTML strings. We haven’t separated the responsibilities very well if the view is now hiding inside the helper.

Like peanut butter and chocolate, some things are better with a little mixing. The trick is knowing which ingredients belong together.

Six sections, one display problem

A documentation page has methods in two scopes, class and instance, and three visibility levels: public, protected, and private. I want the page to group methods by those categories, give each group a heading, and omit the empty groups.

The input might look like this:

const methods = [
  { name: 'create', scope: 'class', visibility: 'public' },
  { name: 'save', scope: 'instance', visibility: 'public' },
  { name: 'reset', scope: 'instance', visibility: 'private' }
];

One awkward translation would spell out all six combinations in the template. Another would put the loops, headings, list tags, and method rendering into a helper that returns one large HTML string. Both can work. Neither seems particularly satisfying.

It would be unfair to stop there and declare the template engine broken. There is a better alternative: give the template data arranged for the page it needs to render.

Prepare a view model

We can turn the method records into an ordered list of nonempty groups:

function methodGroups(methods) {
  const scopes = ['class', 'instance'];
  const visibilities = ['public', 'protected', 'private'];

  return scopes.flatMap(scope =>
    visibilities.map(visibility => ({
      title: `${visibility} ${scope} methods`.toUpperCase(),
      methods: methods.filter(method =>
        method.scope === scope && method.visibility === visibility
      )
    })).filter(group => group.methods.length > 0)
  );
}

This assumes the input already has valid method names and the scope and visibility values shown above. Validation of the documentation metadata belongs at its input boundary. Within that contract, the function preserves the order of methods in each group, orders the groups explicitly, and leaves the input records unchanged.

What comes out is still data. It has headings and lists of methods, but no h3 or ul strings. That is a view model: a representation prepared for one particular display.

Now a Handlebars template can handle the markup:

{{#each groups}}
<section class="method-group">
  <h3>{{title}}</h3>
  <ul class="reference-list">
    {{#each methods}}
    <li><code>{{name}}</code></li>
    {{/each}}
  </ul>
</section>
{{else}}
<p>No documented methods.</p>
{{/each}}

The rendering context is { groups: methodGroups(methods) }. Handlebars’ built-in each helper iterates the groups and their methods; its else block handles an empty collection. There is no need for six copied sections or a helper that writes the whole page.

The ordinary {{name}} expression also retains Handlebars’ HTML escaping. Building raw HTML in a helper would make that responsibility easier to mishandle. A label coming from data shouldn’t accidentally become markup.

The grouping function, template, and checks are available separately. The checks can run with Node; the optional --render checks additionally require Handlebars to be installed.

The logic has moved, but has it improved?

In this example, yes. The grouping function can be checked without rendering HTML. The template can be changed without editing a JavaScript string full of tags. Both now speak in terms appropriate to their job.

That doesn’t mean every small conditional deserves its own view model property or helper. If deciding whether to show a heading is clearest beside the heading, a simple if in the template may be exactly the right place. Making readers follow three files to understand one visible choice can cost more than the separation saves.

Likewise, formatting a date or adding a display label is different from calculating an invoice’s authoritative balance. The latter needs to remain consistent wherever the application uses it. Whether to show that balance in a compact row or a large heading belongs to the presentation.

Grouping and sorting can fall on either side. Grouping these methods for navigation is a display choice. Grouping transactions to determine who owes money is a domain operation. The name of the operation doesn’t settle where it belongs; its meaning in the application does.

A useful boundary

I want templates to support the decisions needed to express a view clearly: conditions, iteration, formatting, and composition. I want substantial data preparation to have a place where it can be understood and checked separately. And I want authoritative application rules to live somewhere they won’t change merely because a different page needs them.

Sometimes that produces a prepared view model and a small template. Sometimes a modest helper or an ordinary conditional is sufficient. Either way, judge the complete implementation. A clean-looking template is a poor bargain if its helper has become a second, less readable template engine.

The goal is a view whose decisions are easy to find and whose rules have a clear home. A little logic can help us get there. We don’t have to spread the peanut butter across the entire kitchen.