G · 05 guides

Guides / Composition & Injection

Composition & Injection

aihu has first-class support for the two halves of composition: composables — reusable functions bundling reactive logic — and hierarchical injection — providing a dependency to a subtree and injecting it anywhere below.

Tag naming

Every .aihu component compiles to a native custom element, and the platform requires custom-element names to contain a hyphen. The compiler normalizes tags for you, with one hard rule:

Single-word component names are a hard compile error (C450). A single word can never become a valid custom-element name. Multi-word PascalCase kebab-cases automatically; already-hyphenated tags pass through lowercased; plain lowercase HTML/SVG tags are never touched.

You write Compiles to
<UserCard> user-card
<APIClient> api-client
<HTMLParser> html-parser
<my-widget> my-widget
<Comment> error C450comment has no hyphen
<div>, <linearGradient> untouched (plain HTML/SVG)

A component's own resolved name (@meta name@route name → file stem) normalizes the same way, so UserCard.aihu defines user-card.

Fixing a C450: pick a hyphenated tag, or keep the file name and set an explicit @meta { name: 'hn-comment' }.

Passing props: one consequence. Plain-curly attribute props (comment={item}) are only accepted on a PascalCase reference. On a hyphenated reference you must $-prefix them. Both normalize to the same element, so pick whichever you prefer.

Route-scoped component registration

You never write a boot file importing every component. The compiler records which components each page references, and the router's Vite plugin turns those into a compile-time registry (virtual:aihu-components) of tag → lazy import. On navigation, @aihu/app registers the matched route's components before the page renders.

Don't do this:

governedtsts154 B
// src/main.ts — DON'T
import './components/hn-comment.js'
import './components/vote-button.js'
import { createApp } from '@aihu/app/client'
createApp()

Every such line drags that component into the entry chunk, so every page pays for every component — exactly the cost the registry exists to avoid. Just reference the tag:

governedhtmlhtml45 B
@template {
  <hn-comment comment={item} />
}

Only the components the active route uses are loaded. The registry is transitive: a component's loader also loads its own nested children, because the compiler emits a child as a bare tag with no import — so without that closure a nested element would stay an inert unknown element. A referenced tag with no registry entry (an element you registered globally) is skipped silently.

Composables

An @state block is your component's setup function. A plain function you call from @state therefore runs inside setup, with the full reactive surface: signals, lifecycle hooks bound to the calling component, and injection.

Look before you write one. @aihu/use ships ~65 composables — useClickOutside, useElementSize, useIntersectionObserver, useDebounced, useClipboard, useReducedMotion and so on. @aihu/router owns the routing ones (useRoute, useRouter, useRouteParams); @aihu/use owns essentially everything else. Check both before hand-rolling.

For logic genuinely specific to your app, extract a use* function:

governedtsts493 B
// src/composables/use-counter.ts
import { signal } from '@aihu/signals'
import { onMount, onCleanup } from '@aihu/runtime'

export function useCounter(start = 0) {
  const [count, setCount] = signal(start)
  const inc = () => setCount(count() + 1)
  const dec = () => setCount(count() - 1)

  // Lifecycle hooks bind to the component that CALLED the composable.
  onMount(() => console.log('counter mounted'))
  onCleanup(() => console.log('counter disposed'))

  return { count, inc, dec }
}
governedhtmlhtml170 B
@state {
  import { useCounter } from '../composables/use-counter.ts'
  const { count, inc } = useCounter(10)
}

@template {
  <button on:click={inc}>{count()}</button>
}

Everything a composable returns stays reactive: count is a signal, so the template tracks it.

Rules of thumb

  • Name them use* — the convention signalling "this touches reactive state and/or lifecycle".
  • Call them synchronously at the top of @state, never inside a callback or conditional. Lifecycle hooks and inject resolve against the currently-setting-up component, which is only correct during setup.
  • Return the reactive surface, not snapshots. Return count, not count().
  • Two call sites is the threshold. Duplicated reactive logic drifts, and the copies stop agreeing — which is worse than the duplication, because now one of them is quietly wrong.

Hierarchical injection

@aihu/context provides tree-scoped dependency injection. An ancestor provides; any descendant injects. It is scoped to the subtree, a nearer provider overrides a farther one, and it crosses shadow boundaries.

governedtsts147 B
import { createContext } from '@aihu/context'

export interface Api { base: string }
export const ApiContext = createContext<Api>({ base: '/api' })

Provide it at a layer boundary:

governedhtmlhtml144 B
@state {
  import { provide } from '@aihu/context'
  import { ApiContext } from '../context/api.ts'
  provide(ApiContext, { base: '/api/v2' })
}

Inject it anywhere below, directly or inside a composable:

governedtsts187 B
import { inject } from '@aihu/context'
import { ApiContext } from '../context/api.ts'

export function useApi() {
  return inject(ApiContext) // nearest ancestor's value, or the default
}

inject returns the token's default when nothing provided it, so a component works standalone and gains the injected layer under a provider.

Reactive injection

Provide a signal and descendants read it reactively — no extra machinery:

governedhtmlhtml122 B
@state {
  const [theme, setTheme] = signal('dark')
  provide(ThemeContext, theme)   // the signal itself, not its value
}
governedhtmlhtml122 B
@state {
  const theme = inject(ThemeContext)   // () => 'dark' | 'light'
}
@template {
  <div class={theme()}>…</div>
}

How it works

Each component instance holds a provides object whose prototype chain is the ancestor context tree. A component providing nothing shares its parent's object by reference (zero cost); the first provide does one Object.create. inject is a single prototype-chain lookup — no per-injection tree walk. The parent resolves once at connect via a shadow-host hop, so lazily-registered components still find their ancestors.

On the server

Server rendering uses a flat per-request map instead of the prototype chain — same provide/inject API, different storage. runWithContext(map, fn) activates one for the duration of a render.

There is a wrinkle worth understanding for prerendering. A prerendered tree has no provider components in it at all<router> and the app root are client constructs, so anything they would provide is simply absent on the server. The mechanism for that case is pre-populating the map before the walk, via SsrOptions.contextSetup:

governedtsts113 B
await renderToString(component, {
  contextSetup: () => provideRouteContext({ router, current: () => match }),
})

@aihu/app's prerenderer does exactly this for RouteContext, which is why useRoute() and active-link state resolve correctly in statically generated HTML rather than being null until hydration.

Separating logical layers

governedaihuaihu207 B
<app-root>            provide(AuthContext, authService)
  <dashboard>         provide(DataContext, dataStore)   // scoped to the dashboard
    <widget>          const data = useData(); const user = useAuth()

useData() and useAuth() are one-line composables wrapping inject, so consumers never touch tokens directly and never prop-drill.

See also