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:
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>
);
}
}
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;
}
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.
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:
export { validationMixin } from './mixins/validation.js';
export { focusMixin } from './mixins/focus.js';
{
"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:
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>
);
}
}
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();
};
}
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 });
}
}
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.
render(), componentShouldUpdate() and @Watch() are only on the component host. They're just shown here as they gate hostWillUpdate / hostDidUpdate.
| Hook | Called | Useful for |
|---|---|---|
hostConnected | When the host connects to the DOM | Setting up event listeners, observers, or subscriptions |
hostDisconnected | When the host disconnects from the DOM | Tearing down whatever hostConnected set up |
hostWillLoad | Once, before the host's first render | Async setup that should delay the first render - return a promise and Stencil waits for it |
hostDidLoad | Once, after the host's first render | Work that needs the rendered DOM, run a single time |
hostWillRender | Before every render, including the first | Reading DOM state before it changes |
hostDidRender | After every render, including the first | Reading or measuring the DOM after it changes |
hostWillUpdate | Before a re-render; never fires on the first render | Work that should skip the initial mount |
hostDidUpdate | After a re-render; never fires on the first render | Side 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 factories | Reactive controllers | |
|---|---|---|
| Logic lives | On the component's own prototype chain | On separate controller objects |
| Access to component members | Direct | Only through what you pass in or expose |
| Naming collisions | Possible, between mixins or with the component | Not possible; controllers stay off the prototype chain |
| Best fit | Behavior that feels native to the component | Behavior you want to test or reuse independently of any component |