Way4Tech

Part 3. Reactivity · lesson 11 of 40

useState, and what actually causes a re-render

After this lesson you can: Explain exactly when OWL re-renders, and predict whether a given write will update the screen.

Written against Odoo 19.0 and 18.0. Last checked 2026-10-09.

OWLโ€™s reactivity is small enough to understand completely, which is unusual and worth taking advantage of. Once you have the model in your head, almost every โ€œwhy did it not updateโ€ question answers itself.

The mechanism

this.state = useState({ count: 0 });

useState wraps the object in a Proxy and returns the proxy. Two things happen through it.

On read, during a render: OWL records that this component read this key. That is a subscription.

On write: OWL looks up which components subscribed to that key and schedules them for re-render.

Two consequences follow immediately, and they explain most surprises.

Reads outside a render are not tracked. Reading this.state.count in a click handler subscribes to nothing, because no render was in progress.

A component only re-renders for keys it actually read. If your template never mentions state.verbose, writing to state.verbose re-renders nothing. This is a feature, not a bug, and it is why OWL is fast without you doing anything.

Writes that work

this.state.count++;
this.state.name = "new";
this.state.items.push(item);
this.state.items.splice(0, 1);
this.state.user.address.city = "Dubai";
delete this.state.draft;

Nested objects are wrapped on access, so deep writes are tracked. Array methods work because they are implemented as writes to indices and length, both of which the proxy sees.

Writes that do not

Three. These are the whole lesson.

1. Replacing the state object

// Wrong
this.state = { count: 0 };

You threw away the proxy and replaced it with a plain object. Nothing is observed any more, and nothing you do afterwards updates the screen. Mutate the proxy instead:

this.state.count = 0;
Object.assign(this.state, { count: 0, name: "" });

2. Destructuring out of state

// Wrong
const { count } = this.state;
count++;                        // a local number, unrelated to state

Destructuring copies the value out. For a primitive, the copy has no connection to the proxy at all.

Destructuring an object out of state is more subtle: the reference you get is still the reactive proxy for that sub-object, so writing to its properties works. Reassigning the variable does not.

const { user } = this.state;
user.name = "Changed";   // works, still the proxy
user = { name: "x" };    // changes a local variable, nothing else

Keep it simple: write through this.state.

3. Holding a reference taken before

setup() {
    this.items = this.state.items;   // taken once
}
later() {
    this.state.items = [];            // state now points at a different array
    this.items.push(x);               // pushing to the old one
}

The screen renders state.items, which is now empty. You are pushing into an array nothing reads.

Reads must happen during render

This catches people with conditional templates.

<div t-if="state.showDetail">
    <t t-esc="state.detailText"/>
</div>

While showDetail is false, the template never reads detailText, so the component is not subscribed to it. Writing state.detailText changes nothing on screen. Setting showDetail = true re-renders, that render reads detailText, and the subscription now exists.

This is correct behaviour and almost never a problem, because the thing you change alongside the hidden value is usually the flag itself. It is worth knowing when a value seems to update late.

One state object or several

Both work:

this.state = useState({ loading: false, items: [], filter: "all" });

// or
this.ui = useState({ loading: false });
this.data = useState({ items: [] });

Separate objects give finer subscriptions, so a component that only reads this.ui does not re-render when this.data changes. In practice one object per component is fine and clearer. Split when a child reads only part of it.

Watching it work

import { Component, useState, onRendered } from "@odoo/owl";

export class Counter extends Component {
    static template = "owl_course.Counter";
    setup() {
        this.state = useState({ count: 0, hidden: 0 });
        onRendered(() => console.log("rendered", this.state.count));
    }
}
<t t-name="owl_course.Counter">
    <div>
        <button t-on-click="() => this.state.count++">Count: <t t-esc="state.count"/></button>
        <button t-on-click="() => this.state.hidden++">Hidden</button>
    </div>
</t>

Clicking the first button logs. Clicking the second logs nothing, every time, because the template never reads hidden. That is the subscription model in one experiment, and it is worth running once.

Renders are batched

Several writes in one synchronous block produce one render:

async load() {
    this.state.loading = true;      // no render yet
    this.state.error = null;        // no render yet
    const data = await this.fetch();
    this.state.items = data;        // render after the microtask
    this.state.loading = false;     // batched with the line above
}

You never need to think about batching, but it does mean you cannot read the DOM immediately after a write and expect it to have updated. If you genuinely must, await this.render() or use onRendered.

What is next

reactive(), the primitive underneath useState, and how to share reactive state between components without passing props everywhere.

Back to the full course