Tutorial: a reading list

In this tutorial you build a reading list: add books, mark them as read, filter them, and keep them after a reload. Each step adds one idea. The demos on this page are live, and each step uses a numbered tag name (reading-list-2, reading-list-3, …) so they can sit on one page. In your own project, call it reading-list.

You need a text editor, a browser and one HTML file. Load REALM and REALM UI from the CDN in its <head>, as in the Quick start:

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/dist/theme.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js"></script>

Then import the components at the top of the <body>:

<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/dist/ui.html"></import-element>

Step 1: markup and style

Start with a component that has no script at all. It is still a custom element with a shadow root and scoped styles.

<custom-element name="reading-list">
  <style type="realm">
    :host {
      display: block;
      width: min(calc(var(--spacing) * 120), 100%);
    }
    form {
      display: flex;
      gap: calc(var(--spacing) * 2);
    }
    form ui-input {
      flex: 1;
    }
  </style>

  <h2>Reading list</h2>
  <form>
    <ui-input name="title" aria-label="Book title" placeholder="Add a book"></ui-input>
    <ui-button type="submit">Add</ui-button>
  </form>
</custom-element>

<reading-list></reading-list>

The style only applies inside the component. ui-input and ui-button come from REALM UI.

Step 2: state and a list

Add a <script>. useState returns a reactive object. When a property changes, every slot and binding that reads it updates.

Previewindex.htmlreading-list-2.html
Add
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Tutorial: a reading list demo</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.css">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/dist/theme.css">
  <script type="module" src="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js"></script>
</head>
<body>
  <import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/button.html"></import-element>
  <import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/input.html"></import-element>

  <import-element from="./reading-list-2.html"></import-element>

  <reading-list-2></reading-list-2>
</body>
</html>
<custom-element name="reading-list-2">
  <script>
    const books = useState({ items: [] });
    const draft = useState({ title: "" });

    function add(event) {
      event.preventDefault();
      const title = draft.title.trim();
      if (!title) return;
      books.items.push({ id: crypto.randomUUID(), title, read: false });
      draft.title = "";
    }

    function updateDraft(field) {
      draft.title = field.value;
    }
  </script>

  <style>
    :host {
      display: block;
      width: min(calc(var(--spacing) * 120), 100%);
    }
    form {
      display: flex;
      gap: calc(var(--spacing) * 2);
    }
    form ui-input {
      flex: 1;
    }
  </style>

  <form onsubmit="add(event)">
    <ui-input name="title" aria-label="Book title" placeholder="Add a book" _value="#draft.title" oninput="updateDraft(this)"></ui-input>
    <ui-button type="submit">Add</ui-button>
  </form>

  <ul>
    <li repeat="#books.items" as="book" key="id"><slot name="#book.title"></slot></li>
  </ul>
</custom-element>

Four things are new:

books.items.push(...) is enough to update the list. Array methods such as push, splice and sort are tracked.

Step 3: events per item, derived values and empty states

Each row gets a checkbox and a remove button. Inside a repeated element, the loop variable (book) is available in handlers. Getters on the state object are cached derived values, so summary recomputes only when the items change.

Previewindex.htmlreading-list-3.html
Add
  • Remove

Nothing here yet. Add the first book above.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Tutorial: a reading list demo</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.css">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/dist/theme.css">
  <script type="module" src="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js"></script>
</head>
<body>
  <import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/button.html"></import-element>
  <import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/checkbox.html"></import-element>
  <import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/input.html"></import-element>

  <import-element from="./reading-list-3.html"></import-element>

  <reading-list-3></reading-list-3>
