How t-model Works
t-model sets up a two-way connection between a state property and a form input. Internally, OWL sets the input's value from the state and listens to the appropriate event (usually input) to write changes back:
class SearchBox extends Component {
state = useState({ query: "" });
static template = xml`
<div>
<!-- t-model links input.value ↔ state.query -->
<input type="text" t-model="state.query" placeholder="Search…"/>
<p>You typed: <t t-esc="state.query"/></p>
</div>
`;
}
t-model updates state on every keystroke (the input event). Use t-model.lazy if you want updates only when the field loses focus or the user commits a change (the change event). For heavy computed operations on each keystroke, t-model.lazy can improve performance significantly.
Text, Email, Password, Number Inputs
<form>
<!-- Text -->
<input type="text" t-model="state.name" placeholder="Full name"/>
<!-- Email -->
<input type="email" t-model="state.email" placeholder="Email address"/>
<!-- Password -->
<input type="password" t-model="state.password" placeholder="Password"/>
<!-- Number — state.age will be a string by default -->
<input type="number" t-model="state.age" min="0" max="120"/>
<!-- Use .number modifier to get a real Number type -->
<input type="number" t-model.number="state.price" step="0.01"/>
<!-- Search -->
<input type="search" t-model="state.query" placeholder="Search…"/>
</form>
Checkboxes
For a single checkbox, t-model binds to a boolean state property — checked = true, unchecked = false.
<!-- Single checkbox ↔ boolean -->
<label>
<input type="checkbox" t-model="state.agree"/>
I agree to the terms
</label>
<button t-att-disabled="!state.agree">Continue</button>
<!-- Multiple checkboxes ↔ array of selected values -->
<!-- state.selectedRoles = [] -->
<label><input type="checkbox" t-model="state.selectedRoles" value="admin"/> Admin</label>
<label><input type="checkbox" t-model="state.selectedRoles" value="editor"/> Editor</label>
<label><input type="checkbox" t-model="state.selectedRoles" value="viewer"/> Viewer</label>
<!-- state.selectedRoles will be e.g. ['admin', 'editor'] -->
Radio Buttons
Radio buttons in a group all bind to the same state property. The value of the currently selected radio becomes the state value.
<!-- state.plan = 'free' | 'pro' | 'enterprise' -->
<fieldset>
<legend>Select Plan</legend>
<label>
<input type="radio" t-model="state.plan" value="free"/> Free
</label>
<label>
<input type="radio" t-model="state.plan" value="pro"/> Pro ($9/mo)
</label>
<label>
<input type="radio" t-model="state.plan" value="enterprise"/> Enterprise
</label>
</fieldset>
<p>Selected plan: <strong t-esc="state.plan"/></p>
Select Dropdowns
<!-- Single select ↔ string value -->
<select t-model="state.country">
<option value="">-- Select country --</option>
<option value="us">United States</option>
<option value="gb">United Kingdom</option>
<option value="de">Germany</option>
</select>
<!-- Dynamic options from state -->
<select t-model="state.selectedCategory">
<option value="">All Categories</option>
<option
t-foreach="state.categories"
t-as="cat"
t-key="cat.id"
t-att-value="cat.id"
t-esc="cat.name"
/>
</select>
<!-- Multiple select ↔ array -->
<!-- state.selectedTags = [] -->
<select multiple="multiple" t-model="state.selectedTags">
<option value="js">JavaScript</option>
<option value="owl">OWL JS</option>
<option value="odoo">Odoo</option>
</select>
Textarea
<!-- Textarea works exactly like text input -->
<textarea
t-model="state.description"
rows="5"
placeholder="Enter description…"
></textarea>
<!-- Show character count -->
<p><t t-esc="state.description.length"/> / 500 characters</p>
t-model Modifiers
| Modifier | Effect | When to use |
|---|---|---|
t-model.lazy | Updates state on change event (blur / commit) instead of every input | Expensive validations, large forms where live updates hurt performance |
t-model.trim | Automatically trims leading/trailing whitespace before storing | Name, email, username fields — users often accidentally add spaces |
t-model.number | Converts the string value to a JavaScript Number before storing | All numeric inputs — prevents string concatenation bugs like "5" + 3 = "53" |
<!-- .lazy: update only on blur -->
<input type="text" t-model.lazy="state.username" placeholder="Username"/>
<!-- .trim: auto-strip whitespace -->
<input type="text" t-model.trim="state.email" placeholder="Email"/>
<!-- .number: store as Number, not string -->
<input type="number" t-model.number="state.quantity" min="1"/>
<p>Total: $<t t-esc="state.quantity * props.price"/></p>
<!-- Without .number: "5" * 9.99 = 49.95 (JS coerces) but "5" + 3 = "53" -->
<!-- With .number: state.quantity is already 5 (Number) -->
<!-- Combine modifiers -->
<input type="text" t-model.lazy.trim="state.tag" placeholder="Tag name"/>
Complete Form Example
class RegisterForm extends Component {
formData = useState({
name: "",
email: "",
password: "",
role: "viewer",
agree: false,
});
ui = useState({ errors: {}, submitted: false });
static template = xml`
<form t-on-submit.prevent="submit" t-if="!ui.submitted">
<div>
<label>Name</label>
<input type="text" t-model.trim="formData.name"/>
<span t-if="ui.errors.name" class="error" t-esc="ui.errors.name"/>
</div>
<div>
<label>Email</label>
<input type="email" t-model.trim="formData.email"/>
<span t-if="ui.errors.email" class="error" t-esc="ui.errors.email"/>
</div>
<div>
<label>Password</label>
<input type="password" t-model="formData.password"/>
</div>
<fieldset>
<legend>Role</legend>
<label><input type="radio" t-model="formData.role" value="viewer"/> Viewer</label>
<label><input type="radio" t-model="formData.role" value="editor"/> Editor</label>
<label><input type="radio" t-model="formData.role" value="admin"/> Admin</label>
</fieldset>
<label>
<input type="checkbox" t-model="formData.agree"/>
I agree to the Terms of Service
</label>
<button type="submit" t-att-disabled="!formData.agree">Register</button>
</form>
<div t-if="ui.submitted" class="success">
Account created for <t t-esc="formData.email"/>!
</div>
`;
validate() {
const errors = {};
if (!this.formData.name) errors.name = "Name is required.";
if (!this.formData.email.includes("@")) errors.email = "Valid email required.";
this.ui.errors = errors;
return Object.keys(errors).length === 0;
}
submit() {
if (!this.validate()) return;
// save…
this.ui.submitted = true;
}
}
Two-Way Input Binding in OWL
Unlike Vue's v-model, OWL has no built-in two-way binding — you wire it manually: display the state in the input's value, and update the state on the input event. This explicitness keeps data flow clear.
class SearchBox extends Component {
static template = xml`
<input
t-att-value="state.query"
t-on-input="onInput"
placeholder="Search…"/>
<p>You typed: <t t-esc="state.query"/></p>`;
setup() {
this.state = useState({ query: "" });
}
onInput(ev) {
this.state.query = ev.target.value; // input → state
}
}
| Direction | How |
|---|---|
| state → input | t-att-value="state.query" |
| input → state | t-on-input handler sets state |
The two halves of "two-way": (1) bind the input's value to state with t-att-value so the field reflects the current state (and updates if state changes elsewhere); (2) listen to the input event and write ev.target.value back into state. Together they keep input and state in sync — you're just doing manually what v-model does automatically. Why OWL makes it explicit: seeing both directions in code keeps the data flow obvious and debuggable, consistent with OWL's one-way-data-flow philosophy (state is the single source of truth). Event choice matters: use t-on-input to update on every keystroke (live search, character counters); use t-on-change to update only when the field loses focus (less frequent, good for expensive validation). Checkboxes/selects follow the same pattern with checked/value and their respective events. Tip: for reusable form components, pass the value in as a prop and emit changes up to the parent (props down, events up) rather than mutating a shared object — that keeps components composable, mirroring how React handles controlled inputs.
🏋️ Practical Exercise
Build a ProductFilterPanel using all t-model input types:
- Text input with
t-model.trimfor a name search query. - Number inputs with
t-model.numberfor min/max price range. - A group of checkboxes for categories (Electronics, Clothing, Books, Food).
- A select dropdown for sort order (Name A→Z, Price Low→High, Newest first).
- A checkbox for "In stock only".
- Use a getter
filteredProductsthat applies all active filters to a hard-coded product array. Display the count of matching products. - A "Clear Filters" button that resets all state to defaults.
🔥 Challenge Exercise
Create a form whose inputs are two-way bound to state via t-model (text, checkbox, select), updating a live preview. Explain how t-model provides two-way binding and how it maps to value + input event under the hood.
📋 Summary
t-modelcreates two-way binding: state → input display, input event → state update.- Works on:
input[text/email/password/number/search],input[checkbox],input[radio],select,textarea. - Single checkbox ↔ boolean. Multiple checkboxes with same
t-model↔ array of checked values. - Radio group: all radios bind to the same state property; selected value = state value.
t-model.lazy— update on blur/change instead of every keystroke.t-model.trim— strip whitespace before storing.t-model.number— convert to Number before storing (always use for numeric inputs).
Interview Questions
- What does
t-modeldo? - How does two-way binding work in OWL?
- How do you bind a checkbox vs a text input?
- What events back
t-model? - How would you do one-way binding instead?
Related Topics
FAQ
Not directly — t-model is designed for native HTML form elements. For custom input components, pass a value prop and an onInput or onChange callback prop instead. The child calls props.onInput(newValue) when its internal value changes; the parent updates its state, which flows back down as the value prop. This is the standard controlled-input pattern.
All HTML input values are strings, even for type="number". Without t-model.number, OWL stores the string "42" in state. Add .number modifier: t-model.number="state.age". Then OWL calls parseFloat() on the value before storing. If the input is empty or not a valid number, NaN is stored — add a guard: this.state.age || 0.
Since t-model reads from state, resetting state resets the form. Assign all form state fields back to their initial values: Object.assign(this.formData, { name: '', email: '', agree: false }). The inputs bound to those state properties will automatically display the reset values on re-render. No DOM manipulation needed — this is the advantage of two-way binding.
Yes — t-model and t-on-input on the same element both fire. OWL applies the t-model binding first (updating state), then your t-on-input handler. In the handler, this.state.field already has the updated value. This is useful for live validation, debouncing, or triggering search after a state update.

