
Odoo web library 2 was a good, stable framework. Odoo built years of increasingly complex UI on top of it without much drama. So when a major version rewrite shows up, the fair question isnβt βwhat brokeβ β itβs βwhat did they actually change, and was it worth it?β
The short answer: yes, and the changes are bigger than a typical major version bump. Owl 3 replaces the core reactivity engine, gets rid of the shared env object in favor of a plugin system, and reworks props, refs, and two-way binding around a new primitive called a signal. None of this is cosmetic β itβs Odooβs answer to real pain points that showed up after years of running Owl 2 at scale.
This post walks through whatβs actually new in Odoo web library 3, organized by feature area rather than as a changelog, and compares each piece directly against how Owl 2 did it. Itβs based on Odooβs own Owl 3 Release Notes and migration guide.
Owl 2 vs Owl 3, at a glance
| Area | Owl 2 | Owl 3 |
|---|---|---|
| Reactive state | useState() / reactive(), proxy-based, tied to component instance | signal() / proxy() / computed(), not tied to any component |
| Reading state in templates | Direct property access (this.value) | Signals are called as functions (this.value()) |
| Shared state / services | env object, useEnv, useSubEnv, useService | Plugin system: Plugin, usePlugin, providePlugins |
| Props | this.props auto-injected, validated via static props | Explicit useProps() call, validated via t.* type functions |
| Refs | useRef("name") + t-ref="name", access via .el | signal.ref() + t-ref="this.ref", access via this.ref() |
| Two-way binding | t-model on a proxy path | t-model on a signal (or .proxy modifier for proxy state) |
| Escaping text | t-esc | t-out |
| Slot insertion | t-slot | t-call-slot (with t-set-slot for defining) |
| Forcing a re-render | this.render() | Not needed β mutate a signal instead |
| Derived state | Manual caching, getters, onWillRender tricks | computed() β lazy, cached, dependency-tracked |
| Rendering context in templates | Implicit β bare identifiers could resolve to this | Explicit β only this. reaches the component; everything else is local |
| Content teleportation | t-portal | Removed; mount a component at the target via a plugin instead |
| App instantiation | One βmainβ root + optional βsub rootsβ | Just roots β new App() + createRoot() |
| Passive event listeners | Not supported | .passive modifier on t-on-* |
If you only remember one thing from this table: almost every row traces back to the same root cause, which is worth understanding before the details start to feel arbitrary.
The one idea behind (almost) everything: signals
Owl 2βs reactivity worked through proxies: useState/reactive wrapped an object, and Owl silently tracked which components read which properties during render. It worked for the vast majority of cases, but it had a structural weak spot β tracking happened per component instance, not per value. If a child (or a slot) read some state that a parent merely owned, the parent got dragged into re-rendering as a whole, even though it never touched that state directly in its own render function.
Owl 3βs answer is signals: small, explicit, standalone units of reactive state. A signal isnβt tied to a component at all β itβs tied to whoever reads it, at the moment they read it.
const count = signal(0);
const state = proxy({ color: "red", value: 15 });
const total = computed(() => count() + state.value);
console.log(total()); // 15You read a signal by calling it (count()), and you write it with .set() (count.set(4)). Whoever calls that function during a render is the one who subscribes β full stop, no ambiguity about which component βownsβ the update.
A couple of behaviors worth knowing up front. Setting a signal to an identical value is a no-op. Owl wonβt re-render anything, which is usually what you want β except when youβre mutating something inside the signal in place (pushing to an array, for instance), where you need to explicitly say βthis changedβ:
const list = signal([1, 2, 3]); list().push(4); // Owl has no idea anything changed signal.trigger(list); // now it knows
For the common case of βI just want a mutable list/object/set/map that notifies Owl automatically,β there are ready-made wrappers: signal.Array(), signal.Object(), signal.Set(), signal.Map().
proxy() still exists, and itβs the direct replacement for useState/reactive β recursive, nested-object-friendly, exactly like before, just implemented on top of signals under the hood instead of raw Proxy tricks.
computed() is lazy and cached. It only recalculates when you actually read it and one of its dependencies has changed since last time:
const s1 = signal(3); const d1 = computed(() => 2 * s1()); const d2 = computed(() => d1() + 10); d2(); // evaluates d1, then d2 -> 16 d2(); // returns cached result immediately s1.set(4); d2(); // recomputes -> 18
A computed value is read-only by default, but you can give it a .set() implementation if you want a two-way computed:
const triple = computed(() => 3 * s1(), {
set: (value) => s1.set(value / 3),
});effect() is the new low-level primitive behind useEffect. It runs immediately, then re-runs (after a microtask) whenever anything it read changes:
const cb = effect(() => console.log(count())); // logs immediately count.set(5); // logs again after a microtask cb(); // stops the effect
useEffect is just effect() wired up to clean itself up on onWillDestroy β which also means the second-argument dependency array from Owl 2 is gone entirely. Owl 3 tracks whatever the function reads, automatically:
// Owl 2
useEffect(() => { /* ... */ }, () => [depA, depB]);
// Owl 3
useEffect(() => { /* ... */ });The practical upshot: useState, reactive, and the old useEffect dependency array are all gone, replaced by proxy, signal, computed, and effect. If youβre doing nothing fancy, useState({...}) becomes proxy({...}) and youβre done. If you were using the two-argument reactive(obj, callback) form to derive state, thatβs exactly the kind of thing computed() now does better.
env is gone β plugins take over
This is the single biggest structural change in Owl 3, and itβs not really about syntax β itβs about how youβre supposed to architect shared state and services at all.
Owl 2βs env was a single object, inherited down the component tree, that held whatever a parent decided to put in it via useSubEnv. It worked, but it was untyped, hard for IDEs to autocomplete, and easy to accidentally read from a component that shouldnβt have known that data existed.
Owl 3 replaces it with a plugin system. A plugin is a self-contained class with its own setup/destroy lifecycle, that can depend on other plugins, hold its own signals, and be explicitly imported by whatever component or plugin needs it:
class Clock extends Plugin {
value = signal(1);
setup() {
const interval = setInterval(() => this.value.set(this.value() + 1), 1000);
onWillDestroy(() => clearInterval(interval));
}
}
class DoubleClock extends Plugin {
clock = usePlugin(Clock);
mcm = computed(() => 2 * this.clock.value());
}
class Root extends Component {
static template = xml`<t t-out="this.a.mcm()"/>`;
a = usePlugin(DoubleClock);
}
mount(Root, document.body, { plugins: [Clock, DoubleClock] });The mapping from old to new is fairly direct once you see it:
| Owl 2 | Owl 3 |
|---|---|
| A global service | A global plugin, passed in mount(Root, target, { plugins: [...] }) |
useService("something") | usePlugin(SomethingPlugin) |
useSubEnv({...}) | providePlugins([SomePlugin]) β scoped to that component and its children |
useEnv() | usePlugin(SomePlugin) on whatever plugin held that piece of env |
this.env.thing in a component | this.thing = usePlugin(ThingPlugin) |
Odooβs own framing for this is refreshingly blunt: βservices are removedβ β not because Owl ever defined services (it didnβt, that was always an Odoo-layer concept), but because every service in the Odoo codebase becomes a plugin under this model, with dependencies between them expressed as plain usePlugin() calls instead of ambient env lookups.
The payoff is real: plugins are typed, so IDE autocomplete works properly; they can be swapped out per-subtree (useful for testing or theming); and β unlike env β they can be attached to a component tree dynamically at runtime, not just at setup time.
Props: explicit, typed, and no longer automatic
In Owl 2, this.props just existed β the framework populated it for you, and you validated its shape with a static props object plus an optional static defaultProps. Owl 3 makes this explicit on both ends.
Getting props at all now requires asking for them:
// Owl 2 - this.props exists automatically
class MyComponent extends Component {
setup() { /* this.props is already there */ }
}
// Owl 3 - you explicitly import what you need
class MyComponent extends Component {
props = useProps();
setup() { /* this.props is now populated */ }
}Type validation moved from a static object DSL to composable functions, using the new t.* helpers β which, unlike the old { type: X, optional: true } shape, are easy for an IDE to actually infer types from:
// Owl 2
static props = {
name: String,
visible: { type: Boolean, optional: true },
leaveDuration: { type: Number, optional: true },
};
static defaultProps = { leaveDuration: 100 };
// Owl 3
props = useProps({
name: t.string(),
visible: t.boolean().optional(),
leaveDuration: t.number().optional(100),
});A quietly nice behavioral change here: when you pass a schema to useProps(), you get back only the keys in that schema β Owl no longer cares what extra props a caller passed. That means you donβt need to declare a slots key just because your component happens to receive slots youβre not using, and subclassing a component to add extra props becomes much less fragile.
You can also call useProps() more than once in the same component β handy when you want to hand a subset of props straight to a child:
props = useProps({ a: t.string() });
otherProps = useProps({ c: t.instanceOf(SomeClass) });
allProps = useProps(); // everything, no validationAnd if you only need a single prop, thereβs a shorthand that skips the object-destructure step:
todo = useProps.static("todo", t.instanceOf(Todo));For validating values outside of props entirely, Owl 3 also exports two standalone functions built on the same type system: validateType(value, type) (returns a list of issues) and assertType(value, type) (throws on mismatch).
One more prop-related upgrade worth knowing, since itβs a genuine performance feature rather than an API change: Owl 3βs compiler now automatically detects inline arrow-function props whose captured variables havenβt changed, and skips re-rendering the child component in that case β the same optimization Owl 2βs manual .alike suffix provided, but applied automatically in the common case:
<!-- Owl 3: no .alike needed here --> <t t-foreach="this.items" t-as="item" t-key="item.id"> <Todo todo="item" toggle="() => this.toggle(item.id)"/> </t>
Templates got several related upgrades
A cluster of template-syntax changes all point the same direction: make the rendering context explicit and predictable instead of implicit and occasionally surprising.
Every free variable now needs this. to reach the component
In Owl 2, a bare identifier in a template expression fell back to the component if it wasnβt a local template variable β convenient, but it meant capturing this inside an inline handler could silently point somewhere unexpected. Owl 3 makes the rule simple: local template variables (from t-set, t-foreach, slots) are read directly; everything else needs this.
<!-- Owl 2 --> <t t-set="item" t-value="123"/> <button t-on-click="onClick"><t t-out="val"/><t t-out="item"/></button> <!-- Owl 3 --> <t t-set="item" t-value="123"/> <button t-on-click="this.onClick"><t t-out="this.val"/><t t-out="item"/></button>
The nice part: this syntax already works fine under Owl 2, so itβs safe to roll out ahead of an actual version bump.
Loop scoping now matches real JavaScript
Owl 2 had a quirk where a t-set reassignment inside a t-foreach didnβt propagate back out to the surrounding scope β closer to how a var in old-style JS behaved than a let. Owl 3 fixes this so each loop iteration gets its own scope, and mutations propagate the way youβd expect:
<t t-set="a" t-value="0"/> <t t-foreach="[1, 2]" t-as="i" t-key="i"> <t t-set="a" t-value="a + 1"/> </t> <!-- Owl 2: a is 0 (loopβs changes were lost) --> <!-- Owl 3: a is 2 --> <t t-out="a"/>
This also fixes a related bug class where slots or t-call bodies defined inside a loop captured a frozen snapshot of a variable instead of its live binding β so a mutation after the slot was defined wouldnβt show up when it eventually rendered. Owl 3 closes over the actual binding now, like a JS closure would.
t-esc is t-out
Simple rename, with one wrinkle: Owl 2βs t-esc quietly stringified objects; Owl 2βs own t-out threw on them instead. Owl 3βs t-out handles both text and objects correctly, so this only matters during a staged migration where youβre running t-out under the old runtime.
t-slot is t-call-slot
A small rename with a real reason behind it: it was never obvious from t-slot alone whether you were defining a slotβs content or inserting it somewhere. Owl 3 splits the two:
<div class="header"><t t-call-slot="header"/></div> <div><t t-call-slot="body"/></div>
t-call gained real parameters and got a restriction
Passing data into a called template used to mean setting variables with t-set inside the t-call body β implicit, and evaluated eagerly whether or not the called template used them. Owl 3 lets you pass values as attributes directly, prop-style, and only evaluates the body lazily if the template actually asks for it via t-out="0":
<!-- Owl 2 --> <t t-call="sub"> <t t-set="node" t-value="subtree"/> </t> <!-- Owl 3 --> <t t-call="sub" node="subtree"/>
The restriction: t-call now only works on a <t> node β <div t-call="sub"/> throws instead of silently rendering. Wrap it: <div><t t-call="sub"/></div>.
Refs and two-way binding both became signal-based
Two APIs that used to be their own special cases in Owl 2 are now just⦠signals, which makes them compose better with the rest of the reactivity system (and with each other).
Refs
// Owl 2
static template = xml`<div t-ref="somename">...</div>`;
setup() {
this.ref = useRef("somename");
onMounted(() => console.log(this.ref.el));
}
// Owl 3
static template = xml`<div t-ref="this.ref">...</div>`;
setup() {
this.ref = signal.ref();
onMounted(() => console.log(this.ref()));
}Because itβs a plain signal now, a parent can hand a ref signal down to a child and read the DOM node the child renders β something that required awkward workarounds in Owl 2. For collecting multiple refs out of a loop, thereβs a dedicated Resource class instead of trying to cram several elements into one signal.
Two-way binding (t-model)
// Owl 2 - only works on an assignable proxy path
static template = xml`<input t-model="state.value"/>`;
this.state = useState({ value: "hello" });
// Owl 3 - works on a signal directly
static template = xml`<input t-model="this.value"/>`;
value = signal("hello");If your state is still a proxy() rather than a signal, thereβs a .proxy modifier that keeps the old read/write-a-property behavior instead of forcing a signal conversion: t-model.proxy="this.state.value" (and it composes with .trim, .number, .lazy, same as before).
Lifecycle hooks: several removed, one narrowed
Four hooks are gone outright, and one pair changed how often they fire β all in service of the same idea: push logic toward declarative reactive values instead of imperative lifecycle callbacks.
onWillUpdateProps β computed, useEffect, or asyncComputed
It used to cover three different real needs, and Owl 3 wants you to pick the tool that matches which one you actually meant: derived state (computed, with the relevant prop declared as t.signal(...)), a one-time reset on a specific change (useEffect), or an async load keyed off a prop (asyncComputed, where available).
onWillRender β computed or onMounted
Precomputing something expensive before a render is exactly what computed caches for you; triggering an actual side effect (a notification, an API call) based on βa render is about to happenβ was rarely a reliable signal to begin with, so that moves to onMounted.
onRendered β onMounted or onPatched
Usually used to reset a flag or gate some control flow β the same job onMounted/onPatched do more reliably, since βrenderedβ as its own event wasnβt buying much beyond what those two already cover.
this.render() β mutate a signal instead
The manual escape hatch for forcing an update is gone on the theory that signals make it unnecessary β anywhere you used to call this.render() after mutating a plain field, switch that field to a signal and call .set().
onPatched/onWillPatch still exist, but fire less often
Read this carefully. This is the change most likely to produce a silent behavior regression, because it doesnβt throw an error, it just stops running. In Owl 2, reactivity tracking happened per component instance, so a parent that owned some state a child read would still re-render (and fire onPatched) when that state changed. In Owl 3, only whoever actually reads the signal during render subscribes β so a parent that merely holds a signal a child reads no longer re-renders or fires onPatched just because that signal changed.
// Owl 2 - relied on the parentβs onPatched firing because a child read its state onPatched(() => this.syncWithMessage(this.message)); // Owl 3 - subscribe to the value directly instead effect(() => this.syncWithMessage(this.message()));
The rule of thumb: keep onPatched/onWillPatch for things genuinely about your own DOM being repainted; switch to effect/useEffect for βrun this when value X changes,β since an effect subscribes to what it reads regardless of which component ends up re-rendering.
t-portal is gone, with a real replacement pattern
t-portal let you teleport a chunk of template content to somewhere else in the DOM (a common trick for modals and dropdowns escaping an overflow: hidden container). Odooβs reasoning for removing it: it was internally complex, and in a framework with real declarative reactivity, there are more robust tools for the same job.
The replacement pattern is to stop relocating template content and instead mount a real component at the target location, through a plugin:
class PortalPlugin extends Plugin {
add(selector, component, props) {
// create and mount a root at the selectorβs location
}
}
function usePortal(selector, component, props) {
const portal = usePlugin(PortalPlugin);
onWillDestroy(portal.add(selector, component, props));
}
class Something extends Component {
setup() {
usePortal(".someselector", PortalContent);
}
}Itβs more code than a single attribute, but it plugs directly into the plugin/root system instead of working around it β and cleanup is handled by the componentβs own lifecycle instead of framework magic.
App simplified to βjust roots,β plus a new useApp hook
Owl 2βs App had a βmainβ root plus optional βsub rootsβ bolted on for cases needing multiple mount points that shared settings. Owl 3 drops that distinction β an App is now just a container holding a set of roots, full stop:
// Owl 2
const app = new App(SomeComponent);
await app.mount(target, { props: someProps });
// Owl 3
const app = new App();
const root = app.createRoot(SomeComponent, { props: someProps });
await root.mount(target);If your only interaction with App was through the mount() helper function, none of this affects you β itβs unchanged. A new useApp() hook rounds this out, letting a component grab the active App to spin up additional roots dynamically (handy for the portal pattern above, or for things like a floating toolbar a rich-text editor mounts on demand):
setup() {
const app = useApp();
const root = app.createRoot(SomeOtherComponent);
root.mount(targetEl);
onWillDestroy(() => root.destroy());
}Odooβs own guidance: prefer multiple roots under one App over multiple independent App instances β the latter is less efficient and can cause hard-to-debug issues if state ends up shared across app boundaries.
useComponent is gone
It existed for three-and-a-half reasons in Owl 2: reading env, reading props, writing values directly onto the component from inside a hook, and β more quietly β reaching into internals (comp.__owl__.app) to get the current App. Every one of those now has a dedicated, better-typed replacement: usePlugin for env, useProps for props, a hook returning values instead of mutating this, and useApp() for the app instance. Odooβs own audit of its codebase found most existing useComponent calls were doing something youβd want to refactor anyway.
Small quality-of-life additions
A few things that arenβt corrections of Owl 2 behavior so much as genuinely new capability:
.passiveevent modifier βt-on-scroll.passive="this.onScroll"sets the native passive option onaddEventListener, letting the browser optimize scroll/touch performance. Combines with.capture; conflicts (with a console warning) if paired with.prevent.RegistryandResourceclasses β moved from Odoo into Owl core and rebuilt on signals/computed values: an ordered key/value registry and an ordered collection class, both reactive out of the box, both supporting type validation and sequencing.loadFileremoved β it was a small fetch-and-return-text helper mostly used by the Owl playground; if you relied on it, itβs about ten lines to reimplement yourself.
Is Owl 3 worth the upgrade?
If youβre weighing whether to take this on: the changes arenβt arbitrary API churn, theyβre targeted at specific pain Odoo hit at scale, hard-to-diagnose reactivity bugs, weak IDE support for props and env, and derived-state code that didnβt compose well across addons patching each otherβs components. Signals, plugins, and typed props are Odooβs answer to each of those, respectively, and the design notes are candid that some of these problems only really show up once a codebase gets large and has many teams touching the same components.
For a small project, some of this will feel like more ceremony for the same result β props = useProps() instead of props just existing, for instance. For a large one, the explicitness is very likely the point: fewer implicit dependencies, better autocomplete, and a reactivity model where βwhy did this re-renderβ has one clear answer instead of several possible ones.
Frequently Asked Questions
The reactivity system. Owl 2 tracked subscriptions per component instance using proxies; Owl 3 tracks them per reactive value using signals, where whoever reads a signal during render is the one who subscribes. Almost every other change in Owl 3 — props, refs, t-model, env to plugins — is a consequence of adopting that model consistently.
Yes — proxy() is still there and still works like useState/reactive did, wrapping an object recursively so nested properties stay reactive. It is just implemented on top of signals now rather than being its own separate mechanism, and it sits alongside signal() and computed() rather than being the only option.
Functionally, yes — plugins replace it as the way to share state and services down a component tree. Odoo has documented a compatibility shim that reintroduces an env-like object on top of the plugin system for large codebases that need to migrate incrementally, but the long-term direction is plugins only.
Both, in different ways. The signal model itself tends to produce fewer unnecessary re-renders (since subscriptions are precise rather than component-wide), and the automatic .alike detection for arrow-function props removes a common source of wasted child re-renders without any code changes. It is not primarily a performance release, but performance is a real side effect of the more precise tracking.
If you are starting fresh, Owl 3’s typed props, plugin system, and signal-based state are a more modern foundation than Owl 2’s env/proxy model, and you skip the migration entirely. The main trade-off is ecosystem maturity — Owl 3 is newer, so expect fewer existing examples and community patterns than a framework that has been stable for longer.
This post draws on Odoo’s official Owl 3 Release Notes and Owl 2 to Owl 3 Migration Guide. Both are living documents — check the source for the latest details on any section still evolving.
