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.
<!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:
- Handlers call script functions.
onsubmit="add(event)"is a native handler attribute, butaddresolves to the function in the component's script.eventandthiswork as in plain HTML. - Bindings.
_value="#draft.title"binds the input's current value todraft.title.oninputsends typed text back. REALM never writes state for you. - Lists.
repeat="#books.items"renders one<li>per item, with the item available asbook. Inside<ul>,<ol>,<table>and<select>, putrepeaton the item element itself. Elsewhere you can use the<repeat-list>tag. - Keys.
key="id"lets REALM reuse and move rows when the list changes instead of rebuilding them.
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.
-
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>_data-read="#book.read"addsdata-readwhen the value istrueand removes it when it isfalse, like a native boolean attribute. The style uses it.<is-hidden when="#books.isEmpty">and<is-visible>mount and unmount their content. Unmounted content has no running bindings or listeners.- Markup only holds references, never expressions. "How many are unread" is a getter in the script, not a calculation in the markup.
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.
-
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>definePropsdeclares typed attributes.<slot name="@heading">shows a prop.@means "prop",#means "script value"._variant="#filter.variant"passes a value to another component's prop._aria-pressedreceives"true"or"false", as ARIA expects.:host(:state(state-books-isEmpty))matches whilebooks.isEmptyis truthy. REALM sets these custom states for every:state(state-…)and:state(attr-…)selector it finds in the component's styles. See Styling.<repeat-list>repeats any content where a list element is not needed.
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.
-
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 |