LearnManaging state
Hover, focus and shared UI state
Hover, focus and press are usually a pile of event handlers: pointerenter, pointerleave, focusin, focusout, pointerdown, and the edge cases between them. Most of the time CSS covers what you need with :hover and :focus-visible. When it doesn't, because logic needs to know, or another part of the page should light up, or another tab or person should see it, interaction() from @weft-js/core/interaction turns them into state you read like any other box.
Right now it is idle.
import { derived } from '@weft-js/core'import { interaction } from '@weft-js/core/interaction'import { Button, Card } from '@weft-js/warp'export default function Pointer() { // No `share`: this row is private to this one button. const row = interaction() const words = derived(() => { const on = [row.hover.value && 'hovered', row.focus.value && 'focused', row.pressed.value && 'pressed'] return on.filter(Boolean).join(', ') || 'idle' }) return ( <Card title="One button, three bits"> <Button ui={row}>Press me</Button> <p>Right now it is {words.value}.</p> </Card> )}Try this: Hover the button, press Tab to focus it, then hold it down. Each bit is a box you can read.
A row of interaction state
import { interaction } from '@weft-js/core/interaction'import { Button } from '@weft-js/warp'export function Save() { const row = interaction() return ( <p> <Button ui={row}>Save</Button> {row.hover.value && <small>Saves a draft. Nothing is published.</small>} </p> )}interaction() returns a row: three boxes and a little more.
| Read | It means |
|---|---|
row.hover.value | a pointer is over it |
row.focus.value | focus is inside it |
row.pressed.value | it's being pressed |
row.remote.value | how many others have each of those, when it's shared (below) |
Hand the row to a warp component with ui={row}. The component fills the row from real events and draws from it. The components that take ui are Button, Checkbox, Switch, Radio, Toggle, Chip and Card. Passing ui to one that doesn't take it is U005: an attribute is written and nothing is drawn.
The three local values are ordinary boxes, so you can read them anywhere you'd read state: in JSX, in a derived, in a watch. You can also write them yourself, for an element warp doesn't draw:
import { interaction } from '@weft-js/core/interaction'export function Tile(props: { title: string }) { const row = interaction() return ( <div tabIndex={0} data-hover={row.hover.value || undefined} onPointerEnter={() => (row.hover.value = true)} onPointerLeave={() => (row.hover.value = false)} > {props.title} </div> )}An app that never calls interaction() ships none of it, and a warp component that's never given ui doesn't pull it in either.
One key, one row
Give interaction a share key, and every call with that key on the page gets the same row. Two components that show the same to-do, in two panes, light up together:
import { defineState, derived, state, type State } from '@weft-js/core'import { interaction } from '@weft-js/core/interaction'import { Card, Checkbox } from '@weft-js/warp'// What's ticked is data: one box each, read by both panes, and with sync: 'tabs' by every tab.const trip = defineState('demo-trip', () => ({ flights: state(false, { persist: 'local', sync: 'tabs' }), pack: state(false, { persist: 'local', sync: 'tabs' }),}))function Row(props: { id: string; text: string; done: State<boolean> }) { // Hover, focus and press are UI state: one key is one row, here, in other tabs and on other clients. const row = interaction({ share: () => `demo:${props.id}` }) const note = derived(() => { if (row.hover.value) return 'here' const { hover, pressed } = row.remote.value if (pressed) return 'pressed elsewhere' return hover ? `${hover} elsewhere` : '' }) return ( <Checkbox ui={row} hint={note.value} checked={props.done.value} onChange={() => { props.done.value = !props.done.value }}> {props.text} </Checkbox> )}export default function UiState() { return ( <div data-part="panes"> {['Ana', 'Ben'].map(who => ( <Card key={who} title={who}> <Row id="flights" text="Book flights" done={trip.flights} /> <Row id="pack" text="Pack" done={trip.pack} /> </Card> ))} </div> )}Try this: Tick a row: both panes tick. Hover a row: both panes light. Open this page in a second tab and do the same there: this tab follows, and says how many are elsewhere.
import { derived } from '@weft-js/core'import { interaction } from '@weft-js/core/interaction'import { Checkbox } from '@weft-js/warp'export function TodoRow(props: { id: string; text: string }) { const row = interaction({ share: () => `todo:${props.id}` }) const note = derived(() => (row.hover.value ? 'here' : row.remote.value.hover ? 'someone else is here' : '')) return <Checkbox ui={row} hint={note.value}>{props.text}</Checkbox>}The key is read once, when the row is made. A prop goes in as a function, as here, so it's read at the right moment; a plain string is fine when the key is fixed. To change the key, the row has to be made again, which for a list row means a new key on the row.
Build keys from stable ids. A key made from Math.random(), Date.now() or crypto.randomUUID() can never match anyone else's, which is U001.
How far it reaches
| Where | How it travels | What you do |
|---|---|---|
| The same page | the same object: one key is one row | nothing |
| Other tabs of this browser | BroadcastChannel, at most one message a frame | nothing |
| Other browsers and people | one socket per browser, through the app's realtime server | allow it, below |
Drop share and the row is private to that one call. An app without @weft-js/start shares on the page and across tabs only.
Yours and theirs never mix
What other tabs and people do arrives only in row.remote, and only as counts: how many others hover, focus or press it. Never who. Your own row.hover is only ever set by you.
That rule is what makes a shared row safe to run logic on. A watch on row.hover runs for your hover only, so it can't fire because someone else pointed at something, and it can't echo back and forth between tabs:
import { watch } from '@weft-js/core'import { interaction } from '@weft-js/core/interaction'import { Button } from '@weft-js/warp'export function Open(props: { id: string }) { const row = interaction({ share: () => `doc:${props.id}` }) // Prefetch on your own hover, never on someone else's. watch(() => row.hover.value, on => { if (on) void fetch(`/api/docs/${props.id}`) }) return <Button ui={row} emphasis="soft">Open</Button>}To draw both by hand, read both: row.hover.value || row.remote.value.hover > 0. warp does this for you: a component lit from anywhere gets data-hold, and data-remote when it's only others.
row.remote is a derived value: only the network writes it. Assigning to it is U004.
Who may see what
Sharing with other browsers is off until the server says who may see which keys. The rule goes in src/realtime.server.ts:
import { realtime } from '@weft-js/start'export default realtime({ share: ({ ctx, key }) => ctx.user !== null && key.startsWith('todo:') })The rule runs once per key, when a client joins and at each session re-check. With no share rule, the socket refuses every key (R011), and a refused key still works between tabs. weft check warns about interaction({ share }) in an app with no rule as U003, which you can leave if tabs are all you want.
Peers see three bits and an opaque connection id: no name, no pointer position, nothing typed. For names and cursors, there's @weft-js/multiplayer.
The Shared UI state reference covers the batched form of the rule for long lists, the limits, and how the browser's one socket is shared between tabs.
Other UI state, the same in every tab
Hover is momentary. Some UI state lasts: whether the sidebar is collapsed, which tab of a settings page is open, list or grid view. For those, two tools from the state chapter fit.