Models
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:
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>
);
}
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:
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(" ");
}
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 writesonValueChange$. They are exported asStepperModels. The parent passes both, and its callback writes its own state. - Vue writes
defineModel, and the parentv-model:value. - Svelte declares the prop
$bindable(0), and the parent binds it withbind: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 (modelis 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:
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>
);
}
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:
<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}:
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>
);
}
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 atype, or of typetext,search,email,url,tel,password,date,time,datetime-local,month,weekorcolor, 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 typenumberorrange.
Each framework writes its own binding:
| Control | React | Vue | Svelte | Angular | Solid | Qwik | Astro |
|---|---|---|---|---|---|---|---|
A text field, a <textarea> | value and onChange | v-model | bind:value | [value] and (input) | value and onInput | value, read in render, and onInput$ | its initial value |
| A checkbox | checked and onChange | v-model | bind:checked | [checked] and (change) | checked and onChange | checked and onChange$ | its initial checked |
| A radio | checked={plan === "monthly"} | v-model | bind:group | [checked] and (change) | as React | as React, with onChange$ | its initial checked |
A <select> | value and onChange | v-model | bind:value | [selected] on each option, and (change) | selected on each option, and onChange | selected on each option, and onChange$ | its initial selected |
| Checkboxes into an array | toggle | v-model | bind:group | toggle | toggle | toggle | its initial checked |
A <select multiple> | value, an array | v-model | bind:value | selectedValues | selectedValues | selectedValues | its initial selected |
| A number or range, a modifier | modelText | v-model | modelText | modelText | modelText | modelText | its 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:
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_trimtrims the text the ref receives, and the field once it commits.v-model_lazywrites the ref onchange, when the field commits, rather than on everyinput. React writes it as the field loses focus, so Enter alone doesn't commit it there.v-model_numbercasts the text to a number where it reads as one, as Vue'slooseToNumberdoes, and an<input>of typenumberorrangecasts 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.
Slots
A component declares its slots with defineSlots and renders them as children, with fallback content, props for a scoped slot, a presence test and forwarding. A parent fills them with children and a slot object.
Fallthrough
A parent's class and style on a component fall through to the component's root element, merged with the root's own. defineOptions({ inheritAttrs: false }) turns it off. Every framework gets the same two attributes.