Fresh data through a stable row
Type a shelf note for Field notebook and press Restock +5: its stock changes from 8 to 13 while the note stays put. Stock lives in the createKeyedRows collection in browser memory; the unsaved note lives in the existing input element.
Here createList receives the collection and a row function. That function gets an item getter: () => item().stock reads the latest object for that key. rows.set replaces that object without rebuilding its row, so the stock text can change while the note input remains intact.
Keep state rules and controls aligned
Sell one must clamp the next stock value at zero. Also disable the button when stock is zero so the interface communicates that no sale is available. Both expressions read current item data; neither captures the initial stock value.
Read item() inside the click handler before changing stock, so each click uses the latest value. This exercise has no server: a new preview discards the stock and notes. The later Field Notes application adds Rust validation and file storage so a confirmed edit survives a reload.
Your exercise
- Add a Sell one button in the marked row area.
- Use rows.set with Math.max(0, current.stock - 1) so the stock never becomes negative.
- Disable Sell one when item().stock is zero using a reactive accessor.
- Write a shelf note, restock, then sell. Keep that note and its input node intact.
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 { createKeyedRows, createList, h, mount } from '@getforma/core';
import './styles.css';
function Inventory() {
const rows = createKeyedRows([
{ id: 'notebook', name: 'Field notebook', stock: 8 },
{ id: 'pencil', name: 'Graphite pencil', stock: 24 },
], item => item.id);
return <main>
<p class="eyebrow">04 / Indexed updates</p>
<h1>New data. Same row.</h1>
<p>Write a shelf note, then restock: stock rises by five while your text stays in the same input. Stock and notes last only for this preview; nothing is saved to a server.</p>
<ul>{createList(rows, item =>
<li class="inventory-row" data-key={() => item().id}>
<span>{() => item().name} · <strong>{() => item().stock}</strong> in stock</span>
<button onClick={() => {
const current = item();
rows.set(current.id, { ...current, stock: current.stock + 5 });
}}>Restock +5</button>
{/* Exercise: add Sell one, clamp at zero, and disable it at zero. */}
<label>{() => item().name + ' shelf note'}<input placeholder="Try keeping this text" /></label>
</li>
)}</ul>
</main>;
}
mount(Inventory, '#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 |
|---|---|
| Write Shelf B-2 for Field notebook and restock once | Its stock rises from 8 to 13 and the note remains Shelf B-2. |
| Sell all 13 notebooks | Stock stops at 0 and Sell one is disabled; pencil stock remains 24. |
| Restock once and sell once more | Stock is 4, Sell one is enabled, and the original note input still contains Shelf B-2. |
These checks exercise the visible result. Running without a compiler error is only the first step.
A working solution
Sell one reads the current item, creates its replacement with one less in stock, and stores it under the same key. The stock text and disabled button read that new object; the unchanged key retains the note input. Clamping at zero protects the value, while disabling the button makes the limit visible.
Read the complete solution files
app.tsx
import { createKeyedRows, createList, h, mount } from '@getforma/core';
import './styles.css';
function Inventory() {
const rows = createKeyedRows([
{ id: 'notebook', name: 'Field notebook', stock: 8 },
{ id: 'pencil', name: 'Graphite pencil', stock: 24 },
], item => item.id);
return <main>
<p class="eyebrow">04 / Indexed updates</p>
<h1>New data. Same row.</h1>
<p>Write a shelf note, then restock: stock rises by five while your text stays in the same input. Stock and notes last only for this preview; nothing is saved to a server.</p>
<ul>{createList(rows, item =>
<li class="inventory-row" data-key={() => item().id}>
<span>{() => item().name} · <strong>{() => item().stock}</strong> in stock</span>
<button onClick={() => {
const current = item();
rows.set(current.id, { ...current, stock: current.stock + 5 });
}}>Restock +5</button>
<button disabled={() => item().stock === 0} onClick={() => {
const current = item();
rows.set(current.id, {
...current,
stock: Math.max(0, current.stock - 1),
});
}}>Sell one</button>
<label>{() => item().name + ' shelf note'}<input placeholder="Try keeping this text" /></label>
</li>
)}</ul>
</main>;
}
mount(Inventory, '#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.