A key identifies the item
Check Understand signals, then press Reverse: that item moves and stays checked. The list’s array signal holds the items and their order; the browser checkbox itself holds its checked state. Both last only for the current preview.
createList uses each item’s stable ID to recognize its existing row after the order changes. The key identifies the item, not its position. Your Sort by title button should move those same rows into alphabetical order.
Return a new array for a new order
JavaScript sort mutates the array it receives. Copy the current array with [...current] before sorting and pass the new array through the signal setter. Keep the item objects and their IDs unchanged.
Do not use the array index as the key for a collection that moves: position 1 can refer to a different item after sorting. This exercise changes order and membership, not an existing item’s title; the next lesson shows how to make replacement item data reactive.
Your exercise
- Add a Sort by title button in the marked controls area.
- Sort a copy with a.title.localeCompare(b.title), then return it from setItems.
- Check Understand signals, reverse, then sort. Verify that the same item remains checked; the optional node check below verifies how.
The starter already works. Make the requested changes in app.tsx, then choose Run again. A successful run starts a fresh preview with your new code and its initial values. A compiler error leaves the last successful preview in place; check Diagnostics before judging the result.
What Run, Reset and refresh keep
The values you change in these previews live only in browser memory. Running again or refreshing the page discards them. Your source edits are separate: the editor keeps them while this page is open, but does not autosave them.
Share link puts the current source files in the URL; Download ZIP saves those files as a local project. Neither saves the current count, checkbox states or typed notes. Refreshing a lesson link reloads the source in its current URL and waits for Run; edits not captured in that URL are lost.
On a lesson link, the playground’s Reset restores the source originally loaded by that link and clears the preview. Choose Run to start it again. This is separate from an application button such as Reset count, which changes a value inside a running preview.
Read the complete starter files
app.tsx
import { createSignal, createList, h, mount } from '@getforma/core';
import './styles.css';
function ReadingList() {
let nextId = 4;
const [items, setItems] = createSignal([
{ id: 1, title: 'Understand signals' },
{ id: 2, title: 'Build a tiny island' },
{ id: 3, title: 'Keep the HTML useful' },
]);
return <main>
<p class="eyebrow">03 / Keyed rows</p>
<h1>A list that keeps its place.</h1>
<p>Tick a checkbox, then reverse. The same title stays checked because its existing row moves with its key. Run starts a fresh list; these checkmarks are not saved.</p>
<div class="controls">
<button onClick={() => {
const id = nextId++;
setItems(current => [...current, { id, title: 'New idea ' + id }]);
}}>Add an idea</button>
<button onClick={() => setItems(current => [...current].reverse())}>Reverse</button>
{/* Exercise: add a Sort by title button here. */}
</div>
<ul>{createList(items, item => item.id, item =>
<li data-key={item.id}>
<span>{item.title}</span>
<input type="checkbox" aria-label={'Read: ' + item.title} />
<button aria-label={'Remove: ' + item.title}
onClick={() => setItems(current => current.filter(row => row.id !== item.id))}>×</button>
</li>
)}</ul>
</main>;
}
mount(ReadingList, '#app');
styles.css
:root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #162225; background: #f2f5f1; }
* { box-sizing: border-box; }
body { margin: 0; padding: 32px; }
main { max-width: 540px; margin: 0 auto; }
h1 { font-size: clamp(28px, 7vw, 40px); line-height: 1.1; letter-spacing: -.05em; }
p { line-height: 1.6; color: #52615d; }
button, input { font: inherit; }
button { cursor: pointer; border: 1px solid #1a3530; border-radius: 8px; padding: 10px 15px; background: #173e34; color: #fff; }
button:hover { background: #24624f; }
button:focus-visible, input:focus-visible { outline: 3px solid #cb7bff; outline-offset: 3px; }
button:disabled { opacity: .5; cursor: default; }
input { min-width: 0; padding: 10px; border: 1px solid #a5b4aa; border-radius: 8px; background: white; }
label { display: grid; gap: 8px; }
output { display: inline-block; font-variant-numeric: tabular-nums; font-size: 42px; font-weight: 700; }
.eyebrow { text-transform: uppercase; letter-spacing: .15em; font-size: 11px; font-weight: 700; color: #326b58; }
.controls { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin: 24px 0; }
.card { padding: 22px; border: 1px solid #cbd8ce; border-radius: 14px; background: #fff; }
ul { list-style: none; padding: 0; display: grid; gap: 10px; }
li { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding: 14px; border: 1px solid #cbd8ce; border-radius: 10px; background: white; }
li > span:first-child { flex: 1; }
.inventory-row { display: grid; grid-template-columns: 1fr auto; }
.inventory-row label { grid-column: 1 / -1; }
@media (max-width: 420px) { body { padding: 20px; } }
Verify the behavior
| Action | Expected result |
|---|---|
| Sort the original items by title | Build a tiny island, Keep the HTML useful, Understand signals. |
| Check Understand signals, reverse, then sort | Understand signals is still checked, now in the final row. |
| Optional: inspect the row before sorting in browser DevTools, then sort | The inspected row remains in the document at its new position; its checkbox stays checked. |
These checks exercise the visible result. Running without a compiler error is only the first step.
A working solution
Sorting returns a new array, but Understand signals still has ID 1. createList moves that existing row, keeping its checkbox and checked state. Removing the item or starting a fresh preview discards that row; this is identity preservation, not saved reading progress.
Read the complete solution files
app.tsx
import { createSignal, createList, h, mount } from '@getforma/core';
import './styles.css';
function ReadingList() {
let nextId = 4;
const [items, setItems] = createSignal([
{ id: 1, title: 'Understand signals' },
{ id: 2, title: 'Build a tiny island' },
{ id: 3, title: 'Keep the HTML useful' },
]);
return <main>
<p class="eyebrow">03 / Keyed rows</p>
<h1>A list that keeps its place.</h1>
<p>Tick a checkbox, then reverse. The same title stays checked because its existing row moves with its key. Run starts a fresh list; these checkmarks are not saved.</p>
<div class="controls">
<button onClick={() => {
const id = nextId++;
setItems(current => [...current, { id, title: 'New idea ' + id }]);
}}>Add an idea</button>
<button onClick={() => setItems(current => [...current].reverse())}>Reverse</button>
<button onClick={() => setItems(current =>
[...current].sort((a, b) => a.title.localeCompare(b.title))
)}>Sort by title</button>
</div>
<ul>{createList(items, item => item.id, item =>
<li data-key={item.id}>
<span>{item.title}</span>
<input type="checkbox" aria-label={'Read: ' + item.title} />
<button aria-label={'Remove: ' + item.title}
onClick={() => setItems(current => current.filter(row => row.id !== item.id))}>×</button>
</li>
)}</ul>
</main>;
}
mount(ReadingList, '#app');
styles.css
:root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #162225; background: #f2f5f1; }
* { box-sizing: border-box; }
body { margin: 0; padding: 32px; }
main { max-width: 540px; margin: 0 auto; }
h1 { font-size: clamp(28px, 7vw, 40px); line-height: 1.1; letter-spacing: -.05em; }
p { line-height: 1.6; color: #52615d; }
button, input { font: inherit; }
button { cursor: pointer; border: 1px solid #1a3530; border-radius: 8px; padding: 10px 15px; background: #173e34; color: #fff; }
button:hover { background: #24624f; }
button:focus-visible, input:focus-visible { outline: 3px solid #cb7bff; outline-offset: 3px; }
button:disabled { opacity: .5; cursor: default; }
input { min-width: 0; padding: 10px; border: 1px solid #a5b4aa; border-radius: 8px; background: white; }
label { display: grid; gap: 8px; }
output { display: inline-block; font-variant-numeric: tabular-nums; font-size: 42px; font-weight: 700; }
.eyebrow { text-transform: uppercase; letter-spacing: .15em; font-size: 11px; font-weight: 700; color: #326b58; }
.controls { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin: 24px 0; }
.card { padding: 22px; border: 1px solid #cbd8ce; border-radius: 14px; background: #fff; }
ul { list-style: none; padding: 0; display: grid; gap: 10px; }
li { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding: 14px; border: 1px solid #cbd8ce; border-radius: 10px; background: white; }
li > span:first-child { flex: 1; }
.inventory-row { display: grid; grid-template-columns: 1fr auto; }
.inventory-row label { grid-column: 1 / -1; }
@media (max-width: 420px) { body { padding: 20px; } }
Need the API details? Read the Forma 3.0 core reference. This playground compiles TSX and runs browser code. It does not run a Rust server or demonstrate server rendering; the later native lessons cover that path.