Element, Controller, and Context in Umbraco Backoffice Extensions

Umbraco
Fundamentals

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:

  1. A basic custom element with inline logic
  2. A controller class to encapsulate logic
  3. 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 functionality here is intentionally trivial — the purpose of this tutorial is not to build something feature-rich, but to demonstrate how to structure your code using code splitting in Umbraco 14+.

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:

  1. Implement the UmbController interface
  2. (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:

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:

  1. Automatically registers the context with the host
  2. Makes it self-providing using the token
  3. 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.

More Resources