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 C450 — comment 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:
// 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:
@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/useships ~65 composables —useClickOutside,useElementSize,useIntersectionObserver,useDebounced,useClipboard,useReducedMotionand so on.@aihu/routerowns the routing ones (useRoute,useRouter,useRouteParams);@aihu/useowns essentially everything else. Check both before hand-rolling.
For logic genuinely specific to your app, extract a use* function:
// 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 }
}@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 andinjectresolve against the currently-setting-up component, which is only correct during setup. - Return the reactive surface, not snapshots. Return
count, notcount(). - 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.
import { createContext } from '@aihu/context'
export interface Api { base: string }
export const ApiContext = createContext<Api>({ base: '/api' })Provide it at a layer boundary:
@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:
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:
@state {
const [theme, setTheme] = signal('dark')
provide(ThemeContext, theme) // the signal itself, not its value
}@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:
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
<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
- Authoring Components — the SFC blocks in depth
- Reactivity — signals, computed, effects
- SSR & Hydration — the server rendering model
- @aihu/context · @aihu/use