Ad – 728Γ—90
πŸ¦‰ Project

OWL JS Form with Real-Time Validation

Forms are the most interaction-heavy UI pattern. A form done right shows errors only after the user has touched a field (not before), validates asynchronously when uniqueness must be checked against a server, cross-validates related fields (e.g., password and confirm), and disables submit until every rule passes. This project builds a user registration form implementing all of these patterns in OWL JS, then shows how the same patterns map to Odoo form view constraints.

⏱️ 40 min 🎯 Intermediate πŸ“… Updated 2026

What You'll Build

A user registration form with these fields and rules:

FieldRulesType
UsernameRequired, 3–20 chars, alphanumeric, unique (async)text
EmailRequired, valid email formatemail
PasswordRequired, min 8 chars, must contain numberpassword
Confirm PasswordMust match Passwordpassword
AgeRequired, number, 18–120number

Validation Architecture

State has three parallel objects: fields (values), errors (error strings), touched (whether user has blurred the field). Only show an error when a field is touched.

// RegistrationForm.js
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";

export class RegistrationForm extends Component {
  static template = "my_module.RegistrationForm";

  setup() {
    this.state = useState({
      fields: {
        username: "",
        email: "",
        password: "",
        confirm: "",
        age: "",
      },
      errors: {
        username: "",
        email: "",
        password: "",
        confirm: "",
        age: "",
      },
      touched: {
        username: false,
        email: false,
        password: false,
        confirm: false,
        age: false,
      },
      submitting: false,
      submitError: "",
      submitted: false,
    });
  }

  // Mark field as touched on blur, then validate
  onBlur(field) {
    this.state.touched[field] = true;
    this.validateField(field);
  }

  // Validate a single field
  validateField(field) {
    const v = this.state.fields[field];

    switch (field) {
      case "username": {
        if (!v) { this.state.errors.username = "Username is required."; break; }
        if (v.length < 3) { this.state.errors.username = "Min 3 characters."; break; }
        if (v.length > 20) { this.state.errors.username = "Max 20 characters."; break; }
        if (!/^[a-zA-Z0-9_]+$/.test(v)) { this.state.errors.username = "Only letters, numbers, underscores."; break; }
        this.state.errors.username = "";
        break;
      }
      case "email": {
        if (!v) { this.state.errors.email = "Email is required."; break; }
        if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) { this.state.errors.email = "Enter a valid email."; break; }
        this.state.errors.email = "";
        break;
      }
      case "password": {
        if (!v) { this.state.errors.password = "Password is required."; break; }
        if (v.length < 8) { this.state.errors.password = "Min 8 characters."; break; }
        if (!/\d/.test(v)) { this.state.errors.password = "Must contain at least one number."; break; }
        this.state.errors.password = "";
        // Re-validate confirm if it was already touched
        if (this.state.touched.confirm) this.validateField("confirm");
        break;
      }
      case "confirm": {
        if (!v) { this.state.errors.confirm = "Please confirm your password."; break; }
        if (v !== this.state.fields.password) { this.state.errors.confirm = "Passwords do not match."; break; }
        this.state.errors.confirm = "";
        break;
      }
      case "age": {
        const n = Number(v);
        if (!v) { this.state.errors.age = "Age is required."; break; }
        if (isNaN(n) || !Number.isInteger(n)) { this.state.errors.age = "Enter a whole number."; break; }
        if (n < 18) { this.state.errors.age = "Must be 18 or older."; break; }
        if (n > 120) { this.state.errors.age = "Enter a valid age."; break; }
        this.state.errors.age = "";
        break;
      }
    }
  }

  get isFormValid() {
    const { errors } = this.state;
    return Object.values(errors).every(e => e === "") &&
           Object.values(this.state.fields).every(v => v !== "");
  }

  async onSubmit(ev) {
    ev.preventDefault();
    // Touch all fields to show any remaining errors
    for (const field of Object.keys(this.state.touched)) {
      this.state.touched[field] = true;
      this.validateField(field);
    }
    if (!this.isFormValid) return;

    this.state.submitting = true;
    this.state.submitError = "";
    try {
      await this.checkUsernameUnique(this.state.fields.username);
      // Submit to server...
      await new Promise(r => setTimeout(r, 800)); // simulate API call
      this.state.submitted = true;
    } catch (err) {
      this.state.submitError = err.message;
    } finally {
      this.state.submitting = false;
    }
  }

  async checkUsernameUnique(username) {
    // Simulate server check β€” replace with actual orm.searchCount or rpc call
    const taken = ["admin", "odoo", "user"];
    await new Promise(r => setTimeout(r, 300));
    if (taken.includes(username.toLowerCase())) {
      this.state.errors.username = "Username already taken.";
      throw new Error("Username already taken.");
    }
  }
}
Ad – 336Γ—280

Template

