- Overview
- Defining a Plugin
- Using a Plugin
- Providing Plugins
- Plugin Dependencies
- Configuration
- Plugin Shadowing
- Resources
- Lifecycle and Cleanup
Plugins are self-contained units of shared state and logic. They replace Owl 2's
env and services with a more type-safe and composable approach.
A plugin can hold reactive state (signals, computed values), perform side effects,
depend on other plugins, and be shared across a component subtree or the entire
application. Plugins have a simple lifecycle: setup then destroy.
class Clock extends Plugin {
value = signal(0);
setup() {
const interval = setInterval(() => {
this.value.set(this.value() + 1);
}, 1000);
onWillDestroy(() => clearInterval(interval));
}
}A plugin is a class that extends Plugin. It can define reactive state as
class fields and use setup() for initialization:
class NotificationManager extends Plugin {
notifications = signal.Array([]);
add(message) {
this.notifications().push({ id: Date.now(), message });
}
dismiss(id) {
const list = this.notifications();
const index = list.findIndex((n) => n.id === id);
if (index >= 0) {
list.splice(index, 1);
}
}
}Each plugin class has a static id property used as a unique identifier.
It defaults to the class name, but can be set explicitly:
class MyPlugin extends Plugin {
static id = "my-custom-id";
}The plugin() function imports a plugin instance. It can be used in component
class fields or in the setup() method:
class App extends Component {
static template = xml`
<div>
<t t-foreach="this.notifications.notifications()" t-as="n" t-key="n.id">
<div t-out="n.message" t-on-click="() => this.notifications.dismiss(n.id)"/>
</t>
</div>`;
notifications = plugin(NotificationManager);
}The return value is the plugin instance with full type information (minus the
setup method). Any reactive values on the plugin (signals, computed) are
tracked automatically when read during a component render.
Pass a plugins array when mounting the application. These plugins are
available to all components:
await mount(RootComponent, document.body, {
plugins: [NotificationManager, RouterPlugin],
});Use providePlugins() in a component's setup() to make plugins available
only to that component and its descendants:
class FormView extends Component {
static template = xml`<FormRenderer/>`;
setup() {
providePlugins([FormModel, FormValidator]);
}
}Plugins provided at the component level are destroyed when the component is destroyed.
A plugin can depend on other plugins by calling plugin() in its class
fields or setup(). If the dependency has not been started yet, it is
auto-started:
class RouterPlugin extends Plugin {
currentRoute = signal("/");
navigateTo(url) {
this.currentRoute.set(url);
}
}
class ActionPlugin extends Plugin {
router = plugin(RouterPlugin);
doAction(action) {
// ... perform action ...
this.router.navigateTo("/result");
}
}When ActionPlugin is started, it will automatically start RouterPlugin
if it hasn't been started already.
Plugins can read configuration values using the config() function. Config
is passed as the second argument to providePlugins(), or in the mount()
options:
class ApiPlugin extends Plugin {
baseUrl = config("apiBaseUrl", t.string);
timeout = config("apiTimeout?", t.number) || 5000;
setup() {
// use this.baseUrl and this.timeout
}
}Append ? to the key name to make it optional. In dev mode, the type
validator (second argument) is used to check the value.
Providing config at app level:
await mount(RootComponent, document.body, {
plugins: [ApiPlugin],
config: { apiBaseUrl: "https://api.example.com" },
});Or at component level:
setup() {
providePlugins([ApiPlugin], { apiBaseUrl: "/api" });
}A child providePlugins can override a parent plugin by providing a plugin
with the same id. This is useful to customize behavior for a subtree:
class ThemePlugin extends Plugin {
static id = "theme";
color = "blue";
}
class DarkThemePlugin extends Plugin {
static id = "theme"; // same id — shadows ThemePlugin
color = "dark-blue";
}
class DarkSection extends Component {
setup() {
providePlugins([DarkThemePlugin]);
}
}Components inside DarkSection will get DarkThemePlugin when calling
plugin(ThemePlugin), while components outside still get the original.
Plugins can define Resource fields — ordered collections that components
can contribute to. Items are automatically removed when the contributing
component is destroyed:
class SystrayPlugin extends Plugin {
items = new Resource({ name: "systray-items" });
display = computed(() => {
return this.items.items();
});
}Components contribute items using useResource():
class MyComponent extends Component {
systray = plugin(SystrayPlugin);
setup() {
useResource(this.systray.items, [{ label: "Settings", action: () => this.openSettings() }]);
}
}When MyComponent is destroyed, its contributed items are automatically
removed from the resource.
Plugins follow a simple lifecycle:
- The plugin is instantiated
setup()is called- The plugin is active and can be used
- On destroy, cleanup runs in reverse order (LIFO)
All reactive values (signals, computed, effects) created during setup() are
automatically cleaned up when the plugin is destroyed. For manual cleanup,
use onWillDestroy():
class WebSocketPlugin extends Plugin {
setup() {
this.ws = new WebSocket("wss://example.com");
onWillDestroy(() => this.ws.close());
}
}The useListener() and useEffect() hooks also work inside plugins, with
automatic cleanup on destroy:
class KeyboardPlugin extends Plugin {
lastKey = signal("");
setup() {
useListener(window, "keydown", (ev) => {
this.lastKey.set(ev.key);
});
}
}