What You'll Build
A user registration form with these fields and rules:
| Field | Rules | Type |
|---|---|---|
| Username | Required, 3β20 chars, alphanumeric, unique (async) | text |
| Required, valid email format | ||
| Password | Required, min 8 chars, must contain number | password |
| Confirm Password | Must match Password | password |
| Age | Required, number, 18β120 | number |
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.");
}
}
}
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; }