# Handle Changes

Report changes, let the app accept or cancel them, and choose how state updates reach the page.

[Compose Behaviors](/docs/core/compose-behaviors) used `createChange` to report each new rating. This page goes further: callbacks that accept or cancel a change, state a parent controls, commits, frequent updates and undo. These helpers are exported from `@ui-sdk/core`.

## Control the State

The mute button from [Build a Component](/docs/core/build-a-component) manages its own state. Let's control it from a parent component and use that state to change its icon and label.

We'll use `createChange`. Update `lib/mute-button.ts` with the highlighted changes:

```ts title="lib/mute-button.ts" {1,5-19,22-23}
import { createChange, definePrimitive } from "@ui-sdk/core"

const muteButton = definePrimitive({
  name: "mute-button",
  options: { muted: "boolean", onMutedChange: "callback" },
  controlled: ["muted"],
  state: (options: {
    muted?: boolean
    onMutedChange?: (muted: boolean) => void
  }) => ({
    muted: options.muted ?? false,
  }),
  sync: (options) =>
    options.muted === undefined ? {} : { muted: options.muted },
  api: (context) => {
    const change = createChange(context)

    return {
      toggle: (event?: Event) => {
        change("muted", !context.store.get().muted, { reason: "press", event })
      },
    }
  },
  slots: (slot) => ({
    root: slot({
      attrs: () => ({ type: "button" }),
      press: ({ api }, event) => api.toggle(event),
    }),
  }),
})

export { muteButton }
```

- `options` adds the value and callback. `controlled` marks `muted` as a state value that can be controlled.
- `state` sets the initial value. `sync` keeps it up to date when a new `muted` value comes in.
- `createChange` calls `onMutedChange` with the next value and waits for it to come back through `muted`. If you leave out `muted`, it updates the button's own state.

The label now describes the next action, so this version doesn't need `aria-pressed`.

### Connect the State

Now use `muted` for the button, its icon and its label:

**React**

```tsx title="pages/index.tsx"
"use client"

import * as React from "react"
import { Volume2Icon, VolumeXIcon } from "lucide-react"
import { MuteButton } from "@/components/ui/mute-button"

function Example() {
  const [muted, setMuted] = React.useState(false)

  return (
    <MuteButton muted={muted} onMutedChange={setMuted}>
      {muted ? (
        <VolumeXIcon aria-hidden="true" />
      ) : (
        <Volume2Icon aria-hidden="true" />
      )}
      {muted ? "Unmute" : "Mute"}
    </MuteButton>
  )
}

export { Example }
```

**Vue**

```vue title="pages/index.vue"
<script setup lang="ts">
import { ref } from "vue"
import { MuteButton } from "@/components/ui/mute-button"

const muted = ref(false)
</script>

<template>
  <MuteButton v-model:muted="muted">
    <svg
      aria-hidden="true"
      width="16"
      height="16"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      stroke-width="2"
      stroke-linecap="round"
      stroke-linejoin="round"
    >
      <path d="m11 5-6 4H2v6h3l6 4z" />
      <path
        :d="
          muted ? 'm16 9 6 6m0-6-6 6' : 'M16 9a5 5 0 0 1 0 6m3-9a9 9 0 0 1 0 12'
        "
      />
    </svg>
    {{ muted ? "Unmute" : "Mute" }}
  </MuteButton>
</template>
```

`v-model:muted` passes the value and listens for updates.

**Svelte**

```svelte title="pages/index.svelte"
<script lang="ts">
  import { MuteButton } from "@/components/ui/mute-button"

  let muted = $state(false)
</script>

<MuteButton bind:muted>
  <svg
    aria-hidden="true"
    width="16"
    height="16"
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    stroke-width="2"
    stroke-linecap="round"
    stroke-linejoin="round"
  >
    <path d="m11 5-6 4H2v6h3l6 4z" />
    <path
      d={muted ? "m16 9 6 6m0-6-6 6" : "M16 9a5 5 0 0 1 0 6m3-9a9 9 0 0 1 0 12"}
    />
  </svg>
  {muted ? "Unmute" : "Mute"}
</MuteButton>
```

