Context
Some values belong to a whole subtree rather than one component: a theme, a locale, the depth of a nested outline, the state of a set of tabs. Passing them down as props through every level couples components that only pass them on. A component provides such a value under an injection key, and any descendant injects it, however deep.
Keys
An injection key is an exported top-level const of the .uf.tsx module whose component provides it, typed InjectionKey<T> and set to a Symbol with a description:
import { provide } from "unframework";
import type { InjectionKey } from "unframework";
import Badge from "./Badge.uf.tsx";
export interface Theme {
accent: string;
label: string;
}
export const ThemeKey: InjectionKey<Theme> = Symbol("uf.theme");
export default function ThemeProvider() {
provide(ThemeKey, { accent: "teal", label: "Teal" });
return (
<section aria-label="Themed">
<Badge text="New" />
</section>
);
}
import { inject } from "unframework";
import { ThemeKey } from "./ThemeProvider.uf.tsx";
export default function Badge({ text }: { text: string }) {
const theme = inject(ThemeKey);
return (
<span class={["badge", theme?.accent]}>
{text} in {theme?.label ?? "no theme"}
</span>
);
}
ThemeProvider provides a theme to everything it renders, and Badge imports the key and injects the theme. With no provider above it, inject gives undefined, so Badge reads theme?.label. Each framework writes the key in its own form, into the provider's output:
import Badge from "./Badge";
import { type CSSProperties, createContext, useState } from "react";
export interface Theme {
accent: string;
label: string;
}
export interface ThemeProviderAttributes {
className?: string;
style?: CSSProperties;
}
export const ThemeKey = createContext<Theme | undefined>(undefined);
export default function ThemeProvider({ className, style }: ThemeProviderAttributes) {
const [themeValue] = useState({ accent: "teal", label: "Teal" });
return (
<ThemeKey value={themeValue}>
<section aria-label="Themed" className={className} style={style}>
<Badge text="New" />
</section>
</ThemeKey>
);
}
import { ThemeKey } from "./ThemeProvider";
import { type CSSProperties, use } from "react";
export interface BadgeAttributes {
className?: string;
style?: CSSProperties;
}
export default function Badge({ text, className, style }: { text: string } & BadgeAttributes) {
const theme = use(ThemeKey);
return (
<span className={cx("badge", theme?.accent, className)} style={style}>
{text} in {theme?.label ?? "no theme"}
</span>
);
}
/** Joins class names, and the keys of an object's truthy entries, into one `className`. */
function cx(...parts: unknown[]): string {
const names: string[] = [];
for (const part of parts) {
if (typeof part === "string") {
if (part) names.push(part);
} else if (part && typeof part === "object") {
for (const [key, on] of Object.entries(part)) {
if (on) names.push(key);
}
}
}
return names.join(" ");
}
- React declares the key with
createContext, wraps the template in<ThemeKey value={…}>, and reads it withuse(ThemeKey). - Vue keeps the
Symbolin a<script lang="ts">block beside<script setup>, so that other files can import it. It writesprovideandinject. - Svelte keeps the
Symbolin a<script module>block, typed with the value it carries. It writessetContextandgetContext. - Angular declares an
InjectionToken, lists it in the component'sproviders, and reads it from the parent's injector withskipSelf, once its inputs are set. - Solid declares the key with
createContext, wraps the template in<ThemeKey.Provider>, and reads it withuseContext. - Qwik declares the key with
createContextId, and writesuseContextProvideranduseContext. - Astro renders each component on its own, with no tree of components to provide to, so the key is a type and
injectgives its fallback (context, a warning).
A key declared as anything but an exported InjectionKey Symbol is UF2033. Passing provide or inject anything but a key, such as a string, is UF2032, and a key imported from a .ts module is UF1002 until M5. Qwik uses the description as its context's id, so two keys with one description collide in one application: give each a namespaced one, Symbol("acme.theme").
Providing and injecting
provide(ThemeKey, value) and inject(ThemeKey, fallback) are setup items at the top level (UF2005), and their first argument is the key, by its name (UF2032). inject gives the value of the nearest ancestor that provides the key, or the fallback where none does:
import { inject } from "unframework";
import { LocaleKey } from "./LocaleProvider.uf.tsx";
export default function Greeting({ name }: { name: string }) {
const locale = inject(LocaleKey, "en");
return (
<p>
{locale === "pt" ? "Olá" : "Hello"}, {name} ({locale})
</p>
);
}
Outside a LocaleProvider, Greeting says "Hello, Ada (en)", and inside one with locale="pt", "Olá, Inês (pt)". On Astro every Greeting gives the fallback.
An injected value is read-only: a write to it, or to one of its members, is UF2034. It would change the provider's state behind its back on some targets and nothing on others. To let descendants change it, provide a function beside the value, and call it.
Reactive values
A provided ref or computed stays reactive: a descendant that injects it renders each change.
import { provide, ref } from "unframework";
import type { InjectionKey, Ref } from "unframework";
import Display from "./Display.uf.tsx";
export const CountKey: InjectionKey<Ref<number>> = Symbol("uf.count");
export default function Counter() {
const count = ref(1);
provide(CountKey, count);
return (
<section aria-label="Counter">
<Display />
<button type="button" onClick={() => (count.value += 1)}>
Add one
</button>
</section>
);
}
import { inject, ref } from "unframework";
import { CountKey } from "./Counter.uf.tsx";
export default function Display() {
const none = ref(0);
const count = inject(CountKey, none);
return <output>Count: {count.value}</output>;
}
Vue provides the ref itself, and Qwik its signal. React, Svelte and Angular provide an object with a value getter over the state, through an inline helper, refObject, and Solid writes the same getter in place, so a descendant reads count.value as it changes (reactive-context, emulated). React makes that object again at each render of the provider, so on React code that runs later, after an await or in a timer, reads the value of the descendant's last render. On Astro a provided ref reaches no descendant.
Nested providers
A component may inject a key and provide it too. It reads its parent's value, as Vue does, so its inject comes before its provide (UF2032):
import { defineSlots, inject, provide } from "unframework";
import type { Element, InjectionKey } from "unframework";
export const DepthKey: InjectionKey<number> = Symbol("uf.depth");
export default function Level({ title }: { title: string }) {
const slots = defineSlots<{ default?(): Element }>();
const depth = inject(DepthKey, 0);
provide(DepthKey, depth + 1);
return (
<section aria-label={title}>
<h2>
{title} at depth {depth}
</h2>
{slots.default?.()}
</section>
);
}
Each Level reads its parent's depth and provides its own, so the "Chapter" is at depth 2 and the "Appendix" at depth 1: the nearest provider wins. Angular injects with skipSelf, since the component's own providers would answer first.
Expose and component refs
defineExpose lists the functions a parent may call, and a template ref on a component holds them. Each framework exposes them its own way, from React's imperative handle to Angular's public methods.
Dynamic components
<component is> renders one of a set of tags or components that the compiler knows, chosen by a condition or a prop. Each framework gets its own dynamic element, and Angular a switch over the candidates.