<!-- RegistrationForm.xml -->
<templates>
  <t t-name="my_module.RegistrationForm">
    <div class="reg-form-wrapper">

      <!-- Success state -->
      <t t-if="state.submitted">
        <div class="success-banner">
          βœ“ Registration complete! Welcome, <strong t-esc="state.fields.username" />.
        </div>
      </t>

      <form t-else="" class="reg-form" t-on-submit="onSubmit" novalidate="1">
        <h2>Create Account</h2>

        <t t-if="state.submitError">
          <div class="form-error-banner" t-esc="state.submitError" />
        </t>

        <!-- Username -->
        <div t-att-class="'form-group' + (state.touched.username and state.errors.username ? ' has-error' : '') + (state.touched.username and !state.errors.username and state.fields.username ? ' has-success' : '')">
          <label for="username">Username</label>
          <input
            id="username"
            type="text"
            t-model.trim="state.fields.username"
            t-on-blur="() => onBlur('username')"
            t-on-input="() => state.touched.username and validateField('username')"
            placeholder="e.g. john_doe"
            autocomplete="username"
          />
          <span class="field-error" t-if="state.touched.username and state.errors.username" t-esc="state.errors.username" />
          <span class="field-ok"   t-if="state.touched.username and !state.errors.username and state.fields.username">βœ“</span>
        </div>

        <!-- Email -->
        <div t-att-class="'form-group' + (state.touched.email and state.errors.email ? ' has-error' : '') + (state.touched.email and !state.errors.email and state.fields.email ? ' has-success' : '')">
          <label for="email">Email</label>
          <input
            id="email"
            type="email"
            t-model.trim="state.fields.email"
            t-on-blur="() => onBlur('email')"
            t-on-input="() => state.touched.email and validateField('email')"
            placeholder="you@example.com"
            autocomplete="email"
          />
          <span class="field-error" t-if="state.touched.email and state.errors.email" t-esc="state.errors.email" />
        </div>

        <!-- Password -->
        <div t-att-class="'form-group' + (state.touched.password and state.errors.password ? ' has-error' : '') + (state.touched.password and !state.errors.password and state.fields.password ? ' has-success' : '')">
          <label for="password">Password</label>
          <input
            id="password"
            type="password"
            t-model="state.fields.password"
            t-on-blur="() => onBlur('password')"
            t-on-input="() => state.touched.password and validateField('password')"
            autocomplete="new-password"
          />
          <span class="field-error" t-if="state.touched.password and state.errors.password" t-esc="state.errors.password" />
          <PasswordStrength t-if="state.fields.password" password="state.fields.password" />
        </div>

        <!-- Confirm Password -->
        <div t-att-class="'form-group' + (state.touched.confirm and state.errors.confirm ? ' has-error' : '') + (state.touched.confirm and !state.errors.confirm and state.fields.confirm ? ' has-success' : '')">
          <label for="confirm">Confirm Password</label>
          <input
            id="confirm"
            type="password"
            t-model="state.fields.confirm"
            t-on-blur="() => onBlur('confirm')"
            t-on-input="() => state.touched.confirm and validateField('confirm')"
            autocomplete="new-password"
          />
          <span class="field-error" t-if="state.touched.confirm and state.errors.confirm" t-esc="state.errors.confirm" />
        </div>

        <!-- Age -->
        <div t-att-class="'form-group' + (state.touched.age and state.errors.age ? ' has-error' : '') + (state.touched.age and !state.errors.age and state.fields.age ? ' has-success' : '')">
          <label for="age">Age</label>
          <input
            id="age"
            type="number"
            t-model.number="state.fields.age"
            t-on-blur="() => onBlur('age')"
            t-on-input="() => state.touched.age and validateField('age')"
            min="18"
            max="120"
          />
          <span class="field-error" t-if="state.touched.age and state.errors.age" t-esc="state.errors.age" />
        </div>

        <button
          type="submit"
          class="submit-btn"
          t-att-disabled="state.submitting"
        >
          <t t-if="state.submitting">Creating account…</t>
          <t t-else="">Create Account</t>
        </button>
      </form>
    </div>
  </t>
</templates>

PasswordStrength Sub-Component

// PasswordStrength.js
/** @odoo-module **/
import { Component } from "@odoo/owl";

export class PasswordStrength extends Component {
  static template = "my_module.PasswordStrength";
  static props = { password: String };

  get score() {
    const p = this.props.password;
    let s = 0;
    if (p.length >= 8)  s++;
    if (p.length >= 12) s++;
    if (/[A-Z]/.test(p)) s++;
    if (/[0-9]/.test(p)) s++;
    if (/[^A-Za-z0-9]/.test(p)) s++;
    return s;  // 0–5
  }

  get label()  { return ["Very Weak","Weak","Fair","Good","Strong","Very Strong"][this.score]; }
  get color()  { return ["#e74c3c","#e67e22","#f1c40f","#2ecc71","#27ae60","#1abc9c"][this.score]; }
}
<t t-name="my_module.PasswordStrength">
  <div class="password-strength">
    <div class="strength-bar">
      <span
        t-foreach="[1,2,3,4,5]"
        t-as="i"
        t-key="i"
        class="strength-segment"
        t-att-style="i <= score ? 'background:' + color : ''"
      />
    </div>
    <span class="strength-label" t-att-style="'color:' + color" t-esc="label" />
  </div>
</t>

Async Uniqueness Check with Odoo ORM