`bind:muted` keeps the button and the parent component's state in sync.

**Solid**

```tsx title="pages/index.tsx"
import { createSignal } from "solid-js"
import { MuteButton } from "@/components/ui/mute-button"

function Example() {
  const [muted, setMuted] = createSignal(false)

  return (
    <MuteButton muted={muted()} onMutedChange={setMuted}>
      <svg
        aria-hidden="true"
        width="16"
        height="16"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
        stroke-linecap="round"
        stroke-linejoin="round"
      >
        <path d="m11 5-6 4H2v6h3l6 4z" />
        <path
          d={
            muted()
              ? "m16 9 6 6m0-6-6 6"
              : "M16 9a5 5 0 0 1 0 6m3-9a9 9 0 0 1 0 12"
          }
        />
      </svg>
      {muted() ? "Unmute" : "Mute"}
    </MuteButton>
  )
}

export { Example }
```

**Web Components**

Pass the initial value through the `muted` attribute:

```html title="pages/index.html"
<mute-button muted="false">
  <button>
    <svg
      aria-hidden="true"
      width="16"
      height="16"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      stroke-width="2"
      stroke-linecap="round"
      stroke-linejoin="round"
    >
      <path d="m11 5-6 4H2v6h3l6 4z" />
      <path data-volume d="M16 9a5 5 0 0 1 0 6m3-9a9 9 0 0 1 0 12" />
    </svg>
    <span data-label>Mute</span>
  </button>
</mute-button>
```

Then listen for `mutedchange` from your browser entry point, after the button's markup is on the page:

```ts title="main.ts"
import "@/components/ui/mute-button"

let muted = false
const button = document.querySelector("mute-button")
const icon = button?.querySelector("[data-volume]")
const label = button?.querySelector("[data-label]")

if (button && icon && label) {
  button.addEventListener("mutedchange", (event) => {
    muted = (event as CustomEvent<{ value: boolean }>).detail.value
    button.setAttribute("muted", String(muted))
    icon.setAttribute(
      "d",
      muted ? "m16 9 6 6m0-6-6 6" : "M16 9a5 5 0 0 1 0 6m3-9a9 9 0 0 1 0 12"
    )
    label.textContent = muted ? "Unmute" : "Mute"
  })
}
```

The adapter reads `muted="false"` as the boolean `false`. Each event updates the value, icon and label together.

Click to switch between Mute and Unmute. The parent component controls the value, icon and label.

Pressing **Mute** updates `muted` in the parent component. The button receives the new value, and the icon and label update with it.

## Changes

This button needs two presses to confirm. The first arms it; a timer disarms it if you don't press again.

Create `change` once in `api`, then call it from a method when an interaction happens:

```ts
api: (context) => {
  const change = createChange(context)
  return {
    arm: (event?: Event) => change("armed", true, { reason: "press", event }),
  }
},
```

Calling `arm` asks for `armed: true`. It calls `onArmedChange` first, then writes the value if the callback allows it and the app isn't controlling `armed`.

The return value tells you whether the request was accepted. It's `false` if the value was already the same or the callback canceled it. For controlled state, `true` means the request was accepted; the app still needs to pass the value back.

Use `context.store.set` for internal bookkeeping that needs no callback, such as the item with the tab stop:

```ts
context.store.set({ pending: true })
```

## What a Change Reports

Try a click, a keyboard press, and the timeout. The callback can tell them apart. Turn on **Lock** to refuse changes.

A callback gets the requested value and a `details` object. For example, the app can refuse to arm the button:

```ts
onArmedChange: (armed, details) => {
  if (armed && locked) {
    details.cancel()
  }
},
```

