Skip to content
weftGitHub

LearnManaging state

Keyed state and entries

On Choosing the state structure you flattened data into an object keyed by id. That shape has a second benefit in weft: a row can read one entry of the object, and then only a change to that entry re-runs it. A list of a thousand products where you add one to the basket does one row's worth of work, not a thousand.

Stock

apple: 3

pear: 0

plum: 7

import { state } from '@weft-js/core'import { Button, Card } from '@weft-js/warp'const FRUIT = ['apple', 'pear', 'plum']export default function Stock() {  const stock = state<Record<string, number>>({ apple: 3, pear: 0, plum: 7 })  const restock = (fruit: string) => stock.set(d => { d[fruit] = (d[fruit] ?? 0) + 1 })  return (    <Card title="Stock">      {FRUIT.map(f => (        <p key={f}>          {f}: {stock.get(f)}{' '}          <Button size="sm" emphasis="ghost" onClick={() => restock(f)}>+1</Button>        </p>      ))}    </Card>  )}

Try this: Press +1 on a row. Only that row's number changes.

Reading one entry with get

Here's the stock list above in code:

tsx
import { state } from '@weft-js/core'const FRUIT = ['apple', 'pear', 'plum']export function Stock() {  const stock = state<Record<string, number>>({ apple: 3, pear: 0, plum: 7 })  const restock = (fruit: string) => stock.set(d => { d[fruit] = (d[fruit] ?? 0) + 1 })  return (    <ul>      {FRUIT.map(f => (        <li key={f}>          {f}: {stock.get(f)}          <button type="button" onClick={() => restock(f)}>+1</button>        </li>      ))}    </ul>  )}

There are two ways a row could read its count:

tsx
{stock.get(f)}     // re-runs when stock[f] changes{stock.value[f]}   // re-runs when anything in stock changes

Both show the same number. The difference is what they follow. stock.value reads the whole box, so every row depends on the whole object, and one write re-runs all of them. stock.get(f) reads one entry, so the row depends on that entry alone, compared with Object.is. Adding a pear re-runs the pear row and nothing else.

In a list of three, you'd never notice. In a table of five hundred live prices, it's the difference between one DOM write and five hundred re-runs that each find nothing to change. The compiler warns when a list row reads one entry of a whole object (stock.value[f], or cart[p.id] from a prop) as W017, and has an autofix to .get.

Writing one entry with set

box.set(update) copies the value one level, hands the copy to update to change, and writes it back as a new value:

ts
stock.set(d => { d.pear = 5 })                      // one entrystock.set(d => { d.kiwi = 1 })                      // a new entrystock.set(d => { d.apple = (d.apple ?? 0) - 1 })    // from its old value

It's the same as writing stock.value = { ...stock.value, pear: 5 }, in a form that's harder to get wrong. After the write, weft compares the entries that get readers are following. Only those whose value changed re-run.

set works on arrays too: list.set(d => { d.push(item) }) copies the array, pushes, and writes the copy.

Passing the box to a row

Rows are usually components. To let a row read its own entry, pass it the box, typed State<…>, and read .get(id) in the row:

Cart: 0 items
Kettle: 0
Teapot: 0
Strainer: 0
import { derived, state, type State } from '@weft-js/core'import { Button, Card, Row } from '@weft-js/warp'type Product = { id: string; name: string }const PRODUCTS: Product[] = [  { id: 'p1', name: 'Kettle' },  { id: 'p2', name: 'Teapot' },  { id: 'p3', name: 'Strainer' },]// Gets the whole box and reads its own entry, so a change to another product never re-runs this row.function Line(props: { product: Product; cart: State<Record<string, number>> }) {  const qty = derived(() => props.cart.get(props.product.id) ?? 0)  const add = () => props.cart.set(d => { d[props.product.id] = qty.value + 1 })  const remove = () => props.cart.set(d => { d[props.product.id] = Math.max(0, qty.value - 1) })  return (    <Row gap="tight" align="baseline">      <span>{props.product.name}: {qty.value}</span>      <Button size="sm" onClick={add}>+1</Button>      <Button size="sm" emphasis="ghost" disabled={qty.value === 0} onClick={remove}>−1</Button>    </Row>  )}export default function Cart() {  const cart = state<Record<string, number>>({})  const count = derived(() => Object.values(cart.value).reduce((a, b) => a + b, 0))  return (    <Card title={`Cart: ${count.value} items`}>      {PRODUCTS.map(p => <Line key={p.id} product={p} cart={cart} />)}    </Card>  )}

