Skip to content

Context

Threading a value through five elements that do not care about it is the thing that actually stops people building large trees out of web components.

Alacris implements the W3C community context protocol — the same context-request event @lit/context uses — so it works between libraries, not just inside Alacris.

import { createContext, provide, consume } from '@alacris/core/context';
export const ThemeCtx = createContext('theme');
define('app-shell', {
setup(_props, host) {
const theme = signal('dark');
provide(host, ThemeCtx, theme); // a value, a signal, or any function
return html`<slot></slot>`;
},
});

If the value is reactive, subscribers are pushed the new value automatically when it changes.

define('deep-button', {
setup(_props, host) {
const theme = consume(host, ThemeCtx, 'light'); // a read-only signal
return html`<button class=${theme}><slot></slot></button>`;
},
});

consume returns a signal, so it drops straight into a binding and updates on its own.

Two levels down, no props, liverunning the real bundleEdit in playground
context.js
import { define, html, signal } from '@alacris/core';
import { createContext, provide, consume } from '@alacris/core/context';
// One key object, imported by both the provider and the consumer.
const Theme = createContext('demo-theme');
// A leaf, several levels down, that never receives the value as a prop.
define('demo-leaf', {
styles: `
:host { display: inline-block; padding: .35rem .7rem; border-radius: 6px;
border: 1px solid currentColor; font: inherit; font-size: .9rem }
:host([data-theme='dark']) { background: #22222a; color: #f2f2f5 }
`,
setup(_props, host) {
const theme = consume(host, Theme, 'light');
// Reflect it so the host can style itself.
return html`<span ref=${() => queueMicrotask(() => host.setAttribute('data-theme', theme()))}
>theme is ${theme}</span>`;
},
});
define('demo-middle', {
styles: `:host { display: block; padding-left: 1rem; border-left: 2px solid currentColor; opacity: .9 }`,
setup: () => html`<p style="margin:.25rem 0">a component that knows nothing about themes</p>
<demo-leaf></demo-leaf>`,
});
define('demo-provider', {
styles: `
:host { display: grid; gap: .6rem; font: inherit }
button { font: inherit; padding: .3rem .7rem; border-radius: 6px; cursor: pointer;
border: 1px solid currentColor; background: transparent; color: inherit; justify-self: start }
`,
setup(_props, host) {
const theme = signal('light');
// Serve it to every descendant, across shadow boundaries.
provide(host, Theme, theme);
return html`
<button @click=${() => theme(theme() === 'light' ? 'dark' : 'light')}>
toggle theme (currently ${theme})
</button>
<demo-middle></demo-middle>`;
},
});

The middle component knows nothing about themes. It does not receive one, it does not forward one, and it never re-renders when the theme changes — only the leaf does.

The request is a composed, bubbling event, which gives you the semantics you would expect from a scope chain:

  • It crosses shadow boundaries, so nesting depth is irrelevant.
  • The nearest provider wins; an inner provider shadows an outer one.
  • A consumer created before its provider exists holds the fallback and updates as soon as one appears — so script order does not matter.
consume(host, ThemeCtx, 'light');
// ^^^^^^^ used until a provider answers

Because it is the shared protocol rather than a private mechanism, a Alacris element can consume context provided by a Lit element, and a Lit element can consume one provided by Alacris. Both directions are covered by the test suite.

That matters if you are adopting Alacris incrementally inside an existing component library, or shipping components into an app you do not control.

provide returns a function that stops providing. Inside a component, prefer provideTo, which ties it to the enclosing scope so it is released when the element is removed:

import { provideTo } from '@alacris/core/context';
setup(_props, host) {
provideTo(host, ThemeCtx, theme); // released automatically
}

Context is for values that are genuinely ambient — theme, locale, a router, a service client, the current user. It is not a general state container.

The natural combination:

export const StoreCtx = createContext('app-store');
// once, at the top
provideTo(host, StoreCtx, store({ user: null, items: [] }));
// anywhere below
const state = consume(host, StoreCtx)();
state.items.push({ id: 1 }); // path-level updates, from any depth