- `reason` says what happened, such as `"press"` or `"timeout"`.
- `event` is the DOM event behind it, when there is one.
- `source` says how it happened: `"mouse"`, `"touch"`, `"pen"`, `"keyboard"`, `"virtual"`, `"programmatic"` or `"agent"`.
- `cancel()` refuses the request; `canceled` records that decision.

A virtual click has no pointer or keyboard press behind it and may come from assistive technology. Agent calls through `@ui-sdk/core/agent` report `"agent"`. Use the source for things like analytics and focus choices, without changing which actions are available.

Call `details.cancel()` to refuse an uncontrolled change. Calling `event.preventDefault()` alone doesn't cancel it. With controlled state, the app decides by passing a new value or keeping the old one.

## Reasons

A reason describes the interaction, rather than just saying that something changed. Here, the app lets the Close button work but can refuse Escape.

Declare the reasons your callback can receive:

```ts
type NoticeOptions = {
  onOpenChange?: (
    open: boolean,
    details: ChangeDetails<"close-press" | "escape-key">
  ) => void
}
```

Use a shared reason when one fits. `ReasonVocabulary` lists them, and checking `details.reason` narrows `details.event`: `"escape-key"`, for example, gives you a `KeyboardEvent`. Development builds also warn when an event doesn't match its reason.

Custom reasons work too. A reason outside the vocabulary, such as `"validation"`, has `Event` as its event type.

`isDismissal` groups reasons such as Escape, a press outside and a swipe. Use it when the same rule applies to several ways of closing:

```ts
onOpenChange: (open, details) => {
  if (!open && isDismissal(details.reason) && hasUnsavedChanges) {
    details.cancel()
  }
},
```

## State a User Controls

The checkbox and the panel's trigger below share one value held by the app.

