triad.js

8 min read Original article ↗

Alternative text

A toolset for creating web apps

Features

  • Composable views
    • using the full power of javascript
  • Modular code
    • the javascript code can be split and organized in multiple files and folders
  • Progressive asset loading
    • loading additional files can be delayed until needed

Examples

You can find the example apps "news", "gallery" and "todo" in the /examples directory.

Screenshot of "news" app

→ Try it live

Screenshot of "gallery" app

→ Try it live

Screenshot of "todos" app

→ Try it live

The tools

The tools consist of three small helper functions, that wrap around already existing javascript functionality.

e

This is a helper function that uses the native javascript functionality of creating elements. The power lies in the fact that it accepts a child (or list of children) as an argument. This enables expressing a tree of html elements, as a tree of function calls.

withFiles

Enables programatically loading additional javascript and css files, and executes a callback only when all files have been loaded.

dispatch

A small wrapper to make it more convenient to dispatch custom events.

Every non-trivial application built with triad.js needs to re-render parts of the page in response to user interaction or data changes. The pattern for doing this is a placeholder element that can be swapped out on each render, combined with lazy file loading. makeView implements that pattern.

Creating a web app

Generating html

Here is how to generate basic html, with the e function:

e('div', e('h1', 'Hello World!'));

Results in:

<div>
  <h1>Hello World!</h1>
</div>

And further:

e('div', [
  e('h1', 'Lorem Ipsum'),
  e('p', 'Lorem ipsum dolor sit amet'),
  e('ul', [e('li', 'a'), e('li', 'b'), e('li', 'c')]),
  e(
    'div',
    e('button', 'Click me', {
      onclick: function () {
        alert('Button clicked');
      }
    })
  )
]);

Results in:

<div>
  <h1>Lorem Ipsum</h1>
  <p>Lorem ipsum dolor sit amet</p>
  <ul>
    <li>a</li>
    <li>b</li>
    <li>c</li>
  </ul>
  <div>
    <button>Click me</button>
  </div>
</div>

A complete working example, consisting of two files:

index.html

<html>
  <body>
    <div id="app"></div>
    <script src="triad.js"></script>
    <script
      src="app.js"
      onload="document.getElementById('app').appendChild(app.main().render())"
    ></script>
  </body>
</html>

app.js

(function ({ e }, app) {
  app.main = function () {
    return {
      render: function () {
        return e('div', [
          e('h1', 'Lorem Ipsum'),
          e('p', 'Lorem ipsum dolor sit amet'),
          e('ul', [e('li', 'a'), e('li', 'b'), e('li', 'c')]),
          e(
            'div',
            e('button', 'Click me', {
              onclick: function () {
                alert('Button clicked');
              }
            })
          )
        ]);
      }
    };
  };
})(triad, app);

Running the app locally

Simply type in file:/// in your web browser, and select the index.html file.

Leveraging javascript

When eis used to generate the html, all the power of abstraction and composability in javascript, can be used. It's now possible, for example, to split the view into multiple smaller parts that can be re-used, and build on top of each other:

(function ({ e }, app) {
  app.main = function () {
    var renderListItem = function (content) {
        return e('li', content);
      },
      list = e('ul', ['a', 'b', 'c'].map(renderListItem)),
      renderFooter = function (buttonLabel) {
        return e(
          'div',
          e('button', buttonLabel, {
            onclick: function () {
              alert('Button clicked');
            }
          })
        );
      },
      ingress = e('p', 'Lorem ipsum dolor sit amet');
    return {
      render: function () {
        return e('div', [
          e('h1', 'Lorem Ipsum'),
          ingress,
          list,
          renderFooter('Click me')
        ]);
      }
    };
  };
})(triad, app);

Adding interactivity

In order for the user to interact with and change the page, the view needs to be modified to enable also re-rendering:

(function ({ e }, app) {
  app.main = function () {
    var renderListItem = function (content) {
        return e('li', content);
      },
      list = e('ul', ['a', 'b', 'c'].map(renderListItem)),
      renderFooter = function (buttonLabel) {
        return e(
          'div',
          e('button', buttonLabel, {
            onclick: function () {
              alert('Button clicked');
            }
          })
        );
      },
      ingress = e('p', 'Lorem ipsum dolor sit amet'),
      el = e('div', ''),
      view = function () {
        return e('div', [
          e('h1', 'Lorem Ipsum'),
          ingress,
          list,
          renderFooter('Click me')
        ]);
      };
    return {
      render: function () {
        var newEl = view();
        el.replaceWith(newEl);
        el = newEl;
        return el;
      }
    };
  };
})(triad, app);

The variable el is a reference to the current element, allowing it to be replaced. With this, it's possible to add user interactivity that changes the page:

(function ({ e }, app) {
  app.main = function () {
    var renderListItem = function (content) {
        return e('li', content);
      },
      list = e(
        'ul',
        ['a', 'b', 'c'].map(function (item) {
          return renderListItem(item);
        })
      ),
      showList = false,
      render = function () {
        var newEl = view();
        el.replaceWith(newEl);
        el = newEl;
        return el;
      },
      renderFooter = function (buttonLabel) {
        return e(
          'div',
          e('button', buttonLabel, {
            onclick: function () {
              showList = !showList;
              render();
            }
          })
        );
      },
      ingress = e('p', 'Lorem ipsum dolor sit amet'),
      el = e('div', ''),
      view = function () {
        return e('div', [
          e('h1', 'Lorem Ipsum'),
          ingress,
          showList && list,
          renderFooter(`${showList ? 'Hide' : 'Show'} list`)
        ]);
      };
    return { render };
  };
})(triad, app);

