Advertisement
πŸ¦‰ Core Concepts

OWL JS State & useState – Reactive State Management

State is the data that belongs to a component and can change over time in response to user actions or async operations. When state changes, OWL automatically re-renders the component. The useState() hook is the primary way to create reactive state in OWL β€” it wraps your state object in a JavaScript Proxy that intercepts every mutation and schedules a re-render. This lesson explains how useState works under the hood, covers every mutation pattern (especially for nested objects and arrays), and shows common real-world state patterns.

⏱️ 22 min read 🎯 Beginner πŸ“… Updated 2026 πŸ‘οΈ Lesson 4 of 5

useState Basics

Call useState() with an initial value and assign the result to a class field. The returned object is a reactive Proxy β€” every property read and write on it is tracked by OWL.

JavaScript – useState basic usage
import { Component, xml, useState } from "@odoo/owl";

class Counter extends Component {
  // Initialize reactive state
  state = useState({
    count: 0,
    label: "Clicks",
  });

  static template = xml`
    <div>
      <p><t t-esc="state.label"/>: <t t-esc="state.count"/></p>
      <button t-on-click="increment">+1</button>
      <button t-on-click="reset">Reset</button>
    </div>
  `;

  increment() {
    this.state.count++;          // direct mutation β€” triggers re-render
  }

  reset() {
    this.state.count = 0;        // assignment β€” triggers re-render
    this.state.label = "Clicks"; // multiple mutations still trigger one re-render
  }
}
ℹ️
Multiple mutations = one re-render

OWL batches mutations within the same synchronous call. If your method changes five state properties in a row, OWL schedules exactly one re-render (via a microtask queue), not five separate renders. This makes mutation-heavy code efficient.

How Proxy-Based Reactivity Works

useState(obj) returns a JavaScript Proxy wrapping obj. Every property access (get) and mutation (set, delete, array mutation) on the proxy is intercepted:

JavaScript – what Proxy does internally
// Simplified illustration β€” not the actual OWL source
function useState(initialValue) {
  return new Proxy(initialValue, {
    set(target, key, value) {
      target[key] = value;
      scheduleRerender();   // notify OWL to re-render the component
      return true;
    },
    // get, deleteProperty, etc. are also trapped
  });
}

// In your component:
this.state.count = 5;  // triggers the 'set' trap β†’ schedules re-render

The Proxy is deep β€” nested objects and arrays inside the state are also wrapped when accessed. This means you can mutate deeply nested properties and OWL will detect them.

Mutation Patterns

Primitives

JavaScript – primitive mutations
// All of these trigger re-renders:
this.state.count++;              // increment
this.state.count = 0;            // assignment
this.state.isLoading = true;     // boolean
this.state.message = "Hello";    // string
delete this.state.errorMsg;      // delete a property

Nested Objects

JavaScript – nested object mutations
state = useState({
  user: {
    name: "Alice",
    address: {
      city: "London",
      country: "UK",
    },
  },
});

// Direct nested mutation β€” works because the proxy is deep
this.state.user.name = "Bob";                   // βœ… triggers re-render
this.state.user.address.city = "Manchester";    // βœ… nested deep mutation
this.state.user.address = { city: "Paris", country: "FR" }; // βœ… replace whole object

// Replace entire user object
this.state.user = { name: "Carol", address: { city: "NYC", country: "US" } }; // βœ…

Arrays in State

The OWL Proxy intercepts all array mutation methods. Use them directly β€” do not replace the array with a new one unless you need to.

JavaScript – array mutations
state = useState({
  todos: [
    { id: 1, text: "Learn OWL", done: false },
    { id: 2, text: "Build app", done: false },
  ],
});

// βœ… All these methods are intercepted and trigger re-renders:
this.state.todos.push({ id: 3, text: "Deploy", done: false });
this.state.todos.pop();
this.state.todos.splice(1, 1);             // remove item at index 1
this.state.todos.splice(1, 0, newItem);    // insert at index 1
this.state.todos.sort((a, b) => a.id - b.id);
this.state.todos.reverse();
this.state.todos[0].done = true;           // mutate a property of an item

// ❌ These do NOT trigger re-renders (non-mutating):
// const newTodos = this.state.todos.filter(t => !t.done);
// β€” creates a new array, the original state.todos is unchanged
// If you want to replace the array:
this.state.todos = this.state.todos.filter(t => !t.done); // βœ… assignment triggers re-render
⚠️
filter/map/reduce do not mutate

array.filter(), array.map(), and array.reduce() return new arrays without mutating the original. They do not trigger a re-render. If you use them to compute a new list, assign the result back: this.state.items = this.state.items.filter(...). Or use a getter to compute derived views (e.g. get filteredItems()) β€” getters re-run on each render, so they always reflect current state.

