Templates

Our Approach to Rendering

One of the key features of any JavaScript framework is rendering components. The JS frameworks of the first generation (React, Angular, Vue) used to render components on the client side. In recent years it became apparent that this approach has quite a few drawbacks. While it allows to refresh parts of the view without a server request, it comes at a price:

  • Hydration: Basically, we have to redo the some of work the server already did on the client side, so that the JavaScript application knows the component structure, how they are interconnected and when to refresh what. This procedure delays the time till your application is interactive. That's why you see loading spinners and animated placeholders so often in many modern web applications. Also, the browser doesn't know how big the content will be when the page loads, that's why you might see some flashes and layout shifts.
  • Bundle size: If the client needs to render the user interface and do more complex things, the bundle size of JavaScript resources inevitably goes up as well. While it's possible to load modules and data on demand, it's often quite difficult to know what will be needed and which approach fares better - and it increases the complexity of your application. That's why most websites bundle just about everything together, delivering sometimes huge junks of JavaScript assets.
  • SEO and Accessibility: It's possible to optimize for search engines and get accessibility right with client-side rendering, but again, it's a lot harder.

Because of these reasons, almost all JavaScript frameworks now also offer at least an option for server-side rendering. But this further increases the complexity of the software architecture and, I think, is vastly over-engineered for most websites and web applications. That's why UIElement proposes a much simpler approach: We don't do rendering at all. We only update granular bits like attributes or text content of some elements - and hydrate pre-rendered templates if needed.

Instead, we focus solely on one tasks: bringing fine-grained reactivity to Web Components. We radically prioritize simplicity over features and syntactic sugar. That's why the minimized code of UIElement is less than 1kB. Once fine-grained reactivity (as in the Signals proposal) and the context request protocol become standard features of ECMAScript and Web Components, you shall be able to drop UIElement with as little effort as possible.

All of this does not mean, you cannot do client-side rendering. In the next section, we'll have a look at suxch a technique.

Template Literals

Consider this spreadsheet app:

Sum

We need to be able to dynamically add rows and columns. For this kind of task, template literals are great. The markup for the empty cells is relatively simple, contains logic, but no content. So, whenever we need the <spreadsheet-table/> component, we will also need the same markup. In this case, we can bundle the markup in the JavaScript logic.

Here's the JavaScript for the table component:

spreadsheet-table.js JS

class SpreadsheetTable extends UIElement {
  static observedAttributes = ['rows', 'columns'];

  attributeMapping = {
    rows: 'integer',
    columns: 'integer',
  };

  connectedCallback() {
    !this.has('rows') && this.set('rows', 1);
    !this.has('columns') && this.set('columns', 1);
    this.set('sums', []);

    const tableRows = () => this.querySelectorAll('tbody tr');
    const tableCols = () => this.querySelectorAll('tfoot td');

    // tagged template literal function that does nothing special; just for syntax highlighting ;-)
    const html = (strings, ...values) => String.raw({ raw: strings }, ...values);
    const colHeadings = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
    const thTemplate = colId => html`
      <th scope="col">${colId}</th>`;
    const rowTemplate = (rowId, cells) => html`
      <tr>
        <th scope="row">${rowId}</th>
        ${cells}
      </tr>`;
    const cellTemplate = (colId, cellId) => html`
      <td>
        <input-number class="input-${colId}" value="0" min="-100" max="100">
          <label for="cell-${cellId}-input" class="visually-hidden">${cellId}</label>
          <input type="number" id="cell-${cellId}-input" value="0" min="-100" max="100" aria-invalid="false">
          <p id="cell-${cellId}-error" aria-live="assertive" class="error"></p>
        </input-number>
      </td>`;
    const sumTemplate = colId => html`
      <td class="sum-${colId}"></td>`;

    // event handler for input change
    this.onchange = e => {
      const cellId = e.target.id.split('-')[1];
      const colId = cellId[0];
      const rowId = parseInt(cellId.slice(1), 10);
      this.set(`summands-${colId}`, v => v.with(rowId - 1, e.target.valueAsNumber));
    };

    // change number of table rows
    this.effect(() => {
      let currentNumberOfRows = tableRows().length;
      const newNumberOfRows = this.get('rows');
      if (newNumberOfRows > currentNumberOfRows) {
        do { // add rows
          currentNumberOfRows++;
          const cells = Array.from(tableCols()).map((cell, index) => {
            const colId = colHeadings[index];
            const cellId = colId + currentNumberOfRows;
            return cellTemplate(colId, cellId);
          }).join('');
          this.querySelector('tbody').insertAdjacentHTML('beforeend', rowTemplate(currentNumberOfRows, cells));
        } while (newNumberOfRows > currentNumberOfRows);
      } else if (newNumberOfRows < currentNumberOfRows) {
        do { // remove rows
          currentNumberOfRows--;
          this.querySelector('tbody tr:last-child').remove();
        } while (newNumberOfRows < currentNumberOfRows);
      }

      // resize summands arrays
      const rowsCount = tableRows().length;
      tableCols().forEach((col, index) => {
        const colId = colHeadings[index];
        this.set(`summands-${colId}`, v => v.concat((new Array(rowsCount)).fill(0)).slice(0, rowsCount));
      });
    });

    // change number of table columns
    this.effect(() => {
      let currentNumberOfCols = tableCols().length;
      const newNumberOfCols = this.get('columns');
      if (newNumberOfCols > currentNumberOfCols) {
        do { // add columns
          const colId = colHeadings[currentNumberOfCols];
          currentNumberOfCols++;
          this.querySelector('thead tr').insertAdjacentHTML('beforeend', thTemplate(colId));
          tableRows().forEach((row, index) => row.insertAdjacentHTML('beforeend', cellTemplate(colId, colId + (index + 1))));
          this.querySelector('tfoot tr').insertAdjacentHTML('beforeend', sumTemplate(colId));

          // calculate sum
          this.set(`summands-${colId}`, Array.from(this.querySelectorAll(`.input-${colId}`)).map(input => input.get('value')));
          this.effect(() => {
            this.querySelector(`.sum-${colId}`).textContent = this.get(`summands-${colId}`).reduce((acc, curr) => acc + curr, 0);
          });

        } while (newNumberOfCols > currentNumberOfCols);
      } else if (newNumberOfCols < currentNumberOfCols) {
        do { // remove columns
          const colId = colHeadings[currentNumberOfCols];
          currentNumberOfCols--;
          this.querySelector('thead tr th:last-child').remove();
          tableRows().forEach(row => row.querySelector('td:last-child').remove());
          this.querySelector('tfoot tr td:last-child').remove();

          // remove calculated sum state and effect
          this.delete(`summands-${colId}`);
          
        } while (newNumberOfCols < currentNumberOfCols);
      }
    });
  }

}

Light DOM Templates

TODO

Declarative Shadow DOM

TODO