Components

Models

defineModel declares a value a parent can bind two ways with v-model, and v-model on a form control binds its state to a ref. Each framework gets its own two-way binding, with Vue's modifiers emulated where it has none.

A model is a value that a parent and a child both change: a stepper's count, a disclosure's open state, a field's text. The child shows the value and writes it when the person acts, and the parent holds it and may write it too. A component declares a model with defineModel, and a parent binds it with v-model. The same v-model binds a form control's state to a ref.

Declaring a model

defineModel takes the model's name and its options, and gives a ref that the component reads and writes as value.value, as it would its own state:

Stepper.uf.tsx
import { defineModel } from "unframework";

export default function Stepper({ label }: { label: string }) {
  const value = defineModel<number>("value", { default: 0 });
  return (
    <div class="stepper">
      <button type="button" aria-label={`Fewer ${label}`} onClick={() => (value.value -= 1)}>
        -
      </button>
      <span>
        {label}: {value.value}
      </span>
      <button type="button" aria-label={`More ${label}`} onClick={() => (value.value += 1)}>
        +
      </button>
    </div>
  );
}
Order.uf.tsx
import { ref } from "unframework";

import Stepper from "./Stepper.uf.tsx";

export default function Order() {
  const cups = ref(2);
  return (
    <section aria-label="Order">
      <Stepper label="Cups" v-model:value={cups.value} />
      <output>Ordered: {cups.value}</output>
      <button type="button" onClick={() => (cups.value = 0)}>
        Clear
      </button>
    </section>
  );
}

A parent binds the model with v-model: and its name, v-model:value={cups.value}. The child then shows the parent's value, and a write in the child asks the parent to change it:

Stepper.tsx
import { type CSSProperties, useLayoutEffect, useRef, useState } from "react";

export interface StepperModels {
  value?: number;
  onValueChange?: (value: number) => void;
}

export interface StepperAttributes {
  className?: string;
  style?: CSSProperties;
}

export default function Stepper({
  label,
  value: valueProp,
  onValueChange,
  className,
  style,
}: { label: string } & StepperModels & StepperAttributes) {
  const [localValue, setLocalValue] = useState<{ value: number }>({ value: 0 });
  const value = valueProp === undefined ? localValue.value : valueProp;
  const valueRef = useRef(value);

  useLayoutEffect(() => {
    valueRef.current = value;
  });

  return (
    <div className={cx("stepper", className)} style={style}>
      <button
        type="button"
        aria-label={`Fewer ${label}`}
        onClick={() => {
          valueRef.current -= 1;
          setLocalValue({ value: valueRef.current });
          onValueChange?.(valueRef.current);
        }}
      >
        -
      </button>
      <span>
        {label}: {value}
      </span>
      <button
        type="button"
        aria-label={`More ${label}`}
        onClick={() => {
          valueRef.current += 1;
          setLocalValue({ value: valueRef.current });
          onValueChange?.(valueRef.current);
        }}
      >
        +
      </button>
    </div>
  );
}

/** Joins class names, and the keys of an object's truthy entries, into one `className`. */
function cx(...parts: unknown[]): string {
  const names: string[] = [];
  for (const part of parts) {
    if (typeof part === "string") {
      if (part) names.push(part);
    } else if (part && typeof part === "object") {
      for (const [key, on] of Object.entries(part)) {
        if (on) names.push(key);
      }
    }
  }
  return names.join(" ");
}
Order.tsx
import Stepper from "./Stepper";
import { type CSSProperties, useRef, useState } from "react";

export interface OrderAttributes {
  className?: string;
  style?: CSSProperties;
}

export default function Order({ className, style }: OrderAttributes) {
  const [cups, setCups] = useState(2);
  const cupsRef = useRef(cups);

  return (
    <section aria-label="Order" className={className} style={style}>
      <Stepper
        label="Cups"
        value={cups}
        onValueChange={(value) => {
          cupsRef.current = value;
          setCups(cupsRef.current);
        }}
      />
      <output>Ordered: {cups}</output>
      <button
        type="button"
        onClick={() => {
          cupsRef.current = 0;
          setCups(cupsRef.current);
        }}
      >
        Clear
      </button>
    </section>
  );
}
  • React, Solid and Qwik give a model two props: the value, value, and a callback, onValueChange, which Qwik writes onValueChange$. They are exported as StepperModels. The parent passes both, and its callback writes its own state.
  • Vue writes defineModel, and the parent v-model:value.
  • Svelte declares the prop $bindable(0), and the parent binds it with bind:value.
  • Angular declares model<number>(0), and the parent binds a signal two ways, [(value)]="this.cups".
  • Astro renders the model's value, and the parent passes its state as the prop, value={cups}. Nothing changes it there: Astro renders on the server only (model is native, and the binding is inert).

The name is a string literal, and the options, default and required, are static values, as a prop's default is (UF2028, whose fix names a nameless model "value"). Every target names the prop after the model, and React, Solid and Qwik its callback too, onValueChange.

Bound and unbound

A child doesn't need its parent to bind a model. Unbound, it keeps the value as local state, which starts at its default:

Disclosure.uf.tsx
import { defineModel } from "unframework";

export default function Disclosure({ title }: { title: string }) {
  const open = defineModel<boolean>("open", { default: false });
  return (
    <div class="disclosure">
      <button type="button" aria-expanded={open.value} onClick={() => (open.value = !open.value)}>
        {title}
      </button>
      {open.value && <p>Details of {title}</p>}
    </div>
  );
}
Panels.uf.tsx
import { ref } from "unframework";

import Disclosure from "./Disclosure.uf.tsx";