Advertisement

Multiple State Objects

One component can have multiple useState objects. This is useful for separating concerns β€” form state, UI state, and data state can live independently.

JavaScript – multiple state objects
class UserForm extends Component {
  // Separate concerns into distinct state objects
  formData = useState({
    name:  "",
    email: "",
    role:  "viewer",
  });

  ui = useState({
    isSubmitting: false,
    showSuccess:  false,
    errorMessage: null,
  });

  async submitForm() {
    this.ui.isSubmitting = true;
    this.ui.errorMessage = null;

    try {
      await saveUser(this.formData);
      this.ui.showSuccess = true;
    } catch (err) {
      this.ui.errorMessage = err.message;
    } finally {
      this.ui.isSubmitting = false;
    }
  }

  static template = xml`
    <form t-on-submit.prevent="submitForm">
      <input t-model="formData.name" placeholder="Name"/>
      <input t-model="formData.email" placeholder="Email"/>
      <button t-att-disabled="ui.isSubmitting">
        <t t-if="ui.isSubmitting">Saving…</t>
        <t t-else="">Save</t>
      </button>
      <p t-if="ui.showSuccess" class="success">Saved!</p>
      <p t-if="ui.errorMessage" class="error" t-esc="ui.errorMessage"/>
    </form>
  `;
}

Computed Values with Getters

JavaScript getters on the component class act as computed properties. They are called fresh on every render and automatically use the latest state, without any extra setup.

JavaScript – getters as computed values
class ShoppingCart extends Component {
  state = useState({
    items: [
      { id: 1, name: "OWL Course",  price: 49.99, qty: 1 },
      { id: 2, name: "React Course", price: 39.99, qty: 2 },
    ],
  });

  // Computed β€” re-evaluated every render (reflects latest state)
  get itemCount() {
    return this.state.items.reduce((sum, item) => sum + item.qty, 0);
  }

  get subtotal() {
    return this.state.items.reduce((sum, item) => sum + item.price * item.qty, 0);
  }

  get formattedTotal() {
    return `$${this.subtotal.toFixed(2)}`;
  }

  static template = xml`
    <div>
      <p>Items: <t t-esc="itemCount"/></p>
      <p>Total: <t t-esc="formattedTotal"/></p>
    </div>
  `;
}

useState vs useRef

OWL also provides useRef() for accessing DOM elements or storing mutable values that should not trigger re-renders. Choose the right one for your use case:

FeatureuseStateuseRef
Triggers re-render on changeβœ… Yes β€” always❌ No β€” never
Accessed in templateβœ… Yes (state.x)❌ No (DOM ref only)
Stores DOM element reference❌ Not the intentβœ… Primary use case
Stores a mutable counter/timer ID⚠️ Causes unnecessary rendersβœ… Ideal (ref.el = timerId)
Must render when value changesβœ… Use useState❌ Use useState instead
JavaScript – useRef for non-reactive values
import { Component, xml, useState, useRef, onMounted, onWillUnmount } from "@odoo/owl";

class Timer extends Component {
  state = useState({ elapsed: 0 });

  // useRef holds the timer ID β€” changing it must NOT cause a re-render
  timerRef = useRef("timer");  // or just a plain property: this._timerId = null;

  onMounted() {
    this._timerId = setInterval(() => {
      this.state.elapsed++;  // useState β€” triggers re-render every second
    }, 1000);
  }

  onWillUnmount() {
    clearInterval(this._timerId);  // cleanup β€” stored as plain property, not state
  }

  static template = xml`
    <p>Elapsed: <t t-esc="state.elapsed"/>s</p>
  `;
}

State Patterns in Odoo Components

In real Odoo module components, you often need to handle loading states, paginated data, and server errors. Here is a typical pattern:

JavaScript – Odoo component with async state loading
/** @odoo-module **/
import { Component, xml, useState, onWillStart } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";

class PartnerList extends Component {
  static template = xml`
    <div>
      <t t-if="state.isLoading"><span>Loading…</span></t>
      <t t-elif="state.error">
        <p class="text-danger" t-esc="state.error"/>
      </t>
      <t t-else="">
        <ul>
          <li t-foreach="state.partners" t-as="p" t-key="p.id" t-esc="p.name"/>
        </ul>
      </t>
    </div>
  `;

  state = useState({
    partners:  [],
    isLoading: true,
    error:     null,
  });

