Skip to content

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

PrimitiveRole
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 effectScopesetContext 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) valuessetContext(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 context and <dom-lifecycle> reference.
  • Custom ElementsgetContext inside connectedCallback is the cleanest pattern when the consumer is a custom element.
  • Scopes — why setContext cleanup needs a reactive scope.