Context propagation
Share a current-user signal with deeply-nested components via setContext + getContext, deferring the lookup with <dom-lifecycle> so it resolves after mount.
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.
import { signal } from "elements-kit/signals";
import { setContext } from "elements-kit/utilities/context";
import { USER, ROLES, type User } from "./user";
import { Avatar } from "./Avatar";
import { Toolbar } from "./Toolbar";
export class App {
render() {
const currentUser = signal<User>({ name: "Ada", role: "admin" });
return (
<div
ref={(el) => {
setContext(el, USER, currentUser);
}}
style="padding: 1.5rem; font-family: system-ui, sans-serif; max-width: 640px;"
>
<h2 style="margin-top: 0;">Context — current user, no prop drilling</h2>
<p style="color: #888; margin: 0 0 1rem;">
One <code>setContext</code> on the root. Two unrelated descendants
read it via <code>getContext</code> in a deferred <code>ref</code>.
</p>
<div style="border: 1px solid #8884; border-radius: 8px; padding: 1rem; margin-bottom: 1rem;">
<h3 style="margin: 0 0 0.5rem;">Header</h3>
<Avatar />
</div>
<div style="border: 1px solid #8884; border-radius: 8px; padding: 1rem; margin-bottom: 1rem;">
<h3 style="margin: 0 0 0.5rem;">Toolbar (deeply nested)</h3>
<section>
<div>
<Toolbar />
</div>
</section>
</div>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<button on:click={() => currentUser({ name: "Ada", role: "admin" })}>
Ada (admin)
</button>
<button
on:click={() => currentUser({ name: "Grace", role: "editor" })}
>
Grace (editor)
</button>
<button
on:click={() => currentUser({ name: "Linus", role: "viewer" })}
>
Linus (viewer)
</button>
<button
on:click={() => {
const next = ROLES[(ROLES.indexOf(currentUser().role) + 1) % 3];
currentUser({ ...currentUser(), role: next });
}}
>
cycle role
</button>
</div>
</div>
);
}
}
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
getContext from a JSX ref returns undefined
ref fires after the element is created and its props/children are attached, but before insertion into the parent. The walk has nowhere to go — parentNode is null. Use <dom-lifecycle onConnect> to defer the lookup, or move it to a custom element's connectedCallback.
getContext does not subscribe
It's a one-shot ancestor walk. If the consumer needs to re-run when the provider's value changes, wrap the value in a Signal / Computed and read it inside an effect or reactive child. If the provider itself is replaced (rare), look it up again on the relevant lifecycle event.
setContext cleanup needs a scope
The onCleanup registered inside setContext only runs if there's an enclosing reactive scope. A custom element's wrapped connectedCallback, <dom-lifecycle onConnect>, and JSX ref callbacks all qualify — calling setContext from a plain top-level function leaves the entry in the registry for the page's lifetime. Fine for a global root provider, leaky for short-lived ones.
See also
- Context — 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.