Try this: Add a teapot. Only the teapot row and the title change; the other rows do not re-run.

tsx
import { derived, state, type State } from '@weft-js/core'type Product = { id: string; name: string }function Line(props: { product: Product; cart: State<Record<string, number>> }) {  const qty = derived(() => props.cart.get(props.product.id) ?? 0)  const add = () => props.cart.set(d => { d[props.product.id] = qty.value + 1 })  return (    <li>      {props.product.name}: {qty.value}      <button type="button" onClick={add}>+1</button>    </li>  )}export function Cart(props: { products: Product[] }) {  const cart = state<Record<string, number>>({})  const count = derived(() => Object.values(cart.value).reduce((a, b) => a + b, 0))  return (    <section>      <h2>{count.value} items</h2>      <ul>{props.products.map(p => <Line key={p.id} product={p} cart={cart} />)}</ul>    </section>  )}

If you passed qty={cart.value[p.id]} instead, the parent's list would read the whole cart for every row, and a change to one product would re-run them all (W017 again). Passing the box is how the row gets the box's per-entry reads. This is one of the two reasons to pass a box down rather than a value: the other is to let the child write it.

The heading reads cart.value, the whole object, because a total depends on every entry. That's right: it re-runs on every change, once, and the rows don't.

Keep list items stable

The other half of a fast list is item identity. A keyed list keeps one row per key, and when the item under a key is a new object, it updates that row in place. So for an array of objects, replace only the item that changed:

ts
products.value = products.value.map(p => (p.id === id ? { ...p, price } : p))

Copying every item, products.value.map(p => ({ ...p, price })), gives each one a new identity, so every row re-runs, even the ones whose price didn't change. That's W014. The compiler catches it inside one file, and weft check follows the box through props into other files to find it there.

State lives with the row

Each row in a keyed list is a component instance made once for its key. State the row makes for itself lives with that instance:

