How to: two-way bind a value
You want a native control’s value to stay in sync with a variable without wiring your own change handler by hand.
React has no equivalent sugar. React’s controlled-component idiom (value +
onValueChange, both explicit) is already the two-way binding — there is no
[(value)]/v-model shorthand to reach for, and none is planned.
This is Vue’s v-model sugar, and it works on switch/text-input even though
they’re bare tags now, not components: Vue’s compiler emits its own runtime
vModelText directive for any element it doesn’t recognize as a DOM input, and the
adapter’s runtime-helpers shim overrides it just enough to write each primitive’s
real value type (Switch needs a real boolean, not vModelText’s stringified
default). Slider is unrelated to this mechanism — it’s a real component, from the
separate @symbiote-native/slider wrapper package
(@symbiote-native/slider/vue):
<script setup lang="ts">import { Slider } from '@symbiote-native/slider/vue';</script>
<template> <switch v-model="enabled" /> <text-input v-model="name" /> <Slider v-model="volume" :minimum-value="0" :maximum-value="1" /></template>Bare v-model="x" binds to onUpdate:modelValue; named v-model:value="x" binds to
onUpdate:value. Each primitive’s behavior resolves whichever arrived and reports
both, so :value/@value-change and v-model are interchangeable, never a choice
you have to commit to upfront.
This is Angular’s banana-in-a-box [(value)] two-way binding. switch and
text-input are element directives now (not components) but still declare a
matching value @Input() + valueChange @Output(), so [(value)] sugar
works unmodified on the tag — Slider is the one real component here, from the
separate @symbiote-native/slider/angular package:
import { SYMBIOTE_ELEMENTS } from '@symbiote-native/angular';import { Slider } from '@symbiote-native/slider/angular';
// component: imports: [SYMBIOTE_ELEMENTS, Slider]// template: `// <switch [(value)]="enabled" />// <text-input [(value)]="name" />// <Slider [(value)]="volume" [minimumValue]="0" [maximumValue]="1" />// `This is not a special case wired per-primitive — it falls out of the RN prop
name (value) already matching Angular’s @Output() naming convention
(value + valueChange), which is exactly what [(x)] expects.
switch and text-input are bare tags now, not components — and Svelte’s
$bindable() mechanism only exists inside a component’s own $props(), so
there’s nothing left to declare it on. bind:value doesn’t even compile on a
plain tag (Svelte only recognizes bind: on a handful of real DOM elements or
a component that opts in), so two-way binding on these two is explicit value +
onValueChange — Svelte’s own equivalent of React’s controlled-component idiom:
<script lang="ts"> let enabled = $state(false); let name = $state('');</script>
<switch value={enabled} onValueChange={(next) => (enabled = next)} /><text-input value={name} onValueChange={(next) => (name = next)} />Slider is different: it’s a real component from the separate
@symbiote-native/slider wrapper package
(@symbiote-native/slider/svelte) and still declares value = $bindable(), so
bind:value keeps working there:
<script lang="ts"> import { Slider } from '@symbiote-native/slider/svelte';
let volume = $state(0.5);</script>
<Slider bind:value={volume} minimumValue={0} maximumValue={1} />That caveat is specific to $bindable() and so only applies to Slider now:
supplying your own onValueChange on the same instance as bind:value silently
takes over — the component can’t tell whether an instance is bound from the
inside, so it only echoes the native-reported value back into your bound
variable when onValueChange is absent. Pass one or the other, not both, on the
same control.
Solid has no two-way syntax either: no v-model, no $bindable(). A signal’s
getter/setter pair already IS the two halves, so the controlled-component idiom is
the same flat value + onValueChange bag React uses, just fed by a signal instead
of a hook:
import { createSignal } from 'solid-js';import { Slider } from '@symbiote-native/slider/solid';
const [enabled, setEnabled] = createSignal(false);const [name, setName] = createSignal('');const [volume, setVolume] = createSignal(0.5);
<switch value={enabled()} onValueChange={setEnabled} /><text-input value={name()} onValueChange={setName} /><Slider value={volume()} onValueChange={setVolume} minimumValue={0} maximumValue={1} />setEnabled/setName/setVolume are handed straight to onValueChange: a signal
setter already takes the new value as its one argument, so there’s no wrapper to write.
Why this isn’t fragile
Section titled “Why this isn’t fragile”The controlled-write correction (native reports a change, you don’t update
value, SymbioteNative commands native back to the JS value) is shared logic in
@symbiote-native/components, not reimplemented per adapter or per binding style —
v-model/[(value)]/$bindable() are all sugar on top of the same contract
described in the Events guide and
the Vue API reference.