What You'll Build
Two versions of a counter widget:
- Standalone counter: manages its own state, accepts
initialValue,min,max,stepprops, emits change events - Odoo field widget: registered in
fieldsregistry, reads/writes an integer field on a form view record
Standalone Counter
Component Code
// Counter.js
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
export class Counter extends Component {
static template = "my_module.Counter";
static props = {
initialValue: { type: Number, optional: true },
min: { type: Number, optional: true },
max: { type: Number, optional: true },
step: { type: Number, optional: true },
onChange: { type: Function, optional: true },
label: { type: String, optional: true },
};
static defaultProps = {
initialValue: 0,
min: -Infinity,
max: Infinity,
step: 1,
onChange: () => {},
label: "Count",
};
setup() {
this.state = useState({ value: this.props.initialValue });
}
increment() {
const next = this.state.value + this.props.step;
if (next > this.props.max) return;
this.state.value = next;
this.props.onChange(this.state.value);
}
decrement() {
const next = this.state.value - this.props.step;
if (next < this.props.min) return;
this.state.value = next;
this.props.onChange(this.state.value);
}
reset() {
this.state.value = this.props.initialValue;
this.props.onChange(this.state.value);
}
get isAtMin() { return this.state.value - this.props.step < this.props.min; }
get isAtMax() { return this.state.value + this.props.step > this.props.max; }
}
Template
<!-- Counter.xml -->
<templates>
<t t-name="my_module.Counter">
<div class="owl-counter">
<span class="counter-label" t-esc="props.label" />
<div class="counter-controls">
<button
class="counter-btn counter-decrement"
t-on-click="decrement"
t-att-disabled="isAtMin"
aria-label="Decrease"
>−</button>
<span class="counter-value" t-esc="state.value" aria-live="polite" />
<button
class="counter-btn counter-increment"
t-on-click="increment"
t-att-disabled="isAtMax"
aria-label="Increase"
>+</button>
</div>
<button class="counter-reset" t-on-click="reset">Reset</button>
</div>
</t>
</templates>
Usage
<!-- Use Counter in a parent template -->
<Counter
label="Quantity"
initialValue="1"
min="1"
max="99"
step="1"
onChange.bind="onQuantityChange"
/>
// Parent component
onQuantityChange(newValue) {
console.log("New quantity:", newValue);
this.state.quantity = newValue;
}
CSS
.owl-counter {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px 16px;
border: 1px solid #e0e0e0;
border-radius: 8px;
background: #fafafa;
}
.counter-controls {
display: flex;
align-items: center;
gap: 12px;
}
.counter-btn {
width: 36px;
height: 36px;
border-radius: 50%;
border: 2px solid #2271b1;
background: white;
color: #2271b1;
font-size: 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.counter-btn:hover:not(:disabled) {
background: #2271b1;
color: white;
}
.counter-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.counter-value {
font-size: 28px;
font-weight: 700;
min-width: 48px;
text-align: center;
color: #1d2327;
}
.counter-reset {
font-size: 11px;
color: #666;
background: transparent;
border: none;
cursor: pointer;
text-decoration: underline;
}
Controlled Counter (No Internal State)
When the parent needs full control (e.g., form validation gate), use a controlled pattern: no internal state, value comes from props.
// ControlledCounter.js — no useState
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class ControlledCounter extends Component {
static template = "my_module.ControlledCounter";
static props = {
value: Number,
min: { type: Number, optional: true },
max: { type: Number, optional: true },
step: { type: Number, optional: true },
onUpdate: Function, // parent owns state; must provide this
};
static defaultProps = { min: -Infinity, max: Infinity, step: 1 };
increment() {
const next = this.props.value + this.props.step;
if (next <= this.props.max) this.props.onUpdate(next);
}
decrement() {
const next = this.props.value - this.props.step;
if (next >= this.props.min) this.props.onUpdate(next);
}
}
<t t-name="my_module.ControlledCounter">
<div class="owl-counter">
<button t-on-click="decrement">−</button>
<span t-esc="props.value" />
<button t-on-click="increment">+</button>
</div>
</t>
Odoo Field Widget
An Odoo field widget is an OWL component registered in the fields registry. It receives record, name (field name), and readonly props from the form view infrastructure.
// CounterField.js
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
export class CounterField extends Component {
static template = "my_module.CounterField";
static props = {
...standardFieldProps, // record, name, readonly, required, etc.
step: { type: Number, optional: true },
min: { type: Number, optional: true },
max: { type: Number, optional: true },
};
static defaultProps = {
step: 1,
min: 0,
max: Infinity,
};
get value() {
return this.props.record.data[this.props.name] ?? 0;
}
increment() {
const next = this.value + this.props.step;
if (next <= this.props.max) {
this.props.record.update({ [this.props.name]: next });
}
}
decrement() {
const next = this.value - this.props.step;
if (next >= this.props.min) {
this.props.record.update({ [this.props.name]: next });
}
}
get isAtMin() { return this.value - this.props.step < this.props.min; }
get isAtMax() { return this.value + this.props.step > this.props.max; }
}
// Register as field widget named "counter"
registry.category("fields").add("counter", CounterField);
<!-- CounterField.xml -->
<templates>
<t t-name="my_module.CounterField">
<div class="owl-counter-field">
<t t-if="!props.readonly">
<button
class="counter-btn"
t-on-click="decrement"
t-att-disabled="isAtMin"
>−</button>
</t>
<span class="counter-value" t-esc="value" />
<t t-if="!props.readonly">
<button
class="counter-btn"
t-on-click="increment"
t-att-disabled="isAtMax"
>+</button>
</t>
</div>
</t>
</templates>
Use in Views
Reference the widget name "counter" in your view XML:
<!-- views/product_form.xml -->
<record id="product_form_view" model="ir.ui.view">
<field name="model">product.template</field>
<field name="arch" type="xml">
<form>
<group>
<field name="name" />
<field
name="qty_on_hand"
widget="counter"
step="1"
min="0"
max="9999"
/>
</group>
</form>
</field>
</record>
How record.update Works
// record.update() — updates local record data, marks field as dirty
// Odoo saves automatically on form Save button or via onSave hook
this.props.record.update({ qty_on_hand: 42 });
// For immediate save (unusual):
await this.props.record.save();
// Warning: save() triggers a full round-trip to the server
Manifest and Assets
# __manifest__.py
{
"name": "Counter Widget",
"version": "17.0.1.0.0",
"depends": ["web"],
"assets": {
"web.assets_backend": [
"my_module/static/src/components/CounterField.js",
"my_module/static/src/components/CounterField.xml",
],
},
}
Testing the Widget
// tests/counter_test.js — OWL component test with @web/../tests/helpers
/** @odoo-module **/
import { Counter } from "@my_module/components/Counter";
import { mount } from "@odoo/owl";
import { getFixture } from "@web/../tests/helpers/utils";
QUnit.module("Counter", () => {
QUnit.test("increments by step", async (assert) => {
const target = getFixture();
const counter = await mount(Counter, target, {
props: { initialValue: 5, step: 2, max: 10 },
env: {},
});
// click +
target.querySelector(".counter-increment").click();
await nextTick();
assert.strictEqual(
target.querySelector(".counter-value").textContent,
"7"
);
});
QUnit.test("disables + at max", async (assert) => {
const target = getFixture();
await mount(Counter, target, {
props: { initialValue: 10, max: 10 },
env: {},
});
assert.ok(target.querySelector(".counter-increment").disabled);
});
});
🏋️ Practical Exercise
- Add a
unitprop (e.g., "kg", "pcs") displayed after the value. - Add animated transitions: fade the value out and in when it changes using
onPatched+ CSS animation. - Build a
RangeCounterwith two thumb handles (min and max selection) — like a price range filter. - Make the counter value editable by clicking on it — switch to an
<input type="number">on click, save on blur.
🔥 Challenge Exercise
Build a full counter app: a reactive count in useState, increment/decrement/reset buttons wired with t-on-click, and min/max bounds that disable buttons. Explain how state, events, and template combine into a working component.
📋 Summary
- Uncontrolled counter: manages own state, emits changes via
onChangecallback prop - Controlled counter: no internal state, parent holds value and provides
onUpdate - Computed getters:
get isAtMin/isAtMaxfor button disable state - Odoo field widget: extends with
standardFieldProps, usesrecord.update() - Field registry:
registry.category("fields").add("counter", CounterField) - View integration:
widget="counter"in form view XML
Interview Questions
- How do you build a counter in OWL?
- Where does the count live?
- How do buttons update state?
- How do you conditionally disable a button?
- What triggers the re-render?
Related Topics
FAQ
standardFieldProps from @web/views/fields/standard_field_props is a shared prop definition object that all Odoo field widgets spread into their static props. It includes record, name, readonly, required, and other props injected by the form view framework. Always include it to avoid prop validation warnings.
No. record.update() updates the local record data in memory and marks the field dirty. The form view saves to the server when the user clicks Save, navigates away, or when record.save() is called explicitly. This deferred-save model means the user can discard all changes by clicking Discard.
Use the same widget="counter" attribute on <field> in a <list> view. For list views, the widget receives readonly="true" by default in read mode. Add editable="1" to the list element to allow inline editing.

