Skip to main content
Version: v5 (Preview)

Mixins & Reactive Controllers

Stencil composes component logic through Mixin(), a function that folds one or more mixin factories over a base class. A mixin factory is a function that takes a base class and returns a new class extending it: (Base) => class extends Base { ... }. See the Mixin() reference for its full type signature.

Combine your own mixin factories​

Write each behavior as its own mixin factory, then pass as many as you need to Mixin(). In this playground, type a name, clear it, and click outside the field:

my-component.tsx
import { Component, Mixin, State } from '@stencil/core';
import { ValidationControllerMixin } from './validation-controller-mixin';
import { FocusControllerMixin } from './focus-controller-mixin';

@Component({ tag: 'my-component' })
export class MyComponent extends Mixin(ValidationControllerMixin, FocusControllerMixin) {
@State() values = { name: '' };

private onBlur = () => {
this.handleBlur();
this.validate(this.values);
};

private onInput = (ev: Event) => {
this.values = { name: (ev.target as HTMLInputElement).value };
};

render() {
return (
<div>
<input placeholder="Name" onInput={this.onInput} onBlur={this.onBlur} />
{this.touched && this.errors.map((error) => <p>{error}</p>)}
</div>
);
}
}
validation-controller-mixin.ts
import { State } from '@stencil/core';

export function ValidationControllerMixin(Base) {
class ValidationControllerMixinClass extends Base {
@State() errors: string[] = [];

validate(values: Record<string, string>) {
this.errors = Object.entries(values)
.filter(([, value]) => !value.trim())
.map(([field]) => `${field} is required`);
}
}
return ValidationControllerMixinClass;
}
focus-controller-mixin.ts
import { State } from '@stencil/core';

export function FocusControllerMixin(Base) {
class FocusControllerMixinClass extends Base {
@State() touched = false;

handleBlur() {
this.touched = true;
}
}
return FocusControllerMixinClass;
}

Mixin(A, B, C) applies the factories right to left, so A ends up closest to MyComponent in the prototype chain. Call super.<method>?.() in any lifecycle method you override, since an earlier factory in the chain might not define it.

note

Methods and properties from different mixins can collide if they share a name. Stencil doesn't detect this for you. Pick distinct names, or keep the mixin count small enough to track by eye.

If a mixin factory declares @Prop, @State, or other decorated members, write it as a named class declaration with an explicit return. TypeScript doesn't allow decorators on the members of a class expression, so a factory that returns one directly fails to compile the moment you add one:

// ✅ Do this: a named class declaration supports decorated members.
function ValidationControllerMixin(Base) {
class ValidationControllerMixinClass extends Base {
@State() errors: string[] = [];
validate(values) {
/* ... */
}
}
return ValidationControllerMixinClass;
}

// ❌ Not this: TypeScript rejects decorators here with "Decorators are not valid here",
// whether the class is named or anonymous.
const ValidationControllerMixin = (Base) =>
class extends Base {
@State() errors: string[] = [];
validate(values) {
/* ... */
}
};

Use mixins from other Stencil libraries​

A mixin factory can come from another Stencil library, such as a shared library of behaviors your design system's packages build on. Stencil reads the factory's decorated members (@Prop(), @State(), and so on) from that library's collection output, so the factory has to be importable from there.

In the library that publishes the mixins, add a package export whose types point at the generated type declarations and whose JavaScript points at the collection:

src/mixins.ts
export { validationMixin } from './mixins/validation.js';
export { focusMixin } from './mixins/focus.js';
package.json
{
"exports": {
"./mixins": {
// type declarations, for TypeScript
"types": "./dist/types/mixins.d.ts",
// transpiled source and metadata, read by other Stencil projects' compilers
"default": "./dist/collection/mixins.js"
}
}
}

Stencil writes dist/types and dist/collection on every production build, whichever output targets you use, so there's nothing to add to your config.

The library that uses them imports the factories from that path and passes them to Mixin() as usual:

import { Component, Mixin } from '@stencil/core';
import { validationMixin, focusMixin } from '@my-org/behaviors/mixins';

@Component({ tag: 'my-input' })
export class MyInput extends Mixin(validationMixin, focusMixin) {}

To find a factory, Stencil follows its import to the file that declares it, through at most one re-export: a named one like those in src/mixins.ts above, or an export *. It doesn't follow a re-export of a re-export, and warns at build time when it can't find a factory. The same applies to your components' own imports of their mixin factories, if another library extends one of your components.

Compose reactive controllers​

A reactive controller is an object that hooks into a component's lifecycle without living inside the component class. Write the behavior once as a controller, and any component can add it.

