Components

Context

provide and inject share a value with every descendant under an injection key, without passing it through each level. Each framework gets its own context, from React's createContext to Angular's InjectionToken.

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:

ThemeProvider.uf.tsx
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>
  );
}
Badge.uf.tsx
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:

ThemeProvider.tsx
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>
  );
}
Badge.tsx
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 with use(ThemeKey).
  • Vue keeps the Symbol in a <script lang="ts"> block beside <script setup>, so that other files can import it. It writes provide and inject.
  • Svelte keeps the Symbol in a <script module> block, typed with the value it carries. It writes setContext and getContext.
  • Angular declares an InjectionToken, lists it in the component's providers, and reads it from the parent's injector with skipSelf, once its inputs are set.
  • Solid declares the key with createContext, wraps the template in <ThemeKey.Provider>, and reads it with useContext.
  • Qwik declares the key with createContextId, and writes useContextProvider and useContext.
  • Astro renders each component on its own, with no tree of components to provide to, so the key is a type and inject gives 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:

Greeting.uf.tsx
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.

Counter.uf.tsx
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>
  );
}
Display.uf.tsx
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):

Level.uf.tsx
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.

The decisions behind this page are ADR-0054 and ADR-0055.

Copyright © 2026