LearnAdding interactivity
Updating objects and arrays
A box can hold any value: a number, a string, an object, an array. Numbers and strings can't be changed in place, only replaced, so a write is always a new value. Objects and arrays can be changed in place, and that's where it goes wrong: a box only knows about writes to .value, and a change made inside the object it already holds never goes through .value. This page shows how to update objects and arrays so the page follows, with weft's set to save the copying.
What counts as a change
A box hears about a change in one way: something assigns to .value. Then it compares the new value with the old using Object.is, and only if they differ does it tell its readers.
That works for this:
position.value = { x: 5, y: 0 } // a new object: readers updateand not for this:
position.value.x = 5 // reads the box, then changes the object it holds: nobody is toldposition.value = position.value // the same object again: Object.is says nothing changedThe second line reads position.value and then changes the object it got back. The object did change, but the box wasn't written, so nothing on the page that shows x updates. The third line writes, but writes the very same object, so the box sees no change either.
So treat any object or array in a box as read-only. To change it, make a new one and write that.
Deep diveWhy weft doesn't watch inside objects
Some libraries wrap your objects in a proxy that notices obj.x = 5. weft doesn't, on purpose. A box compares with one Object.is, which is as cheap as a check can be, and a whole set of features rests on "a new value means a change":
- A keyed list keeps a row when its item is the same object, and updates it when the item is a new one.
box.get(key)re-runs a reader only when that entry is a new value.- A
derivedthat comes out the same object stops an update there. defineState's undo history keeps old values. If they were mutated in place, undo would have nothing to go back to.
All of these work because old values are never changed. That's the same reason React asks you to treat state as immutable, and the same techniques apply.
Copying objects with spread
To change one field, copy the object with the spread syntax and override that field:
person.value = { ...person.value, first: 'Barbara' }{ ...person.value } copies every field of the old object into a new one, and first: 'Barbara', written after it, wins. The box gets a new object, so everything that reads it updates.
Barbara Hepworth (bhepworth@sculpture.com)
import { state } from '@weft-js/core'import { Card, Input } from '@weft-js/warp'export default function Person() { const person = state({ first: 'Barbara', last: 'Hepworth', email: 'bhepworth@sculpture.com' }) return ( <Card title="One box, one object"> <Input label="First name" value={person.value.first} onInput={e => (person.value = { ...person.value, first: e.currentTarget.value })} /> <Input label="Last name" value={person.value.last} onInput={e => (person.value = { ...person.value, last: e.currentTarget.value })} /> {/* set hands you a copy of the object to change; weft writes the copy back as the new value. */} <Input label="Email" value={person.value.email} onInput={e => { const email = e.currentTarget.value person.set(d => { d.email = email }) }} /> <p>{person.value.first} {person.value.last} ({person.value.email})</p> </Card> )}Try this: Edit each field. The line below follows, whether the field wrote a new object or used set.
The first two fields use spread. The third uses set, covered next. Both give the box a new object.
One handler for many fields
With several fields, a handler per field gets repetitive. A computed key, [field], lets one handler update any of them:
import { state } from '@weft-js/core'import { Input } from '@weft-js/warp'type Person = { first: string; last: string; email: string }export function Form() { const person = state<Person>({ first: 'Barbara', last: 'Hepworth', email: 'bhepworth@sculpture.com' }) const update = (field: keyof Person, value: string) => (person.value = { ...person.value, [field]: value }) return ( <form> <Input label="First name" value={person.value.first} onInput={e => update('first', e.currentTarget.value)} /> <Input label="Last name" value={person.value.last} onInput={e => update('last', e.currentTarget.value)} /> <Input label="Email" value={person.value.email} onInput={e => update('email', e.currentTarget.value)} /> </form> )}set: change a copy
Spelling out the spread every time is noisy, and for arrays the spread forms can get long. Every box has a set method that does the copy for you:
person.set(draft => { draft.first = 'Barbara'})set makes a copy of the current value, hands it to your function as draft, and writes the copy back to the box when your function returns. You change the draft as if mutating were allowed, because it's a fresh object nobody else holds.
It's exactly the same as writing the spread yourself:
person.set(d => { d.first = 'Barbara' }) // these twoperson.value = { ...person.value, first: 'Barbara' } // do the same thingFor an array, the draft is a copy of the array, so the array methods that mutate are fine on it:
todos.set(d => { d.push({ id: 4, title: 'Wash up', done: false }) })todos.set(d => { d.sort((a, b) => a.title.localeCompare(b.title)) })React developers may know Immer, which tracks changes at any depth with a proxy. weft's set is deliberately smaller: a one-level copy, with no proxy and nothing to install. For the deeper levels, copy what you change.
Updating a nested object
Objects inside objects need a copy at each level you change. Here a piece of art holds an artist, and the city is inside the artist:
Blue Nana by Niki de Saint Phalle, lives in Hamburg
import { state } from '@weft-js/core'import { Card, Input } from '@weft-js/warp'export default function Artwork() { const piece = state({ title: 'Blue Nana', artist: { name: 'Niki de Saint Phalle', city: 'Hamburg' }, }) // A new artist object inside a new piece object: each level you change is copied. const setCity = (city: string) => (piece.value = { ...piece.value, artist: { ...piece.value.artist, city } }) return ( <Card title="A nested object"> <Input label="Title" value={piece.value.title} onInput={e => (piece.value = { ...piece.value, title: e.currentTarget.value })} /> <Input label="City" value={piece.value.artist.city} onInput={e => setCity(e.currentTarget.value)} /> <p><i>{piece.value.title}</i> by {piece.value.artist.name}, lives in {piece.value.artist.city}</p> </Card> )}Try this: Change the city. The piece and the artist are both copied, and the title is untouched.
piece.value = { ...piece.value, // copy the piece artist: { ...piece.value.artist, city: 'Paris' }, // with a copy of the artist, with a new city}Two spreads, because two objects change: the artist gets a new city, and the piece gets a new artist. The title isn't copied in any meaningful sense: the new piece points at the same string, which is fine, since strings can't change.
With set, the top level is copied for you, and you replace the nested object yourself:
piece.set(d => { d.artist = { ...d.artist, city: 'Paris' } })Updating arrays
Arrays work the same way: give the box a new array. JavaScript has methods for both styles, so pick the ones that return a new array:
Changes the array (avoid on .value) | Returns a new array (prefer) | |
|---|---|---|
| Adding | push, unshift | [...arr, item], [item, ...arr] |
| Removing | pop, shift, splice | filter, slice |
| Replacing | splice, arr[i] = … | map |
| Sorting | sort, reverse | copy first: [...arr].sort() |
Inside a set draft, the left column is fine: the draft is already a copy. On .value, use the right column.
The next three steps build a todo list, one operation at a time.
Adding an item
Spread the old array into a new one with the item at the end:
- Buy milk
- Eat tacos
- Brew tea
import { state } from '@weft-js/core'import { Button, Card, Input } from '@weft-js/warp'type Todo = { id: number; title: string; done: boolean }let nextId = 3export default function Todos() { const draft = state('') const todos = state<Todo[]>([ { id: 0, title: 'Buy milk', done: true }, { id: 1, title: 'Eat tacos', done: false }, { id: 2, title: 'Brew tea', done: false }, ]) const add = () => { todos.value = [...todos.value, { id: nextId++, title: draft.value, done: false }] draft.value = '' } return ( <Card title="Todos"> <Input label="New todo" value={draft.value} onInput={e => (draft.value = e.currentTarget.value)} /> <Button size="sm" disabled={!draft.value.trim()} onClick={add}>Add</Button> <ul> {todos.value.map(t => <li key={t.id}>{t.title}</li>)} </ul> </Card> )}Try this: Type a todo and press Add. The list is a new array with one more item at the end.
todos.value = [...todos.value, { id: nextId++, title: draft.value, done: false }]Put the new item first, [item, ...todos.value], to add at the start. To insert in the middle, slice around the place:
const at = 1todos.value = [...todos.value.slice(0, at), item, ...todos.value.slice(at)]Removing an item
filter makes a new array of the items that pass a test. To remove one todo, keep every todo that isn't it:
- Buy milk
- Eat tacos
- Brew tea
import { state } from '@weft-js/core'import { Button, Card, Input } from '@weft-js/warp'type Todo = { id: number; title: string; done: boolean }let nextId = 3export default function Todos() { const draft = state('') const todos = state<Todo[]>([ { id: 0, title: 'Buy milk', done: true }, { id: 1, title: 'Eat tacos', done: false }, { id: 2, title: 'Brew tea', done: false }, ]) const add = () => { todos.value = [...todos.value, { id: nextId++, title: draft.value, done: false }] draft.value = '' } const remove = (id: number) => (todos.value = todos.value.filter(t => t.id !== id)) return ( <Card title="Todos"> <Input label="New todo" value={draft.value} onInput={e => (draft.value = e.currentTarget.value)} /> <Button size="sm" disabled={!draft.value.trim()} onClick={add}>Add</Button> <ul> {todos.value.map(t => ( <li key={t.id}> {t.title}{' '} <Button size="sm" emphasis="ghost" onClick={() => remove(t.id)}>Delete</Button> </li> ))} </ul> </Card> )}Try this: Delete a todo. filter makes a new array without it.
const remove = (id: number) => (todos.value = todos.value.filter(t => t.id !== id))Changing an item
To change one item, map over the array and return a new object for the item you're changing and the same object for every other:
1 of 3 done
import { state } from '@weft-js/core'import { Button, Card, Checkbox, Input } from '@weft-js/warp'type Todo = { id: number; title: string; done: boolean }let nextId = 3export default function Todos() { const draft = state('') const todos = state<Todo[]>([ { id: 0, title: 'Buy milk', done: true }, { id: 1, title: 'Eat tacos', done: false }, { id: 2, title: 'Brew tea', done: false }, ]) const add = () => { todos.value = [...todos.value, { id: nextId++, title: draft.value, done: false }] draft.value = '' } const remove = (id: number) => (todos.value = todos.value.filter(t => t.id !== id)) // A new object for the one that changed; every other item keeps its identity. const toggle = (id: number) => (todos.value = todos.value.map(t => (t.id === id ? { ...t, done: !t.done } : t))) return ( <Card title="Todos"> <Input label="New todo" value={draft.value} onInput={e => (draft.value = e.currentTarget.value)} /> <Button size="sm" disabled={!draft.value.trim()} onClick={add}>Add</Button> <ul> {todos.value.map(t => ( <li key={t.id}> <Checkbox checked={t.done} onChange={() => toggle(t.id)}>{t.title}</Checkbox> <Button size="sm" emphasis="ghost" onClick={() => remove(t.id)}>Delete</Button> </li> ))} </ul> <p>{todos.value.filter(t => t.done).length} of {todos.value.length} done</p> </Card> )}Try this: Tick and untick todos. map makes a new array where only the changed todo is a new object.
const toggle = (id: number) => (todos.value = todos.value.map(t => (t.id === id ? { ...t, done: !t.done } : t)))Returning t unchanged for the other items matters. The list matches rows by key, and a row whose item is the same object has nothing to do. Ticking one todo updates one row.
Sorting and reversing
sort and reverse change the array they're called on. Copy first:
const sorted = [...todos.value].sort((a, b) => a.title.localeCompare(b.title))todos.value = sortedor let set copy:
todos.set(d => { d.reverse() })If you only need a sorted view and the original order should stay, don't write it back at all. Make it a derived:
const byTitle = derived(() => [...todos.value].sort((a, b) => a.title.localeCompare(b.title)))Reading one entry of a big object
When a box holds a record keyed by id, such as stock levels or cart lines, every reader of box.value[id] depends on the whole record. Change one entry and every reader re-runs. box.get(id) reads one entry instead, and re-runs only when that entry is a new value:
{stock.get(fruit)} // re-runs when stock[fruit] changes{stock.value[fruit]} // re-runs when anything in stock changesThe updates are the same as on this page: stock.set(d => { d[fruit] = (d[fruit] ?? 0) + 1 }). Each entry is compared with Object.is, so this is another place where replacing beats mutating. Keyed state and entries covers get in full, with the W017 warning that suggests it.
Recap
- A box only hears writes to
.value, and compares withObject.is. Changing an object it holds is invisible. - Treat objects and arrays in boxes as read-only: make a new one and write it.
{ ...obj, field: value }copies an object with one field changed. Copy each level you change.box.set(draft => …)copies one level for you and writes the copy. Replace nested objects you change inside it.- For arrays: spread to add,
filterto remove,mapto change, copy beforesortorreverse. - When changing one item, return the same object for every other item, so only one row updates (W014).
box.get(key)reads one entry of a record, and re-runs only when that entry changes.
Try it
Challenge 1
Fix the score
This scoreboard doesn't update when you press the buttons. Find the two bugs and fix them.
<!-- example: skip deliberately broken: the challenge is to find the mutations -->
import { state } from '@weft-js/core'import { Button } from '@weft-js/warp'export function Scoreboard() { const player = state({ name: 'Taylor', score: 10 }) const plusOne = () => { player.value.score++ } const rename = () => { const p = player.value p.name = 'Ranjani' player.value = p } return ( <section> <p>{player.value.name}: {player.value.score}</p> <Button onClick={plusOne}>+1</Button> <Button emphasis="outline" onClick={rename}>Rename</Button> </section> )}Show the solution
plusOne changes the object without writing the box. rename writes the box, but with the same object it already held, so Object.is sees no change. Both need a new object:
import { state } from '@weft-js/core'import { Button } from '@weft-js/warp'export function Scoreboard() { const player = state({ name: 'Taylor', score: 10 }) const plusOne = () => { player.value = { ...player.value, score: player.value.score + 1 } } const rename = () => { player.set(d => { d.name = 'Ranjani' }) } return ( <section> <p>{player.value.name}: {player.value.score}</p> <Button onClick={plusOne}>+1</Button> <Button emphasis="outline" onClick={rename}>Rename</Button> </section> )}Challenge 2
A shopping cart
Add three things to this cart: + raises a product's count, − lowers it, and a product whose count reaches zero is removed from the cart. Don't mutate the array.
import { state } from '@weft-js/core'import { Button } from '@weft-js/warp'type Line = { id: number; name: string; count: number }export function Cart() { const lines = state<Line[]>([ { id: 0, name: 'Baklava', count: 1 }, { id: 1, name: 'Cheese', count: 5 }, { id: 2, name: 'Spaghetti', count: 2 }, ]) return ( <ul> {lines.value.map(l => ( <li key={l.id}> {l.name} ({l.count}) <Button size="sm">+</Button> <Button size="sm" emphasis="outline">−</Button> </li> ))} </ul> )}Show the solution
import { state } from '@weft-js/core'import { Button } from '@weft-js/warp'type Line = { id: number; name: string; count: number }export function Cart() { const lines = state<Line[]>([ { id: 0, name: 'Baklava', count: 1 }, { id: 1, name: 'Cheese', count: 5 }, { id: 2, name: 'Spaghetti', count: 2 }, ]) const change = (id: number, by: number) => (lines.value = lines.value .map(l => (l.id === id ? { ...l, count: l.count + by } : l)) .filter(l => l.count > 0)) return ( <ul> {lines.value.map(l => ( <li key={l.id}> {l.name} ({l.count}){' '} <Button size="sm" onClick={() => change(l.id, 1)}>+</Button>{' '} <Button size="sm" emphasis="outline" onClick={() => change(l.id, -1)}>−</Button> </li> ))} </ul> )}map makes a new object only for the line that changed, and filter drops any line at zero. Both return new arrays, and the lines you didn't touch keep their identity, so their rows don't update.
Challenge 3
Fix a nested update with set
Pressing "Finish first" changes the count at the bottom to 1, but the first checkbox stays unticked. Why, and how do you fix it?
import { state } from '@weft-js/core'import { Button, Checkbox } from '@weft-js/warp'type Task = { id: number; title: string; done: boolean }export function Tasks() { const tasks = state<Task[]>([ { id: 0, title: 'Sketch', done: false }, { id: 1, title: 'Carve', done: false }, ]) return ( <section> {tasks.value.map(t => <Checkbox key={t.id} checked={t.done}>{t.title}</Checkbox>)} <Button onClick={() => tasks.set(d => { d[0]!.done = true })}>Finish first</Button> <p>{tasks.value.filter(t => t.done).length} done</p> </section> )}Show the solution
set copies the array, but not the tasks in it. d[0]!.done = true changes the first task object itself, so the new array holds the same task. The count reads the whole array, which is new, so it updates. The row matched by key has the same item object as before, so it has nothing to do. Replace the task instead:
import { state } from '@weft-js/core'import { Button, Checkbox } from '@weft-js/warp'type Task = { id: number; title: string; done: boolean }export function Tasks() { const tasks = state<Task[]>([ { id: 0, title: 'Sketch', done: false }, { id: 1, title: 'Carve', done: false }, ]) return ( <section> {tasks.value.map(t => <Checkbox key={t.id} checked={t.done}>{t.title}</Checkbox>)} <Button onClick={() => tasks.set(d => { d[0] = { ...d[0]!, done: true } })}>Finish first</Button> <p>{tasks.value.filter(t => t.done).length} done</p> </section> )}