Mix in Stencil's built-in ReactiveControllerHost, then construct controller instances and pass them this. A controller takes the host in its constructor and registers itself. Move the pointer over the preview to see the controller update the component:

src/my-component.tsx
import { Component, Mixin, ReactiveControllerHost } from '@stencil/core';
import { MouseController } from './mouse-controller';

@Component({ tag: 'my-component' })
export class MyComponent extends Mixin(ReactiveControllerHost) {
private mouse = new MouseController(this);

render() {
return (
<div>
x: {this.mouse.pos.x}, y: {this.mouse.pos.y}
</div>
);
}
}
src/mouse-controller.ts
import type { ReactiveController, ReactiveControllerHostInterface } from '@stencil/core';

export class MouseController implements ReactiveController {
pos = { x: 0, y: 0 };
private host: ReactiveControllerHostInterface;

constructor(host: ReactiveControllerHostInterface) {
this.host = host;
host.addController(this);
}

hostConnected() {
window.addEventListener('mousemove', this.onMouseMove);
}

hostDisconnected() {
window.removeEventListener('mousemove', this.onMouseMove);
}

private onMouseMove = (event: MouseEvent) => {
this.pos = { x: event.clientX, y: event.clientY };
this.host.requestUpdate();
};
}
note

ReactiveControllerHost is itself a mixin factory, so it always goes through Mixin(). It implements the component lifecycle methods, from connectedCallback() to componentDidUpdate(), to pass them on to its controllers. If your component overrides one, call super.<method>?.() in it, as the connectedCallback() example below does, or its controllers stop receiving that hook.

If using the loader-bundle output target, a controller that also needs the real host element (e.g. @lit/context's ContextProvider which dispatches DOM events), cannot use a field initializer (like the MouseController above), but must instead use connectedCallback(). In the loader-bundle, this and the host element are different objects until connectedCallback runs: this has addController / requestUpdate / updateComplete, the element does not yet.

import { Component, getElement, Mixin, ReactiveControllerHost } from '@stencil/core';
import { ContextProvider } from '@lit/context';
import { myContext } from './my-context';

@Component({ tag: 'my-provider' })
export class MyProvider extends Mixin(ReactiveControllerHost) {
private provider?: ContextProvider<typeof myContext>;

connectedCallback() {
super.connectedCallback?.();
this.provider ??= new ContextProvider(getElement(this), { context: myContext });
}
}
note

The ??= guard matters: connectedCallback can run again if the element disconnects and reconnects, and re-creating the provider each time would lose its state.

Reactive controller lifecycle hooks​

ReactiveController supports numerous host lifecycle hooks, all optional - mirroring the same load/render/update split componentWillLoad, componentWillRender, and componentWillUpdate use on the component itself.

note

render(), componentShouldUpdate() and @Watch() are only on the component host. They're just shown here as they gate hostWillUpdate / hostDidUpdate.

HookCalledUseful for
hostConnectedWhen the host connects to the DOMSetting up event listeners, observers, or subscriptions
hostDisconnectedWhen the host disconnects from the DOMTearing down whatever hostConnected set up
hostWillLoadOnce, before the host's first renderAsync setup that should delay the first render - return a promise and Stencil waits for it
hostDidLoadOnce, after the host's first renderWork that needs the rendered DOM, run a single time
hostWillRenderBefore every render, including the firstReading DOM state before it changes
hostDidRenderAfter every render, including the firstReading or measuring the DOM after it changes
hostWillUpdateBefore a re-render; never fires on the first renderWork that should skip the initial mount
hostDidUpdateAfter a re-render; never fires on the first renderSide effects that depend on what changed

The host itself exposes requestUpdate(), which schedules a re-render, and updateComplete, a promise that resolves once the current render finishes.

Add several controllers to one host the same way: construct each with this and let it register itself.

@Component({ tag: 'text-input' })
export class TextInput extends Mixin(ReactiveControllerHost) {
private validation = new ValidationController(this);
private focus = new FocusController(this);

getValidationState() {
return this.validation.getValidationState();
}
}

You can combine both patterns in one call, Mixin(ReactiveControllerHost, SomeOtherMixin), if a component needs both.

Choosing between them​

Custom mixin factoriesReactive controllers
Logic livesOn the component's own prototype chainOn separate controller objects
Access to component membersDirectOnly through what you pass in or expose
Naming collisionsPossible, between mixins or with the componentNot possible; controllers stay off the prototype chain
Best fitBehavior that feels native to the componentBehavior you want to test or reuse independently of any component