  setup() {
    this.rpc = useService("rpc");

    onWillStart(async () => {
      try {
        const result = await this.rpc("/web/dataset/call_kw", {
          model:  "res.partner",
          method: "search_read",
          args:   [[["is_company", "=", true]]],
          kwargs: { fields: ["id", "name"], limit: 20 },
        });
        this.state.partners  = result;
        this.state.isLoading = false;
      } catch (err) {
        this.state.error     = "Failed to load partners.";
        this.state.isLoading = false;
      }
    });
  }
}

What Triggers a Re-render (and What Silently Doesn't)

useState returns a reactive proxy. OWL watches which properties you read during render, and re-renders when any of them change. The key rule: mutate the reactive object directly β€” you don't call a setter like in React.

setup() {
  this.state = useState({ count: 0, todos: [] });
}
increment() {
  this.state.count++;          // βœ… reactive β†’ re-renders
}
⚠️
Changes OWL can't see

Reassigning a plain local variable does nothing to the UI β€” only the reactive state object is tracked. And a property that didn't exist when the proxy was created isn't reactive; initialize your shape up front:

// ❌ added later β€” not reactive
this.state.newField = 1;
// βœ… declare it in the initial object
this.state = useState({ count: 0, newField: 0 });

Arrays: mutate, and the render re-reads

Pushing to a reactive array works because OWL tracks access to the array during render:

addTodo(text) {
  this.state.todos.push({ text, done: false });   // re-renders the list
}

Give list items a stable t-key so OWL updates only the changed rows instead of rebuilding the whole list.

πŸ‹οΈ Practical Exercise

Build a KanbanBoard component that demonstrates all the state patterns from this lesson:

  1. State: three columns (todo, in-progress, done), each an array of { id, title } cards. Include at least 2 pre-loaded cards per column.
  2. Add a "Move Right" button on each card that splices it out of the current column and pushes it into the next column.
  3. Add a "Delete" button on each card that splices it out entirely.
  4. Add a getter totalCards that counts all cards across all columns. Display it in a header.
  5. Separate state: put the cards in one useState object and a ui state in another that tracks which column is "highlighted" when hovered (use t-on-mouseenter / t-on-mouseleave).

πŸ”₯ Challenge Exercise

Build a component with reactive state via useState that updates the UI when mutated (e.g. a counter). Explain how OWL’s reactivity tracks property access and re-renders only affected components.

πŸ“‹ Summary

  • useState(obj) returns a deep Proxy that schedules a component re-render on any mutation.
  • Assign the result to a class field: state = useState({ ... }). Access with this.state.x in methods, state.x in templates.
  • All mutations are direct: this.state.count++, this.state.items.push(x), this.state.user.name = "Bob".
  • Array mutation methods (push, splice, sort) are intercepted. Non-mutating methods (filter, map) are not β€” assign the result back to trigger a re-render.
  • Multiple useState objects per component are fine β€” use them to separate concerns.
  • Use JS getters for computed/derived values. They re-run on each render automatically.
  • Use useRef (or a plain property) for values that must not trigger re-renders (timer IDs, abort controllers, etc.).

Interview Questions

  • What is useState?
  • How does OWL reactivity work?
  • When does a component re-render?
  • Is state mutation direct or immutable in OWL?
  • What is the difference between props and state?

FAQ

Do I need to call a render function after mutating state? +

No β€” never. OWL detects every mutation on a useState object automatically (via the Proxy) and schedules a re-render for you. Calling this.render() manually is almost always a mistake and can cause double-renders. The only exception is a very unusual case where you bypass the Proxy, which you should never do in normal code.

What happens if I assign a new object to state instead of mutating? +

Assigning a new object to a property of the reactive proxy is fine and triggers a re-render: this.state.user = { name: "New" } works. The new object is then wrapped by the Proxy when accessed again. However, replacing the entire state itself (this.state = { ... }) would replace the Proxy with a plain object β€” mutations on the new object would not be detected. Always mutate through the Proxy reference returned by useState.

Can useState be called inside a method instead of as a class field? +

Only inside setup() β€” hooks in OWL (like useState) must be called during the setup lifecycle phase, which runs synchronously when the component is constructed. Calling useState inside an event handler or an async method will throw. The most common pattern is to declare it as a class field (state = useState({...})), which is equivalent to calling it in the constructor. If you use setup(), call it there.

Why does Object.assign / spread not trigger a re-render? +

Object.assign(this.state, { count: 5 }) actually works β€” it assigns to properties on the Proxy, which intercepts the sets. However, const s = { ...this.state }; s.count = 5; this.state = s; would not, because you replaced the Proxy with a plain object. A spread produces a new plain object; if you spread into a new object and assign it back to the reactive proxy property, the inner object is no longer reactive. Stick to direct property mutations for clarity.