> ## Documentation Index
> Fetch the complete documentation index at: https://docs.metabind.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# onChange

> Runs an action when a watched value changes.

export const PlatformStatuses = ({statuses}) => {
  const StatusBadge = ({status, label}) => {
    const styles = {
      green: {
        backgroundColor: '#dcfce7',
        color: '#166534'
      },
      orange: {
        backgroundColor: '#fed7aa',
        color: '#9a3412'
      },
      red: {
        backgroundColor: '#fecaca',
        color: '#991b1b'
      },
      gray: {
        backgroundColor: '#f3f4f6',
        color: '#4b5563'
      }
    };
    const baseStyle = {
      display: 'inline-flex',
      alignItems: 'center',
      padding: '0.125rem 0.625rem',
      borderRadius: '9999px',
      fontSize: '0.875rem',
      fontWeight: '500'
    };
    const colorStyle = styles[status] || styles.green;
    return <span style={{
      ...baseStyle,
      ...colorStyle
    }}>
        {label || status}
      </span>;
  };
  const STATUS_CONFIG = {
    supported: {
      label: "Supported",
      color: "green"
    },
    partial: {
      label: "Partial",
      color: "orange"
    },
    "not-implemented": {
      label: "Not Implemented",
      color: "gray"
    }
  };
  const renderCard = (platform, value) => {
    if (!value) return null;
    const {status, note} = typeof value === "string" ? {
      status: value
    } : value;
    const config = STATUS_CONFIG[status];
    if (!config) return null;
    const titleMap = {
      ios: "SwiftUI",
      android: "Jetpack Compose",
      web: "Web"
    };
    return <Card key={platform} title={titleMap[platform] || platform}>
          <StatusBadge status={config.color} label={config.label} />
          {note && <div style={{
      marginTop: '0.5rem',
      fontSize: '0.875rem',
      color: '#6b7280'
    }}>
              {note}
            </div>}
      </Card>;
  };
  if (statuses == null) {
    return null;
  }
  return <Columns cols="3">
      {Object.entries(statuses).map(([platform, value]) => renderCard(platform, value))}
    </Columns>;
};

export const ComposeJS = ({code, name, height}) => {
  let encodedCode = "";
  if (code) {
    try {
      encodedCode = btoa(code);
    } catch (e) {
      console.error("Failed to encode code", e);
    }
  }
  if (!encodedCode) {
    return null;
  }
  return <iframe src={`https://www.metabind.ai/embed?code=${encodeURIComponent(encodedCode)}&name=${encodeURIComponent(name ?? 'Example')}`} loading="lazy" style={{
    width: "100%",
    height: height || '350px',
    border: "1px solid #e5e7eb",
    borderRadius: "var(--rounded-2xl,1rem)",
    overflow: "hidden"
  }} title="ComposeJS Preview" />;
};

export const previewCode = `const body = () => {
    const [value, setValue] = useState(50)
    const [changes, setChanges] = useState(0)
    return VStack({ spacing: 8 }, [
        Text("Changed " + changes + " times"),
        Slider({
            value: value,
            setValue: setValue,
            range: [0, 100]
        })
            .onChange(value, () => setChanges(changes + 1))
    ])
        .padding(16)
}
`;

<ComposeJS code={previewCode} name={"onChange"} height="180px" />

```typescript theme={null}
.onChange<V>(value: V, action: (values: [V, V]) => void): Component
```

<ParamField path="value" type="V" required>
  The value to observe for changes.
</ParamField>

<ParamField path="action" type="(values: [V, V]) => void" required>
  A callback that receives a tuple of `[oldValue, newValue]` when the watched value changes.
</ParamField>

## Support

<PlatformStatuses
  statuses={{
ios: { status: "supported" },
android: { status: "partial", note: "The action receives oldValue and newValue as two separate arguments, not a tuple" },
web: { status: "supported" },
}}
/>

## Usage

### Watching a state value

```typescript theme={null}
const body = () => {
    const [name, setName] = useState("")

    return TextField({ text: name, setText: setName })
        .onChange(name, ([oldVal, newVal]) => {
            console.log("Changed from " + oldVal + " to " + newVal)
        })
}
```

### Reacting to selection changes

```typescript theme={null}
const body = () => {
    const [selected, setSelected] = useState("home")

    return VStack([
        Picker("Tab", [selected, setSelected], [
            Text("Home").tag("home"),
            Text("Profile").tag("profile")
        ])
    ]).onChange(selected, ([oldVal, newVal]) => {
        console.log("Selected: " + newVal)
    })
}
```

### Triggering side effects

```typescript theme={null}
const body = () => {
    const [count, setCount] = useState(0)

    return VStack([
        Text(String(count)),
        Button("Increment", () => setCount(count + 1))
    ]).onChange(count, ([oldVal, newVal]) => {
        if (newVal >= 10) {
            console.log("Reached limit")
        }
    })
}
```

## Notes

* The action only fires when the value actually changes, not on initial render. Use `.onAppear()` for initial setup.
* The callback receives both the old and new values as a tuple `[oldValue, newValue]`, allowing comparison logic. On iOS and Android, both values arrive as strings. On Android, the action is called as `(oldValue, newValue)`, so a handler that destructures a tuple gets the wrong values there.

## See Also

* [onAppear](/bindjs/modifiers/identity-and-lifecycle#onappear) -- runs an action on first appearance
* [onDisappear](/bindjs/modifiers/identity-and-lifecycle#ondisappear) -- runs an action on removal


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.