Skip to content

What’s New in Owl 3.0: How It’s Different From Owl 2.0 (Complete Breakdown)

What’s New in Owl 3.0 How It’s Different From Owl 2.0

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

AreaOwl 2Owl 3
Reactive stateuseState() / reactive(), proxy-based, tied to component instancesignal() / proxy() / computed(), not tied to any component
Reading state in templatesDirect property access (this.value)Signals are called as functions (this.value())
Shared state / servicesenv object, useEnv, useSubEnv, useServicePlugin system: Plugin, usePlugin, providePlugins
Propsthis.props auto-injected, validated via static propsExplicit useProps() call, validated via t.* type functions
RefsuseRef("name") + t-ref="name", access via .elsignal.ref() + t-ref="this.ref", access via this.ref()
Two-way bindingt-model on a proxy patht-model on a signal (or .proxy modifier for proxy state)
Escaping textt-esct-out
Slot insertiont-slott-call-slot (with t-set-slot for defining)
Forcing a re-renderthis.render()Not needed β€” mutate a signal instead
Derived stateManual caching, getters, onWillRender trickscomputed() β€” lazy, cached, dependency-tracked
Rendering context in templatesImplicit β€” bare identifiers could resolve to thisExplicit β€” only this. reaches the component; everything else is local
Content teleportationt-portalRemoved; mount a component at the target via a plugin instead
App instantiationOne β€œmain” root + optional β€œsub roots”Just roots β€” new App() + createRoot()
Passive event listenersNot 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()); // 15

You 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 2Owl 3
A global serviceA 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 componentthis.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 validation

And 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:

  • .passive event modifier β€” t-on-scroll.passive="this.onScroll" sets the native passive option on addEventListener, letting the browser optimize scroll/touch performance. Combines with .capture; conflicts (with a console warning) if paired with .prevent.
  • Registry and Resource classes β€” 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.
  • loadFile removed β€” 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.

Planning an Odoo Upgrade or Custom Module Build?

Techvaria builds and maintains custom Odoo modules and front-end work across Odoo versions. If you are weighing an Owl 2 to Owl 3 migration, assessing how a version jump affects your existing customisations, or scoping new development, tell us what you are running today and we will give you an honest read on the effort involved.