</body>
</html>
<custom-element name="reading-list-3">
  <script>
    const books = useState({
      items: [
        { id: "dune", title: "Dune", read: true },
        { id: "piranesi", title: "Piranesi", read: false },
      ],
      get unread() {
        return this.items.filter((book) => !book.read).length;
      },
      get summary() {
        return `${this.unread} of ${this.items.length} unread`;
      },
      get isEmpty() {
        return this.items.length === 0;
      },
    });
    const draft = useState({ title: "" });

    function add(event) {
      event.preventDefault();
      const title = draft.title.trim();
      if (!title) return;
      books.items.push({ id: crypto.randomUUID(), title, read: false });
      draft.title = "";
    }

    function updateDraft(field) {
      draft.title = field.value;
    }

    function toggle(book) {
      book.read = !book.read;
    }

    function remove(book) {
      books.items.splice(books.items.indexOf(book), 1);
    }
  </script>

  <style>
    :host {
      display: block;
      width: min(calc(var(--spacing) * 120), 100%);
    }
    form {
      display: flex;
      gap: calc(var(--spacing) * 2);
    }
    form ui-input {
      flex: 1;
    }
    ul {
      margin: calc(var(--spacing) * 4) 0 0;
      padding: 0;
      list-style: none;
    }
    li {
      display: flex;
      align-items: center;
      gap: calc(var(--spacing) * 3);
      padding: calc(var(--spacing) * 1) 0;
    }
    li span {
      flex: 1;
    }
    li[data-read] span {
      color: var(--muted-foreground);
      text-decoration: line-through;
    }
    p {
      color: var(--muted-foreground);
      font-size: var(--text-sm);
    }
  </style>

  <form onsubmit="add(event)">
    <ui-input name="title" aria-label="Book title" placeholder="Add a book" _value="#draft.title" oninput="updateDraft(this)"></ui-input>
    <ui-button type="submit">Add</ui-button>
  </form>

  <is-hidden when="#books.isEmpty">
    <ul>
      <li repeat="#books.items" as="book" key="id" _data-read="#book.read">
        <ui-checkbox aria-label="Read" _checked="#book.read" onchange="toggle(book)"></ui-checkbox>
        <span><slot name="#book.title"></slot></span>
        <ui-button variant="ghost" size="sm" onclick="remove(book)">Remove</ui-button>
      </li>
    </ul>
    <p><slot name="#books.summary"></slot></p>
  </is-hidden>

  <is-visible when="#books.isEmpty">
    <p>Nothing here yet. Add the first book above.</p>
  </is-visible>
</custom-element>

Step 4: filters, props and reactive CSS

Filters are a lookup table, so adding one is a one-line change. The heading becomes a prop, declared with defineProps, so each instance can set it as an attribute. The empty state is styled with a reactive CSS state.

Previewindex.htmlreading-list-4.html

Add
  • Remove
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Tutorial: a reading list demo</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.css">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/dist/theme.css">
  <script type="module" src="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js"></script>
</head>
<body>
  <import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/button.html"></import-element>
  <import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/checkbox.html"></import-element>
  <import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/input.html"></import-element>

  <import-element from="./reading-list-4.html"></import-element>

  <reading-list-4 heading="Books for the summer"></reading-list-4>
</body>
</html>
<custom-element name="reading-list-4">
  <script>
    const props = defineProps({ heading: prop.string().default("Reading list") });

    const FILTERS = {
      all: { label: "All", keep: () => true },
      unread: { label: "Unread", keep: (book) => !book.read },
      read: { label: "Read", keep: (book) => book.read },
    };

    const books = useState({
      items: [
        { id: "dune", title: "Dune", read: true },
        { id: "piranesi", title: "Piranesi", read: false },
        { id: "circe", title: "Circe", read: false },
      ],
      filter: "all",
      get visible() {
        return this.items.filter(FILTERS[this.filter].keep);
      },
      get filters() {
        return Object.entries(FILTERS).map(([id, filter]) => ({
          id,
          label: filter.label,
          pressed: id === this.filter,
          variant: id === this.filter ? "secondary" : "ghost",
        }));
      },
      get summary() {
        return `${this.items.filter(FILTERS.unread.keep).length} of ${this.items.length} unread`;
      },
      get isEmpty() {
        return this.items.length === 0;
      },
    });
    const draft = useState({ title: "" });

    function add(event) {
      event.preventDefault();
      const title = draft.title.trim();
      if (!title) return;
      books.items.push({ id: crypto.randomUUID(), title, read: false });
      draft.title = "";
    }

    function updateDraft(field) {
      draft.title = field.value;
    }

    function toggle(book) {
      book.read = !book.read;
    }

    function remove(book) {
      books.items.splice(books.items.indexOf(book), 1);
    }

    function show(filter) {
      books.filter = filter.id;
    }
  </script>

  <style>
    :host {
      display: block;
      width: min(calc(var(--spacing) * 120), 100%);
    }
    h2 {
      margin: 0 0 calc(var(--spacing) * 4);
      font-size: var(--text-xl);
    }
    form,
    footer {
      display: flex;
      gap: calc(var(--spacing) * 2);
    }
    form ui-input {
      flex: 1;
    }
    ul {
      margin: calc(var(--spacing) * 4) 0;
      padding: 0;
      list-style: none;
      border: 1px solid var(--border);
      border-radius: var(--radius-lg);
    }
    li {
      display: flex;
      align-items: center;
      gap: calc(var(--spacing) * 3);
      padding: calc(var(--spacing) * 2) calc(var(--spacing) * 3);
      border-bottom: 1px solid var(--border);
    }
    li:last-child {
      border-bottom: 0;
    }
    li span {
      flex: 1;
    }
    li[data-read] span {
      color: var(--muted-foreground);
      text-decoration: line-through;
    }
    footer {
      align-items: center;
      font-size: var(--text-sm);
      color: var(--muted-foreground);
    }
    footer span {
      flex: 1;
    }
    :host(:state(state-books-isEmpty)) ul,
    :host(:state(state-books-isEmpty)) footer {
      display: none;
    }
  </style>

  <h2><slot name="@heading"></slot></h2>

  <form onsubmit="add(event)">
    <ui-input name="title" aria-label="Book title" placeholder="Add a book" _value="#draft.title" oninput="updateDraft(this)"></ui-input>
    <ui-button type="submit">Add</ui-button>
  </form>

  <ul>
    <li repeat="#books.visible" as="book" key="id" _data-read="#book.read">
      <ui-checkbox aria-label="Read" _checked="#book.read" onchange="toggle(book)"></ui-checkbox>
      <span><slot name="#book.title"></slot></span>
      <ui-button variant="ghost" size="sm" onclick="remove(book)">Remove</ui-button>
    </li>
  </ul>

  <footer>
    <span><slot name="#books.summary"></slot></span>
    <repeat-list of="#books.filters" as="filter" key="id">
      <ui-button size="sm" _variant="#filter.variant" _aria-pressed="#filter.pressed" onclick="show(filter)"><slot name="#filter.label"></slot></ui-button>
    </repeat-list>
  </footer>