Dinner guests
import { state } from '@weft-js/core'import { Button, Card, Input, Row } from '@weft-js/warp'type Guest = { id: number; name: string }let nextId = 4function GuestRow(props: { guest: Guest }) {  // This box belongs to this row: it moves with the row and goes when the row goes.  const note = state('')  return (    <Input      label={`${props.guest.name}: dietary note`}      value={note.value}      onInput={e => (note.value = e.currentTarget.value)}    />  )}export default function Guests() {  const guests = state<Guest[]>([    { id: 1, name: 'Ana' },    { id: 2, name: 'Ben' },    { id: 3, name: 'Chloé' },  ])  const reverse = () => (guests.value = [...guests.value].reverse())  const removeFirst = () => (guests.value = guests.value.slice(1))  const add = () => {    const id = nextId++    guests.value = [...guests.value, { id, name: `Guest ${id}` }]  }  return (    <Card title="Dinner guests">      {guests.value.map(g => <GuestRow key={g.id} guest={g} />)}      <Row gap="tight">        <Button size="sm" onClick={reverse}>Reverse</Button>        <Button size="sm" emphasis="outline" onClick={removeFirst}>Remove first</Button>        <Button size="sm" emphasis="ghost" onClick={add}>Add a guest</Button>      </Row>    </Card>  )}

Try this: Type a note for Ana, then Reverse. The note moves with Ana. Remove her, and her note is gone.

tsx
import { state } from '@weft-js/core'type Guest = { id: number; name: string }function GuestRow(props: { guest: Guest }) {  const note = state('')  return (    <label>      {props.guest.name}      <input value={note.value} onInput={e => (note.value = e.currentTarget.value)} />    </label>  )}export function Guests() {  const guests = state<Guest[]>([    { id: 1, name: 'Ana' },    { id: 2, name: 'Ben' },  ])  return (    <section>      {guests.value.map(g => <GuestRow key={g.id} guest={g} />)}      <button type="button" onClick={() => (guests.value = [...guests.value].reverse())}>Reverse</button>    </section>  )}

The rules are short:

  • A key keeps its row. Reorder the list, and each row moves, with its state and its DOM, focus and typed text included. Nothing is rebuilt.
  • A new item under the same key updates the row. Rename Ana (a new object with id: 1), and her row's props.guest.name changes in place. Her note stays.
  • A key that leaves takes its row with it. Remove Ana, and her row is disposed of, with its note. Add her back, and she gets a new row with an empty note.

That's weft's version of React's "preserving and resetting state" for lists, and the reason key matters: it says which row is which. A missing key on a list of objects is W010. Over plain strings or numbers, the value is the key.

Deep diveRow state, or an object box in the parent?

The note above is the row's own state, which is right for something only the row cares about: whether its menu is open, what's typed in it before saving.

When anything outside the row needs the value, such as a total, a save button, or keeping it when the row is removed, lift it into the parent as an object keyed by id: state<Record<number, string>>({}). Each row reads notes.get(g.id) and writes notes.set(d => { d[g.id] = text }). The row is as cheap as before, and the data now outlives it.

state.linked: an entry you can edit

Sometimes a row should start from server data and let the user edit a copy. state.linked(source) is a box that follows source until you write to it:

tsx
import { state } from '@weft-js/core'type Product = { id: string; name: string; price: number }export function PriceEditor(props: { product: Product }) {  const price = state.linked(() => props.product.price, { key: () => props.product.id })  return (    <label>      {props.product.name}      <input type="number" value={price.value} onInput={e => (price.value = e.currentTarget.valueAsNumber)} />      {price.conflict.value && <small>Changed to {price.conflict.value.theirs} while you were editing</small>}    </label>  )}

With key, an edit holds while the key stays the same, so a refetch of the product doesn't wipe what someone is typing. price.conflict.value tells you when the source moved underneath the edit. A state.linked over a query value without key is S012. The State reference has the details.

Recap

  • box.get(key) reads one entry of an object box; only changes to that entry re-run the reader.
  • box.value[key] in a row follows the whole object, so every row re-runs (W017).
  • box.set(d => { d[key] = … }) writes one entry. It copies one level: replace the entry you change.
  • Pass the box (State<T>) to a row component, and read .get(id) there.
  • Replace only the item that changed in an array (W014).
  • A row's own state lives as long as its key stays in the list, through reorders.

Try it

Challenge 1

Sell one

Add a −1 button to each row of the stock list. It should never go below zero, and should be off at zero.

Show the solution
tsx
import { state } from '@weft-js/core'const FRUIT = ['apple', 'pear', 'plum']export function Stock() {  const stock = state<Record<string, number>>({ apple: 3, pear: 0, plum: 7 })  const sell = (f: string) => stock.set(d => { d[f] = Math.max(0, (d[f] ?? 0) - 1) })  return (    <ul>      {FRUIT.map(f => (        <li key={f}>          {f}: {stock.get(f)}          <button type="button" disabled={!stock.get(f)} onClick={() => sell(f)}>−1</button>        </li>      ))}    </ul>  )}

Both the text and disabled read stock.get(f), so selling a plum touches only the plum row.

Challenge 2

Fix the slow price list

A price feed calls update many times a second. Every row re-runs on every update. Fix it.

tsx
function update(id: string, price: number) {  prices.value = prices.value.map(p => ({ ...p, price: p.id === id ? price : p.price }))}
Show the solution
tsx
import { state } from '@weft-js/core'type Price = { id: string; name: string; price: number }export function Prices() {  const prices = state<Price[]>([    { id: 'a', name: 'Oats', price: 2 },    { id: 'b', name: 'Rye', price: 3 },  ])  const update = (id: string, price: number) => {    prices.value = prices.value.map(p => (p.id === id ? { ...p, price } : p))  }  return (    <ul>      {prices.value.map(p => (        <li key={p.id}>          {p.name}: {p.price}          <button type="button" onClick={() => update(p.id, p.price + 1)}>Up</button>        </li>      ))}    </ul>  )}

Only the changed item is a new object, so only its row re-runs (W014). Every other item is the same object as before.

Challenge 3

A row component that reads its entry

The parent passes count={counts.value[t.id]} to each Topic row, so every vote re-runs every row. Change it so a vote re-runs only its own row.

Show the solution
tsx
import { state, type State } from '@weft-js/core'type Topic = { id: string; title: string }function TopicRow(props: { topic: Topic; votes: State<Record<string, number>> }) {  const vote = () => props.votes.set(d => { d[props.topic.id] = (d[props.topic.id] ?? 0) + 1 })  return (    <li>      {props.topic.title}: {props.votes.get(props.topic.id) ?? 0}      <button type="button" onClick={vote}>Vote</button>    </li>  )}export function Topics(props: { topics: Topic[] }) {  const votes = state<Record<string, number>>({})  return <ul>{props.topics.map(t => <TopicRow key={t.id} topic={t} votes={votes} />)}</ul>}

The row gets the box and reads its own entry with .get, so it follows only that entry.