// Replace checkUsernameUnique with real ORM call
async checkUsernameUnique(username) {
  const count = await this.orm.searchCount(
    "res.users",
    [["login", "=", username]],
  );
  if (count > 0) {
    this.state.errors.username = "Username already taken.";
    throw new Error("Username already taken.");
  }
}

Extract useFormValidation Hook

Reuse validation logic across multiple forms with a custom hook:

// hooks/useFormValidation.js
/** @odoo-module **/
import { useState } from "@odoo/owl";

export function useFormValidation(initialFields, validators) {
  const state = useState({
    fields: { ...initialFields },
    errors:  Object.fromEntries(Object.keys(initialFields).map(k => [k, ""])),
    touched: Object.fromEntries(Object.keys(initialFields).map(k => [k, false])),
  });

  function validate(field) {
    const validator = validators[field];
    if (validator) {
      state.errors[field] = validator(state.fields[field], state.fields) || "";
    }
  }

  function touch(field) {
    state.touched[field] = true;
    validate(field);
  }

  function touchAll() {
    for (const field of Object.keys(state.fields)) touch(field);
  }

  const isValid = () =>
    Object.keys(state.fields).every(f => {
      validate(f);
      return !state.errors[f];
    });

  return { state, validate, touch, touchAll, isValid };
}
// Usage in component
setup() {
  const { state, touch, touchAll, isValid } = useFormValidation(
    { username: "", email: "", password: "" },
    {
      username: (v) => !v ? "Required" : v.length < 3 ? "Min 3 chars" : "",
      email:    (v) => !v ? "Required" : !/\S+@\S+\.\S+/.test(v) ? "Invalid email" : "",
      password: (v) => !v ? "Required" : v.length < 8 ? "Min 8 chars" : "",
    }
  );
  this.form = state;
  this.touch = touch;
  this.touchAll = touchAll;
  this.isFormValid = isValid;
}

CSS

.reg-form-wrapper { max-width: 440px; margin: 40px auto; }

.reg-form { display: flex; flex-direction: column; gap: 18px; padding: 28px; border: 1px solid #e0e0e0; border-radius: 8px; }

.form-group { display: flex; flex-direction: column; gap: 4px; }
.form-group label { font-size: 13px; font-weight: 600; color: #1d2327; }
.form-group input {
  padding: 9px 12px;
  border: 1px solid #ccd0d4;
  border-radius: 4px;
  font-size: 14px;
  transition: border-color 0.15s;
}
.form-group.has-error input  { border-color: #d63638; }
.form-group.has-success input { border-color: #00a32a; }
.field-error { font-size: 12px; color: #d63638; }
.field-ok    { font-size: 12px; color: #00a32a; }

.submit-btn {
  padding: 10px;
  background: #2271b1;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 14px;
  cursor: pointer;
}
.submit-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.success-banner { padding: 16px; background: #edfaef; border: 1px solid #00a32a; border-radius: 4px; color: #1d7a1d; }
.form-error-banner { padding: 10px; background: #fce8e8; border: 1px solid #d63638; border-radius: 4px; color: #a20e0e; font-size: 13px; }

.password-strength { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
.strength-bar { display: flex; gap: 3px; }
.strength-segment { width: 28px; height: 4px; background: #e0e0e0; border-radius: 2px; transition: background 0.2s; }

What You Learned

  • touched state: show errors only after user has left a field β€” better UX than immediate validation
  • validateField(): per-field switch statement, sets error string or clears it
  • Cross-field validation: password change re-validates confirm field if already touched
  • Async uniqueness check: called on submit, throws to halt form submission
  • touchAll() on submit: reveals all errors if user tries to submit without touching every field
  • useFormValidation hook: encapsulates validation state for reuse across components

Extend the Project

  1. Add debounced async validation: check username uniqueness 500ms after the user stops typing (not just on blur), using useDebounce custom hook.
  2. Add a phone number field with international format validation using a regex, and mask the input to auto-insert hyphens.
  3. Implement a multi-step form: split the fields into two pages (Account Info / Personal Info) with Back/Next navigation, validating only the current page's fields before proceeding.
  4. Replace the vanilla form with an Odoo wizard (transient model): create the my.registration.wizard model, add Python validation in _check_username, and use record.update() in the OWL wizard component.

FAQ

Why not use the native HTML required / pattern attributes?

Native browser validation fires on submit and shows browser-styled tooltips that cannot be customised. OWL (or any framework) form validation gives full control over when errors appear, how they look, async validation, and cross-field rules. Use novalidate on the <form> element to disable browser validation and rely on your own logic.

How do I prevent double-submit?

Set this.state.submitting = true before the async call and bind t-att-disabled="state.submitting" to the submit button. Also guard at the top of onSubmit: if (this.state.submitting) return; to handle race conditions from keyboard/mouse double-fire.

Is there a validation library for OWL?

OWL does not ship a built-in validation library. Odoo's own form infrastructure uses Python-side constraint validation (@api.constrains) and surfaces errors via the UserError exception. For standalone OWL apps, lightweight libraries like vest or yup work well β€” import them as ES modules and call them inside your validateField methods.