Ad – 728×90
🦉 Project

OWL JS Counter Widget – Standalone & Odoo Field Widget

A Counter Widget looks simple but teaches core OWL patterns that appear in real Odoo modules: prop validation, controlled vs uncontrolled state, callback props for two-way communication, and the Odoo field registry. This guide builds the counter in two forms — a reusable standalone component and a full Odoo field widget for integer fields on any model.

⏱️ 30 min 🎯 Beginner 📅 Updated 2026

What You'll Build

Two versions of a counter widget:

  • Standalone counter: manages its own state, accepts initialValue, min, max, step props, emits change events
  • Odoo field widget: registered in fields registry, 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>
Ad – 336×280

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>
Uncontrolled vs Controlled: Uncontrolled (with internal state) is simpler for isolated widgets. Controlled is better when the parent needs to validate, cap, or synchronise the value across multiple components. Odoo field widgets are always controlled — the record holds the value.

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);
  });
});

What You Learned

  • Uncontrolled counter: manages own state, emits changes via onChange callback prop
  • Controlled counter: no internal state, parent holds value and provides onUpdate
  • Computed getters: get isAtMin/isAtMax for button disable state
  • Odoo field widget: extends with standardFieldProps, uses record.update()
  • Field registry: registry.category("fields").add("counter", CounterField)
  • View integration: widget="counter" in form view XML

Extend the Project

  1. Add a unit prop (e.g., "kg", "pcs") displayed after the value.
  2. Add animated transitions: fade the value out and in when it changes using onPatched + CSS animation.
  3. Build a RangeCounter with two thumb handles (min and max selection) — like a price range filter.
  4. Make the counter value editable by clicking on it — switch to an <input type="number"> on click, save on blur.

FAQ

What is standardFieldProps?

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.

Does record.update() save immediately?

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.

How do I add the widget to a list view column?

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.