Element, Controller, and Context in Umbraco Backoffice Extensions
With Umbraco 14+’s modern backoffice built on web standards like TypeScript and Web Components, extension developers now have powerful patterns available for creating modular, maintainable features.
In this post, we'll walk through a common pattern for splitting logic out of your custom elements, leveraging the Umbraco Controller or Umbraco Context concepts to reuse logic across elements or share APIs between parent and child elements. We’ll build a simple dashboard extension with a button that shows an alert — and gradually refactor it from inline logic to a clean, modular structure.
Parts:
- A basic custom element with inline logic
- A controller class to encapsulate logic
- A self-providing context shared with child elements
Part 1: A Basic Dashboard Element With Inline Logic
To get started, we’ll register a dashboard and implement a very simple custom element that displays a button and triggers a “Hi! alert when clicked.
The Dashboard Manifest
We start by registering our dashboard in a manifest:
export const manifests: Array<UmbExtensionManifest> = [
{
type: 'dashboard',
alias: 'My.Dashboard',
name: 'My Dashboard',
element: () => import('./my-dashboard.element.ts'),
weight: 5000,
meta: {
label: 'My Dashboard',
pathname: 'my-dashboard',
},
},
]; The Dashboard Custom Element
This component uses the UmbElement-Mixin, which is an Umbraco helper for custom elements. It gives quick
access to Backoffice-specific APIs like contexts and localization.
import { LitElement, html, customElement, } from '@umbraco-cms/backoffice/external/lit';
import { UmbElement } from '@umbraco-cms/backoffice/element';
@customElement('my-dashboard')
export class MyDashboardElement extends UmbElement(LitElement) {
#sayHi() {
alert('Hi!');
};
render() {
return html`
<button @click=${this.#sayHi}>Click me!</button>
`;
}
}
All logic is kept inside the element itself — using a private method
#sayHi to
handle interaction. This is perfectly fine for small components, but once your
logic grows, this pattern can become hard to scale or reuse. In the next part,
we’ll improve the separation of concerns by extracting the logic into a standalone
controller.
Part 2: Extract Logic Into a Controller
In Umbraco 14+, controllers are plain classes that hook into the element
lifecycle via the UmbController interface. This pattern allows you to encapsulate logic and react to the
lifecycle of the component it's attached to — similar to how connectedCallback
and
disconnectedCallback
work in custom elements, but externalized.
There are two ways to make a class lifecycle-aware:
- Implement the UmbController interface
- (Recommended) Extend the UmbControllerBase class, which gives you additional Backoffice APIs
Let’s walk through both.
A: Manual Controller (implements UmbController)
import type { UmbControllerHost, UmbController } from '@umbraco-cms/backoffice/controller';
export class MyDashboardController implements UmbController {
constructor(host: UmbControllerHost) {
host.addController(this);
}
hostConnected(): void {
console.log('Dashboard controller connected');
}
hostDisconnected(): void {
console.log('Dashboard controller disconnected');
}
destroy(): void {
console.log('Dashboard controller destroyed');
}
sayHi() {
alert('Hi!');
}
} B: Extend the UmbControllerBase class
import { UmbControllerBase } from '@umbraco-cms/backoffice/controller';
export class MyDashboardController extends UmbControllerBase {
constructor(host: UmbControllerHost) {
super(host);
}
hostConnected(): void {
super.hostConnected();
console.log('Dashboard controller connected');
}
hostDisconnected(): void {
super.hostDisconnected();
console.log('Dashboard controller disconnected');
}
destroy(): void {
super.destroy();
console.log('Dashboard controller destroyed');
}
sayHi() {
alert('Hi!');
}
} By extending UmbControllerBase, you automatically get:
-
Lifecycle methods:
hostConnectedandhostDisconnected -
Access to context APIs:
this.consumeContext() -
Access to localization APIs:
this.localize()
Updated Element Using the Controller
import { LitElement, html, customElement, } from '@umbraco-cms/backoffice/external/lit';
import { UmbElement } from '@umbraco-cms/backoffice/element';
import { MyDashboardController } from './my-dashboard.controller.js';
@customElement('my-dashboard')
export class MyDashboardElement extends UmbElement(LitElement) {
#controller = new MyDashboardController(this);
render() {
return html`
<button @click=${() => this.#controller.sayHi()}>Click me!</button>
`;
}
} We now have a clear separation between UI and behavior. The element is responsible for rendering and interaction, and the controller owns the business logic. This makes the code more maintainable and extensible — and in the next part, we’ll go one step further and convert the controller into a shared context, so that child elements can also use it.
Part 3: Share Logic Using a Context
So far, we’ve kept logic inside a controller tied to a single component. But what if multiple elements need access to the same logic or state? In Umbraco 14+, the recommended solution is to use the Context API. We’ll now take the controller logic from Part 2 and convert it into a self-providing context. This context will be provided by the parent element and can be consumed by any nested child elements.
Define a Context Token
We start by creating a unique token for our context. This token identifies the context and ensures type safety.
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
import type { MyDashboardContext } from './my-dashboard.context.js';
export const MY_DASHBOARD_CONTEXT = new UmbContextToken<MyDashboardContext>('MyDashboard'); Create the Context Class
Instead of implementing UmbController, we now extend UmbContextBase, which:
- Automatically registers the context with the host
- Makes it self-providing using the token
- Gives access to context and localization APIs just like UmbControllerBase and UmbElement
import { UmbContextBase } from '@umbraco-cms/backoffice/context-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller';
import { MY_DASHBOARD_CONTEXT } from './my-dashboard.context.token.js';
export class MyDashboardContext extends UmbContextBase {
constructor(host: UmbControllerHost) {
super(host, MY_DASHBOARD_CONTEXT);
}
sayHi() {
alert('Hi!');
}
}
Dashboard Element Provides the Context
In this step, we move the button from the dashboard element into its own
child element — <my-dashboard-button></my-dashboard-button>. This keeps the dashboard element focused solely on providing the
context and handling layout, while leaving all logic and UI interaction to
the child.
import { html, customElement } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { MyDashboardContext } from './my-dashboard.context.js';
@customElement('my-dashboard')
export class MyDashboardElement extends UmbLitElement {
#context = new MyDashboardContext(this);
render() {
return html`
<my-dashboard-button></my-dashboard-button>
`;
}
} Consume the Context in the button
This child component renders the UI and uses consumeContext()
to call the shared sayHi() logic.
import { html, customElement } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { MY_DASHBOARD_CONTEXT } from './my-dashboard.context.token.js';
import type { MyDashboardContext } from './my-dashboard.context.js';
@customElement('my-dashboard-button')
export class MyDashboardButtonElement extends UmbLitElement {
#context?: MyDashboardContext;
constructor() {
super();
this.consumeContext(MY_DASHBOARD_CONTEXT, (context) => {
this.#context = context;
});
}
render() {
return html`
<button @click=${() => this.#context?.sayHi()}>Click me!</button>
`;
}
} Wrapping up
We’ve seen how to go from simple inline logic to reusable controllers and shared contexts using Umbraco 14+’s extension API. These patterns help keep your code modular, scalable, and easier to maintain.