</custom-element>

Step 5: keep the list after a reload

Swap useState for defineStore to share the list between every instance and save it to localStorage. Values come back with their types, so booleans stay booleans.

Previewindex.htmlreading-list-5.html
Add
  • Remove

Your saved list is empty.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Tutorial: a reading list demo</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.css">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/dist/theme.css">
  <script type="module" src="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js"></script>
</head>
<body>
  <import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/button.html"></import-element>
  <import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/checkbox.html"></import-element>
  <import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/input.html"></import-element>

  <import-element from="./reading-list-5.html"></import-element>

  <reading-list-5></reading-list-5>
</body>
</html>
<custom-element name="reading-list-5">
  <script>
    const books = defineStore(
      "tutorial-reading-list",
      {
        items: [],
        get isEmpty() {
          return this.items.length === 0;
        },
      },
      { persist: "local" },
    );
    const draft = useState({ title: "" });

    function add(event) {
      event.preventDefault();
      const title = draft.title.trim();
      if (!title) return;
      books.items.push({ id: crypto.randomUUID(), title, read: false });
      draft.title = "";
    }

    function updateDraft(field) {
      draft.title = field.value;
    }

    function toggle(book) {
      book.read = !book.read;
    }

    function remove(book) {
      books.items.splice(books.items.indexOf(book), 1);
    }
  </script>

  <style>
    :host {
      display: block;
      width: min(calc(var(--spacing) * 120), 100%);
    }
    form {
      display: flex;
      gap: calc(var(--spacing) * 2);
    }
    form ui-input {
      flex: 1;
    }
    ul {
      margin: calc(var(--spacing) * 4) 0 0;
      padding: 0;
      list-style: none;
    }
    li {
      display: flex;
      align-items: center;
      gap: calc(var(--spacing) * 3);
      padding: calc(var(--spacing) * 1) 0;
    }
    li span {
      flex: 1;
    }
    li[data-read] span {
      color: var(--muted-foreground);
      text-decoration: line-through;
    }
    p {
      color: var(--muted-foreground);
      font-size: var(--text-sm);
    }
  </style>

  <form onsubmit="add(event)">
    <ui-input name="title" aria-label="Book title" placeholder="Add a book, then reload the page" _value="#draft.title" oninput="updateDraft(this)"></ui-input>
    <ui-button type="submit">Add</ui-button>
  </form>

  <ul>
    <li repeat="#books.items" as="book" key="id" _data-read="#book.read">
      <ui-checkbox aria-label="Read" _checked="#book.read" onchange="toggle(book)"></ui-checkbox>
      <span><slot name="#book.title"></slot></span>
      <ui-button variant="ghost" size="sm" onclick="remove(book)">Remove</ui-button>
    </li>
  </ul>

  <is-visible when="#books.isEmpty">
    <p>Your saved list is empty.</p>
  </is-visible>
</custom-element>

Add a book and reload the page. It is still there. In a larger app, define the store in its own JavaScript module and import it from every component that needs it. See Stores.

Step 6: move it into a file

Put the finished definition in reading-list.html, switch type="realm" back to a plain <script> and <style>, and load it. Open the page through any static file server (your editor's live preview works), because <import-element> loads files with fetch:

<import-element from="/components/reading-list.html"></import-element>

<reading-list heading="Books for the summer"></reading-list>

What you used

Idea Where to read more
useState, getters, defineStore State and reactivity, Stores
<slot name="#…">, _attr="#…" Values and bindings
onsubmit="add(event)", loop variables in handlers Events
<is-visible>, <is-hidden> Control flow
repeat, <repeat-list>, key Lists and keys
defineProps Props
:state(state-…) Styling
<import-element> Importing components

Edit this page on GitHub · View as Markdown