Context propagation
setContext registers a value on a host element. Any descendant — across open shadow boundaries, at any depth — can read it with getContext. No prop drilling, no global module state, no React-style provider component.
The recipe
import { signal, type Signal } from "elements-kit/signals";import { setContext, getContext } from "elements-kit/utilities/context";import "elements-kit/utilities/dom-lifecycle";
type User = { name: string; role: "viewer" | "editor" | "admin" };const USER = Symbol("user");
function Avatar() { const user = signal<Signal<User> | undefined>(undefined); return ( <dom-lifecycle onConnect={(el) => user(getContext<Signal<User>>(el, USER))} > <span>{() => user()?.().name ?? "(none)"}</span> </dom-lifecycle> );}
class App { render() { const currentUser = signal<User>({ name: "Ada", role: "admin" }); return ( <div ref={(el) => setContext(el, USER, currentUser)}> <Avatar /> </div> ); }}<dom-lifecycle> is a render-inert custom element (display: contents, no a11y role). Wrap the content that needs the context in it — onConnect fires once the wrapper is in the DOM, so the getContext ancestor walk reaches the provider. The provider side uses a ref callback on the root element; by the time JSX ref fires, the JSX runtime has wrapped the call in an effectScope, so setContext’s onCleanup registration is collected.
How each piece contributes
| Primitive | Role |
|---|---|
setContext(host, key, value) | Registers value on host. Returns host for inline composition. Auto-removed on scope dispose via onCleanup. |
getContext<T>(consumer, key) | One-shot ancestor walk from consumer. Returns the nearest registered value, or undefined. Does not subscribe — reactivity is the caller’s responsibility. |
<dom-lifecycle onConnect> | Wraps the consuming subtree and defers getContext until after insertion. JSX ref runs before insertion, so a synchronous getContext there returns undefined. The callback runs inside an effectScope — setContext calls inside it auto-clean on disconnect. |
ref={(el) => setContext(el, …)} | Provider side. JSX ref fires after the element is created and props are attached but before insertion — that’s enough for setContext because it just writes to a registry keyed on el, no ancestor walk needed. |
signal (held by consumer) | Latches the result of the lookup. Reading the signal inside reactive children re-renders when the provider’s value changes. |
Variations
Inside a custom element — skip <dom-lifecycle> entirely. connectedCallback is the canonical getContext site; the element is in the tree by then:
class ProfileCard extends HTMLElement { connectedCallback() { const user = getContext<Signal<User>>(this, USER); effect(() => (this.textContent = user?.().name ?? "(none)")); }}Symbol vs string key — both work. Symbol("name") avoids collisions across modules; strings are easier to inspect in DevTools. Prefer symbols at module scope.
Static (non-reactive) values — setContext(el, KEY, "hello") works just as well. Reactivity is opt-in: pass a Signal / Computed only when consumers should re-render on changes.
Inner provider shadows outer — calling setContext deeper in the tree with the same key wins for that subtree. Useful for region-scoped overrides (e.g. a settings page that wants its own theme).
Gotchas
See also
- Utilities — full
contextand<dom-lifecycle>reference. - Custom Elements —
getContextinsideconnectedCallbackis the cleanest pattern when the consumer is a custom element. - Scopes — why
setContextcleanup needs a reactive scope.