Advertisement
🦉 Dynamic Templates

OWL JS Input Binding – t-model Two-Way Binding

t-model is OWL's two-way binding directive for form inputs. It simultaneously sets the input's display value from a reactive state property and attaches an event listener that updates the state whenever the user types. This eliminates the manual value + onChange boilerplate you would write in vanilla JavaScript. t-model works with every HTML form element — text inputs, checkboxes, radio buttons, selects, and textareas — and supports modifiers to control when and how the value is synced.

⏱️ 18 min read 🎯 Beginner 📅 Updated 2026 👁️ Lesson 3 of 5

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:

JavaScript – basic t-model
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 uses the input event by default

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

XML – text input types
<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.

XML – checkbox binding
<!-- 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'] -->
Advertisement

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.

XML – radio button binding
<!-- 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

XML – select binding
<!-- 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

XML – textarea binding
<!-- 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

ModifierEffectWhen to use
t-model.lazyUpdates state on change event (blur / commit) instead of every inputExpensive validations, large forms where live updates hurt performance
t-model.trimAutomatically trims leading/trailing whitespace before storingName, email, username fields — users often accidentally add spaces
t-model.numberConverts the string value to a JavaScript Number before storingAll numeric inputs — prevents string concatenation bugs like "5" + 3 = "53"
XML – modifiers in practice
<!-- .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

JavaScript – full registration form
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
    }
}
DirectionHow
state → inputt-att-value="state.query"
input → statet-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:

  1. Text input with t-model.trim for a name search query.
  2. Number inputs with t-model.number for min/max price range.
  3. A group of checkboxes for categories (Electronics, Clothing, Books, Food).
  4. A select dropdown for sort order (Name A→Z, Price Low→High, Newest first).
  5. A checkbox for "In stock only".
  6. Use a getter filteredProducts that applies all active filters to a hard-coded product array. Display the count of matching products.
  7. 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-model creates 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-model do?
  • 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?

FAQ

Can I use t-model on a custom child component? +

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.

Why does my number input store a string? +

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.

How do I programmatically clear a form? +

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.

Can I combine t-model with t-on-input for additional logic? +

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.