Using multiple files

Moving the list component into a separete file list.js

(function ({ e }, app) {
  app.list = function () {
    var renderListItem = function (content) {
      return e('li', content);
    };
    return {
      render: function () {
        return e('ul', ['a', 'b', 'c'].map(renderListItem));
      }
    };
  };
})(triad, app);

Using the withFiles function to load list.js, and then render the list, in app.js:

(function ({ e, withFiles }, app) {
  app.main = function () {
    var showList = false,
      render = function () {
        var replaceElement = function () {
          var newEl = view();
          el.replaceWith(newEl);
          el = newEl;
        };
        if (showList) {
          withFiles(['list.js'], replaceElement);
        } else {
          replaceElement();
        }
        return el;
      },
      renderFooter = function (buttonLabel) {
        return e(
          'div',
          e('button', buttonLabel, {
            onclick: function () {
              showList = !showList;
              render();
            }
          })
        );
      },
      ingress = e('p', 'Lorem ipsum dolor sit amet'),
      el = e('div', ''),
      view = function () {
        return e('div', [
          e('h1', 'Lorem Ipsum'),
          ingress,
          showList && app.list().render(),
          renderFooter(`${showList ? 'Hide' : 'Show'} list`)
        ]);
      };
    return { render };
  };
})(triad, app);

Note that the file list.js is not loaded in the browser, until the user clicks the button and the list is rendered.

Creating one more file: listItem.js:

(function ({ e }, app) {
  app.listItem = function (content) {
    var view = function () {
      return e('li', content);
    };
    return {
      render: view
    };
  };
})(triad, app);

And modifying list.js to use this new file:

(function ({ e, withFiles }, app) {
  app.list = function () {
    var renderListItem = function (content) {
        return e('li', content);
      },
      el = e('ul', ''),
      view = function () {
        return e('ul', ['a', 'b', 'c'].map(renderListItem));
      },
      render = function () {
        var replaceElement = function () {
          var newEl = view();
          el.replaceWith(newEl);
          el = newEl;
        };
        withFiles(['listItem.js'], replaceElement);
        return el;
      };
    return { render };
  };
})(triad, app);

Both app.js and list.js now contain the same rendering logic. By using makeView, this duplication disappears:

(function ({ e, makeView, withFiles }, app) {
  app.main = function () {
    var showList = false,
      renderFooter = function (buttonLabel) {
        return e(
          'div',
          e('button', buttonLabel, {
            onclick: function () {
              showList = !showList;
              render();
            }
          })
        );
      },
      ingress = e('p', 'Lorem ipsum dolor sit amet'),
      view = makeView(function () {
        return e('div', [
          e('h1', 'Lorem Ipsum'),
          ingress,
          showList && app.list().render(),
          renderFooter(`${showList ? 'Hide' : 'Show'} list`)
        ]);
      }),
      render = function () {
        return view.render(showList && ['list.js']);
      };
    return { render };
  };
})(triad, app);

Using it also in list.js:

(function ({ e, makeView }, app) {
  app.list = function () {
    var renderListItem = function (content) {
        return app.listItem(content).render();
      },
      view = makeView(function () {
        return e('ul', ['a', 'b', 'c'].map(renderListItem));
      });
    return {
      render: function () {
        return view.render(['listItem.js']);
      }
    };
  };
})(triad, app);

Adding another file listItem.css

div#app {
  li.list-item {
    list-style-type: none;
    margin: 0.5em;
    padding: 0.5em;
    border: 1px solid black;
    width: 5em;
    border-radius: 0.5em;
    background: pink;
    text-align: center;
  }
}

And using it in listItem.js:

(function ({ e, makeView }, app) {
  app.listItem = function (content) {
    var view = makeView(function () {
      return e('li', content, { className: 'list-item' });
    });
    return {
      render: function () {
        return view.render(['listItem.css']);
      }
    };
  };
})(triad, app);

Communicating between components

Dispatching an event in list.js, containing as the payload, what the user has entered:

(function ({ dispatch, e, makeView }, app) {
  app.list = function () {
    var renderListItem = function (content) {
        return app.listItem(content).render();
      },
      view = makeView(function () {
        return e('div', [
          e('ul', ['a', 'b', 'c'].map(renderListItem)),
          e('input', '', {
            onchange: function (e) {
              prefix = e.target.value;
            }
          }),
          e('button', 'Add prefix', {
            onclick: function () {
              dispatch('app:listItem:addPrefix', prefix);
            }
          })
        ]);
      });
    return {
      render: function () {
        return view.render(['listItem.js']);
      }
    };
  };
})(triad, app);

And the corresponding event listener in listItem.js:

(function ({ e, makeView }, app) {
  app.listItem = function (content) {
    var prefix = '',
      view = makeView(function () {
        return e('li', prefix + content, { className: 'list-item' });
      });
    addEventListener('app:listItem:addPrefix', function (e) {
      prefix = e.payload;
      view.render();
    });
    return {
      render: function () {
        return view.render(['listItem.css']);
      }
    };
  };
})(triad, app);

Note that in the browser DOM tree, only the li elements are changed.

This concludes the demonstration of the three tools, and how they can be used to create a web app. The final version of this example can be found under examples/lorem_ipsumtry it live.

Other more elaborate examples are also available under /examples.