export default function Panels() {
  const shipping = ref(true);
  return (
    <section aria-label="Panels">
      <Disclosure title="Shipping" v-model:open={shipping.value} />
      <Disclosure title="Returns" />
      <output>Shipping is {shipping.value ? "open" : "closed"}</output>
      <button type="button" onClick={() => (shipping.value = !shipping.value)}>
        Toggle shipping
      </button>
    </section>
  );
}

The "Shipping" disclosure shows its parent's shipping and asks the parent to change it, so the parent's button opens and closes it too. The "Returns" disclosure, unbound, opens and closes by itself. React, Solid and Qwik tell the two apart by value === undefined: a model bound to undefined acts unbound there, so bind a model to a value that is never undefined (Semantics).

A component may declare several models, and a parent binds each one with its name:

Search.uf.tsx
<PriceRange label="Price" v-model:min={low.value} v-model:max={high.value} />

v-model without a name, on a component, is UF3042, whose fix names the model of a child that declares one. A name the child doesn't declare is UF3037, with a fix to the closest model.

Form controls

v-model on a form control binds the control's state to a ref, v-model={name.value}:

ContactForm.uf.tsx
import { ref } from "unframework";

export default function ContactForm() {
  const name = ref("Ada");
  const email = ref("");
  return (
    <form aria-label="Contact">
      <label>
        Name <input v-model={name.value} />
      </label>
      <label>
        Email <input type="email" v-model={email.value} />
      </label>
      <output>
        {name.value} at {email.value || "no address"}
      </output>
      <button type="button" onClick={() => (name.value = "Grace")}>
        Use Grace
      </button>
    </form>
  );
}
ContactForm.tsx
import { type CSSProperties, useRef, useState } from "react";

export interface ContactFormAttributes {
  className?: string;
  style?: CSSProperties;
}

export default function ContactForm({ className, style }: ContactFormAttributes) {
  const [name, setName] = useState("Ada");
  const nameRef = useRef(name);
  const [email, setEmail] = useState("");

  return (
    <form aria-label="Contact" className={className} style={style}>
      <label>
        Name{" "}
        <input
          value={name}
          onChange={(event) => {
            nameRef.current = event.target.value;
            setName(nameRef.current);
          }}
        />
      </label>
      <label>
        Email{" "}
        <input type="email" value={email} onChange={(event) => setEmail(event.target.value)} />
      </label>
      <output>
        {name} at {email || "no address"}
      </output>
      <button
        type="button"
        onClick={() => {
          nameRef.current = "Grace";
          setName(nameRef.current);
        }}
      >
        Use Grace
      </button>
    </form>
  );
}

What v-model binds depends on the control:

  • the value of an <input> without a type, or of type text, search, email, url, tel, password, date, time, datetime-local, month, week or color, which Vue binds as text;
  • the value of a <textarea> and of a <select>;
  • the checked state of a checkbox, a boolean, and of a radio, the radio's value;
  • an array of values for checkboxes that share one ref, and for a <select multiple>;
  • a number for an <input> of type number or range.

Each framework writes its own binding:

ControlReactVueSvelteAngularSolidQwikAstro
A text field, a <textarea>value and onChangev-modelbind:value[value] and (input)value and onInputvalue, read in render, and onInput$its initial value
A checkboxchecked and onChangev-modelbind:checked[checked] and (change)checked and onChangechecked and onChange$its initial checked
A radiochecked={plan === "monthly"}v-modelbind:group[checked] and (change)as Reactas React, with onChange$its initial checked
A <select>value and onChangev-modelbind:value[selected] on each option, and (change)selected on each option, and onChangeselected on each option, and onChange$its initial selected
Checkboxes into an arraytogglev-modelbind:grouptoggletoggletoggleits initial checked
A <select multiple>value, an arrayv-modelbind:valueselectedValuesselectedValuesselectedValuesits initial selected
A number or range, a modifiermodelTextv-modelmodelTextmodelTextmodelTextmodelTextits initial value

toggle, selectedValues and modelText are small inline helpers that each output declares where it needs them, as Vue's own v-model behaves (model-array and model-modifiers). React's modelText is a hook, useModelText. On Astro every v-model renders the control's initial state and does nothing more, since nothing runs in the browser (two-way-binding, for information).

The bound value is a ref's or a model's .value. Anything else is UF3042: a file, button or hidden input, a value that is no ref's, or a modifier on a control that holds no text.

Modifiers

Vue's three v-model modifiers are written with an underscore, since JSX has no . in an attribute's name:

Profile.uf.tsx
import { ref } from "unframework";

export default function Profile() {
  const name = ref("");
  const city = ref("Lisbon");
  const weight = ref(0);
  return (
    <form aria-label="Profile">
      <label>
        Name <input v-model_trim={name.value} />
      </label>
      <label>
        City <input v-model_lazy={city.value} />
      </label>
      <label>
        Weight <input v-model_number={weight.value} />
      </label>
      <output>
        [{name.value}] from {city.value}, {weight.value + 1}
      </output>
    </form>
  );
}
  • v-model_trim trims the text the ref receives, and the field once it commits.
  • v-model_lazy writes the ref on change, when the field commits, rather than on every input. React writes it as the field loses focus, so Enter alone doesn't commit it there.
  • v-model_number casts the text to a number where it reads as one, as Vue's looseToNumber does, and an <input> of type number or range casts the same way without it.

A text field updates its ref on input events on every target. While someone composes text with an input method, as for Japanese or Chinese, Vue waits for the composition to end and the other targets don't: that difference is outside the contract.

The decisions behind this page are ADR-0054 and ADR-0058.

Copyright © 2026