A No-Build, Local-First frontend framework.
  • JavaScript 99.2%
  • HTML 0.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Joel Andersson 531dcc410f Merges Codeberg's main: loading events only when a file has to load
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 15:42:15 +01:00
examples Makes the examples follow the patterns for larger applications 2026-11-01 19:04:48 +01:00
images Using a more similar sized screenshot. 2026-03-16 16:40:54 +01:00
test Announces triad:loading and triad:loaded only when a file has to load 2026-11-08 14:28:01 +01:00
LICENSE Create LICENSE 2026-01-30 10:20:33 +01:00
README.md Merges Codeberg's main: loading events only when a file has to load 2026-11-11 15:42:15 +01:00
test.html Points the links at the new pages host and Forgejo 2026-11-11 15:33:38 +01:00
test.js Adds unit tests for e, withFiles, makeView and dispatch. 2026-10-18 16:09:23 +00:00
triad.js Announces triad:loading and triad:loaded only when a file has to load 2026-11-08 14:28:01 +01:00

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.

With { defer: true } as a third argument, the files are loaded the same way, but the callback is returned instead of executed. This lets a script define a function together with the files it needs:

lib.parse = withFiles(['reader.js'], function (bytes) {
  return lib.reader(bytes).parse();
}, { defer: true });

A script that is loaded with withFiles only counts as loaded once the files of its own withFiles calls have loaded, so whoever loaded it can call lib.parse right away.

When a file fails to load, the callback is called once the other files have loaded, with an Error that names the file; otherwise it is called with no argument. The failed file leaves the page, so a later call tries it again. A script fails to load, too, when a file of its own withFiles calls does:

withFiles(['list.js'], function (err) {
  if (err) {
    return showOffline();
  }
  renderList();
});

A call with a file still to load dispatches triad:loading on window when it starts, and triad:loaded once they have all loaded, or failed to, after the callback, or with { defer: true } in place of calling it. A call with nothing to wait for dispatches neither, so re-rendering a view stays silent:

addEventListener('triad:loading', function () {
  indicator.hidden = false;
});
addEventListener('triad:loaded', function () {
  indicator.hidden = true;
});

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 app.listItem(content).render();
      },
      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

A component passes data down to its children by rendering them with it. In listItem.js, the item gets a prefix as an argument of render, and dispatches an event upwards when it is clicked:

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

list.js keeps the prefix that the user has entered, and renders the items again with it. It listens on its own element for the event from its items, and removes the item that was clicked. The items get a view of their own, itemsView, so the input and the button are left as they are:

(function ({ e, makeView }, app) {
  app.list = function () {
    var items = ['a', 'b', 'c'],
      input = '',
      prefix = '',
      renderListItem = function (content) {
        return app.listItem(content).render(prefix);
      },
      itemsView = makeView(function () {
        var el = e('ul', items.map(renderListItem));
        el.addEventListener('app:listItem:remove', function (event) {
          items = items.filter(function (item) {
            return item !== event.payload;
          });
          itemsView.render();
        });
        return el;
      }),
      view = makeView(function () {
        return e('div', [
          itemsView.render(),
          e('input', '', {
            onchange: function (e) {
              input = e.target.value;
            }
          }),
          e('button', 'Add prefix', {
            onclick: function () {
              prefix = input;
              itemsView.render();
            }
          })
        ]);
      });
    return {
      render: function () {
        return view.render(['listItem.js']);
      }
    };
  };
})(triad, app);

Note that in the browser DOM tree, only the ul element is 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_ipsum — try it live.

Other more elaborate examples are also available under /examples.

Patterns for larger applications

Stable sub-views

As an app grows, re-rendering the whole tree on every change starts to cost: every child component is created again, and the parts of the page that did not change are rebuilt anyway. Instead, give each region of the page its own makeView, created once in the component's closure, and let each event re-render only the regions it changes.

Here is an app.main for a mail app. Selecting a message changes the message list and the message pane, and selecting a folder changes the folder list and the message list, but neither changes the rest of the page:

(function ({ e, makeView }, app) {
  app.main = function () {
    var folder = 'inbox',
      selectedId = null,
      folderListView = makeView(function () {
        return app.folderList().render({ folder: folder });
      }),
      messageListView = makeView(function () {
        return app.messageList().render({ folder: folder, selectedId: selectedId });
      }),
      messageView = makeView(function () {
        return app.message().render({ selectedId: selectedId });
      }),
      view = makeView(function () {
        var el = e('div', [
          app.header().render(),
          folderListView.render(['folderList.js']),
          messageListView.render(['messageList.js']),
          messageView.render(['message.js'])
        ]);
        el.addEventListener('app:folder:select', function (event) {
          folder = event.payload;
          // The message pane still shows the selected message
          folderListView.render();
          messageListView.render();
        });
        el.addEventListener('app:message:select', function (event) {
          selectedId = event.payload;
          // The folder list does not show the selected message
          messageListView.render();
          messageView.render();
        });
        return el;
      });
    return {
      render: function () {
        return view.render(['header.js']);
      }
    };
  };
})(triad, app);

Each sub-view is created once and keeps its place in the page between renders, so the top-level view renders once and each sub-view swaps out only its own element. There is no diffing: you state what changed, because you wrote the code that changed it.

Component communication

Within a tree, data goes down as arguments to render, and events go up by bubbling from the element of the component that dispatches them, to the nearest ancestor that listens on its own element:

flowchart TD
  main["main"]
  list["messageList<br/>listens on its own element"]
  item["messageItem"]
  main -->|"render(data)"| list
  list -->|"render(data)"| item
  item -.->|"view.dispatch(name, payload)<br/>bubbles up"| list

Across trees, the event bubbles up past every ancestor of the dispatching component to window, where a component in another branch listens with its view's on function, which stops listening once the view's element has left the page:

flowchart TD
  window(("window"))
  b1[ ]:::hidden
  b2[ ]:::hidden
  main["main"]
  list["messageList"]
  item["messageItem"]
  sidebar["sidebar"]
  status["statusBar<br/>view.on(name, handler)"]
  window -.- b1
  b1 -.-|"↑ view.dispatch(name, payload)<br/>bubbles up"| b2
  b2 -.- item
  window --- main
  main -->|"render(data)"| list
  main -->|"render(data)"| sidebar
  list -->|"render(data)"| item
  sidebar -->|"render(data)"| status
  window -.->|"event"| status
  classDef hidden stroke:none,fill:none

Listening on window also lets a component deep in the tree render itself again on its own, without rendering the large subtree above it again.

Tests

The tests run in the browser — run them live.