[Control the State](#control-the-state) shows how to connect the value, callback and `sync`. A few details matter when you make this reusable:

- Read `defaultOpen` when creating the state. Changing that option later doesn't reset the panel.
- Return only controlled values from `sync`. `{}` leaves state alone; `{ open: undefined }` overwrites it.
- Keep each key controlled or uncontrolled for the instance's lifetime. Development builds warn about switching, or passing both `open` and `defaultOpen`.
- Those checks cover `open`, `value` and `checked` by default. Use `controlled: ["expanded"]` to enable them for another key.

## Events That Aren't State

Sometimes a callback asks for an action, such as deleting a file. There's no state key named `delete` to update.

Use `dispatch` to call an option with the same details a change receives:

```ts
remove(file: string, event?: Event) {
  const canceled = dispatch(context, "onDelete", file, { reason: "delete-press", event })
  if (canceled) {
    return
  }
  context.store.set({ files: context.store.get().files.filter((name) => name !== file) })
},
```

`dispatch` returns `true` when the callback canceled the action. In this example, the app uses it to protect the readme.

## Commits

Drag the volume slider. The value changes throughout the drag, but **Saved** changes when you let go. Each arrow-key step saves immediately.

Use `createChange` for the ongoing value and `createCommit` for the end of the interaction. Both are created inside `api`:

```ts
const change = createChange(context)
const commit = createCommit(context)
```

While dragging, report each value:

```ts
change("value", value, { reason: "track-press", event })
```

On release, report the final value:

```ts
commit("value", context.store.get().value, { reason: "track-press", event })
```

Declare `onValueCommit` in the options schema and type it with `CommitDetails`. Add `commits: ["value"]` to the primitive definition so `observe` reports one committed change per interaction. A commit reports the value; it doesn't write it and can't be canceled.

The demo positions its thumb with `css`, which turns declarations into a `style` attribute and skips `undefined` values:

```ts
style: css({ left: `${state.value}%` }),
```

## Live State

Hover over the seats, then choose one. Hover changes often; the selected seat is a separate value.

List frequently updated keys in the primitive's `live` field:

```ts
live: ["hovered"],
```

Then use a slot's `live` field to write attributes directly to its element:

```ts
live: live((state, part) => ({
  "data-hovered": state.hovered === part.key ? "" : undefined,
})),
```

This updates the hover attribute without rendering the seat again. A render function that explicitly reads a live key still subscribes to it, so a label can show the hovered seat.

`selects` answers a different question: which parts need to hear about a state change? For the selected seat, each part only compares the value with its own key:

```ts
selects: selects({ selected: ({ key }) => [key] }),
```

When selection moves, the old and new seats hear about it. Array values are matched by membership; plain objects by entry. Only list keys read in these ways. A range comparison, such as filling every star up to a rating, needs updates for more than the old and new values.

## Attributes Kept Current

Typing in this filter updates each item's `hidden` attribute without rendering the items again.

Use `direct` when the attribute already belongs in `attrs`:

```ts
item: slot<{ value: string }>({
  props: { value: "string" },
  direct: direct(["hidden"]),
  attrs: (state, part) => ({
    hidden: part.key.toLowerCase().includes(state.query.toLowerCase()) ? undefined : "",
  }),
}),
```

Keeping `hidden` in `attrs` also puts it in server-rendered HTML. Use `live` for attributes driven by live state, and `direct` to keep selected attributes from `attrs` current between renders.

## Controllers

A controller lets code outside a component call its API. These buttons start and stop a countdown through one handle.

Give the handle the methods it should expose:

```ts
function createCountdownController() {
  return createController<{
    start: (seconds: number) => void
    stop: () => void
    left: () => number
  }>()
}
```

The primitive takes a `controller` option with schema kind `"value"`, and the handle drives whatever its `api` returns:

```ts
return {
  start,
  stop,
  left: () => context.store.get().left,
}
```

Create the handle once and pass it to the component's `controller` option. `controller.start(30)` forwards to the attached instance's `start` method. Before the component attaches it, a method does nothing and returns `undefined`. The handle detaches when the instance is destroyed.

The methods choose their own change reasons. For example, the countdown's `start` method calls `change("left", seconds, { reason: "imperative" })`; each timer tick uses `"timeout"`.

## Undo

Paint a few pixels, then undo and redo. The buttons and keyboard shortcuts share one undo stack.

`createUndoStack` stores snapshots of any value. Record the result after each edit:

```ts
const stack = createUndoStack(pixels)
stack.record(nextPixels)

const previous = stack.undo()
if (previous !== undefined) {
  show(previous)
}
```

`undo()` and `redo()` return the value to show, or `undefined` when there's no step left. They don't update your component's state themselves. `canUndo()` and `canRedo()` tell you when to disable the buttons. Recording a new edit after undoing replaces the redo branch.

For keyboard shortcuts, `matchesShortcut(event, "mod+z")` recognizes Command on Apple platforms and Control elsewhere. Use `"mod+shift+z"` for redo.

To record every change on a page, including an agent's, and step back through them, see [History and Undo](/docs/agents/connect-an-agent/history-and-undo).

### Undo for Typing

Typing usually feels better when a run of edits takes one undo step. Try typing, pausing, and typing again. **Fix** replaces “teh” in every step; **Clear** starts over.

```ts
const stack = createUndoStack("", { limit: 10, groupDelay: 800 })

stack.record(text, { group: "typing" })
stack.seal()
stack.map((text) => text.replaceAll("teh", "the"))
stack.reset("")
```

Edits in the same `group` join a step while the gaps stay below `groupDelay`. Call `seal()` when that run should end, such as when the caret moves. `limit` caps the undo steps, `map` rewrites every snapshot, and `reset` starts the stack over from a new value.

## How People Are Working

Tab to the save button, then try clicking it. The hint follows how focus arrived.

`sourceOf(event)` identifies a mouse, touch, pen, keyboard, virtual or programmatic event. Without an event, `sourceOf()` reports the last keyboard or pointer input. `isFocusFromKeyboard()` checks whether focus just arrived from the keyboard:

```ts
save: slot({
  press: ({ api }, event) => api.save(sourceOf(event)),
  on: { focus: (_event, { store }) => store.set({ hint: isFocusFromKeyboard() }) },
}),
```

`createChange` starts input tracking when the primitive is created, so its first change can identify the input behind it.

## Changes from Elsewhere

An image load or a timer doesn't need a pointer or key event. Shared reasons such as `"image-load"` and `"timeout"` already resolve to a programmatic source. To supply your own source resolver, pass it as the second argument:

```ts
const change = createChange(context, () => "programmatic")
```

A controlled drag has one more timing concern: its draft may need to stay visible until the app returns the committed value. `settleDraft(store, value, before, clear)` handles that handoff. Pass a getter as `value`, the value from before the commit as `before`, and a cleanup that checks it's still clearing the same draft. [Drag and Drop](/docs/core/behaviors/drag-and-drop) shows it in a reordering example.

## API Reference

### What a Change Reports

- `reason` (`string`): Why it changed, like `"escape-key"`. Example: [What a Change Reports](#what-a-change-reports).
- `event?` (`Event`): The DOM event behind it, typed by `reason`. Undefined for controllers and cascades. Example: [What a Change Reports](#what-a-change-reports).
- `source` (`ChangeSource`): How the user caused it: `"mouse"`, `"touch"`, `"pen"`, `"keyboard"`, `"virtual"` (a click with no pointer or key behind it, which may be assistive technology), `"agent"` (an AI agent's call through `@ui-sdk/core/agent`) or `"programmatic"`. For analytics and focus choices, never for behavior. Example: [What a Change Reports](#what-a-change-reports).
- `cancel` (`() => void`): Stops the change. A controlled component ignores it, since the next value comes from you. Example: [What a Change Reports](#what-a-change-reports).
- `canceled` (`boolean`): Whether `cancel()` was called. Example: [What a Change Reports](#what-a-change-reports).

### What a Commit Reports

- `reason` (`string`): What changed the value, like `"drag"` or `"step-key"`. Example: [Commits](#commits).
- `event?` (`Event`): The DOM event that ended the interaction, or the one behind the last change when the end was another kind, like a blur after a wheel. Undefined for controllers. Example: [Commits](#commits).
- `source` (`ChangeSource`): How the user caused it, like `"mouse"` or `"keyboard"`. Example: [Commits](#commits).

### Undo

- `limit?` (`number`): How many entries are kept. Example: [Undo for Typing](#undo-for-typing).
- `groupDelay?` (`number`): Milliseconds of pause after which typing starts a new entry. Example: [Undo for Typing](#undo-for-typing).

- `record` (`(state: unknown, { group, time }?: { group?: string; time?: number; }) => void`): Records the state after an edit. Edits of the same `group`, like "insert" or "delete", join the last entry while they come within `groupDelay` of each other. Without a group, the edit is its own entry. Example: [Undo](#undo).
- `seal` (`() => void`): Ends the open group, so the next edit starts an entry, like after a caret jump. Example: [Undo for Typing](#undo-for-typing).
- `undo` (`() => unknown`): Steps back, and returns the state to show, or `undefined` at the first entry. Example: [Undo](#undo).
- `redo` (`() => unknown`): Steps forward again, and returns the state to show, or `undefined` at the last entry. Example: [Undo](#undo).
- `canUndo` (`() => boolean`): Whether there's an entry to step back to. Example: [Undo](#undo).
- `canRedo` (`() => boolean`): Whether there's an entry to step forward to. Example: [Undo](#undo).
- `map` (`(update: (state: unknown) => unknown) => void`): Rewrites every entry, like a token's status changing everywhere without an entry of its own. Example: [Undo for Typing](#undo-for-typing).
- `reset` (`(state: unknown) => void`): Starts over from `state`, like after a controlled value from outside. Example: [Undo for Typing](#undo-for-typing).

## Next

That covers the Core guides. [Reference](/docs/core/reference) lists every export by the job it does, and [Behaviors](/docs/core/behaviors) has the building blocks for focus, selection, popups and more.
