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.
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
}
}
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:
// 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
// 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
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.
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
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.
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.
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.
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:
| Feature | useState | useRef |
|---|---|---|
| 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 |
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:
/** @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
}
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:
- State: three columns (
todo,in-progress,done), each an array of{ id, title }cards. Include at least 2 pre-loaded cards per column. - Add a "Move Right" button on each card that
splices it out of the current column andpushes it into the next column. - Add a "Delete" button on each card that
splices it out entirely. - Add a getter
totalCardsthat counts all cards across all columns. Display it in a header. - Separate state: put the cards in one
useStateobject and auistate in another that tracks which column is "highlighted" when hovered (uset-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 withthis.state.xin methods,state.xin 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
useStateobjects 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?
Related Topics
FAQ
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.
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.
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.
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.

