Reference
The react-decor reference
Grammar, placements, conventions, toolchain shims, enforcement, and the complete error catalog — with every pattern running live on this page.
Overview
react-decor adds raw @ decorators to React function components and compiles them away into pure nested JSX composition.
No function ever receives or returns a component at runtime — there are no HOCs, no wrapper factories, and no library code in the bundle. What ships is exactly the JSX you would have written by hand; the decorators only exist in your source.
Decorated files use the *.at.tsx filename convention. It scopes every tool — the Vite transform, at-tsc, the tsserver plugin, the ESLint parser, the Prettier exclusion — to a small, visible surface, and everything outside that surface stays plain TypeScript.
Three forms
A decorator is a bare reference, a call with one object literal, or a JSX element in parens — each shown with its live result.
Bare reference
No props. The wrapper receives the decorated target as its only children.
// bare — no props; the wrapper takes the target as children
@TintCard
export const DemoBareNote = () => <p>{'Wrapped in a card.'}</p>Wrapped in a card.
Call form
Exactly one object literal, passed as the wrapper's props — and type-checked against them (AT008 and AT009 guard the shape).
// call — ONE object literal, type-checked against the wrapper's props
@Labeled({ text: 'Email address' })
export const DemoEmailField = () => <Input type="email" name="email" placeholder="you@example.com" />JSX form
A full element in parens — the parens are required, since a bare @<Badge /> cannot parse. This is also the only legal form inside a JSX tree.
// JSX — parens required (bare @<Badge /> cannot parse)
@(<Badge variant="secondary" />)
export const DemoVersionBadge = () => <>{'v0.1.0'}</>Four placements
The top decorator of a stack is always the outermost wrapper, everywhere. Scoping is exactly what the compiled output implies.
Top-level declarations
Function or const arrow, export or export default — the canonical home for static wrappers. Decorator arguments evaluate in module scope.
// 1 — top-level declaration: the wrapper rides the component itself
@SectionShell({ bare: true, id: 'why' })
export function WhyStrip() {
return <WhyStripBody />
}Local declarations
Grammar-legal but forbidden by default: a local component gets a new identity every render, so React remounts its subtree each time. no-local-atom says hoist it to module scope.
// 2 — local declaration: grammar-legal, forbidden by no-local-atom —
// a local component gets a NEW identity every render, so React
// remounts its whole subtree. Hoist it to module scope.
export function PricingTable({ plans }: Props) {
@(<Card />)
const PlanTile = (plan: Plan) => <div>{plan.name}</div>
return plans.map((plan) => <PlanTile key={plan.id} {...plan} />)
}Above a return
Wraps the returned tree with the full function scope available — hooks, props, and locals can all appear in the directive's attributes.
// 3 — above a return: full function scope (hooks, props, locals)
export function StatsBand() {
const { t } = useTranslation()
@(<Region label={t('stats.region')} />)
return <StatsBandList />
}Inside the tree
JSX form only, at any depth — and it wraps exactly the next sibling element, nothing else.
// 4 — inside the tree: wraps EXACTLY the next sibling (JSX form only)
return (
<section>
@(<TintCard />)
<p>{'Only this sibling is wrapped.'}</p>
<p>{'This one is not.'}</p>
</section>
)Only this sibling is wrapped.
This one is not.
Scoping rules
Two scopes, three invariants, one escape hatch.
Top-level declaration decorators evaluate in module scope only — there is no props channel into their arguments. Return-position, in-tree, and (forbidden) local placements evaluate inside the function, so hooks and props are available there.
- Wrappers are childless — self-closing or an empty pair; the decorated target becomes their children (AT002).
- Wrappers and decorated declarations are capitalized component references (AT003, AT004).
- The top decorator of a stack is the outermost wrapper — reading top-down matches the emitted nesting.
In JSX text, the two-character directive opener would be parsed as syntax — render literal text through an expression instead:
// a literal "@(" in JSX copy must be an expression, or it parses
// as a directive (AT001 flags the malformed remainder)
<p>{'Use @( to open a JSX-form directive.'}</p>Target slots — $at
By default the decorated target becomes the wrapper's children. The $at sentinel routes it into a named prop instead — and hand-authored elements ride decorator args as ordinary props.
Elements as decorator props
Call-form arguments accept any expression — including JSX. The wrapper receives the element as a normal prop while the target still threads as children.
// decorator args take ANY expression — including elements
@SlotPane({ header: <em>{'From a decorator arg.'}</em> })
export const DemoElementArg = () => <p>{'The body is still children.'}</p>The body is still children.
Route the target into a named prop
Import $at from react-decor and write it as a prop value: the transform splices the target element there and emits the wrapper self-closing. Components whose API takes content via element, fallback, or content props become decoratable without adapters — and the slot prop is type-checked, a check the children slot never had.
// $at routes the decorated target into a NAMED prop — the wrapper
// emits self-closing, and the slot prop is type-checked
import { $at } from 'react-decor'
@SlotPane({ header: <em>{'A named slot.'}</em>, body: $at })
export const DemoTargetSlot = () => <p>{'Spliced into body={…}.'}</p>Spliced into body={…}.
Render props and stacks
The sentinel composes inside any expression — an arrow makes it a render prop — and stacks flow through it: every directive below a slotted one lands inside that slot. One $at per directive (AT015); never a prop name, shorthand, or spread (AT016); an in-tree slotted wrapper stands alone (AT017). Aliasing is fine — the sentinel resolves from the import binding.
// composes inside expressions (render props) and through stacks
@Boundary({ fallback: <Oops />, render: () => $at })
export function Feed(props: Props) { … }
// → <Boundary fallback={<Oops />} render={() => <Feed_base {...props} />} />
@Frame({ content: $at })
@Card
export function Panel(props: Props) { … }
// → <Frame content={<Card><Panel_base {...props} /></Card>} />The keyed-tile convention
The default for every mapped wrapper: extract the mapped item into a decorated tile, and put the key on the map's callsite — where lint can see it.
// tiles at module scope — stable identity across re-renders
@StaggerItem({ className: 'flex h-full flex-col gap-3 rounded-xl border p-5' })
const FeatureTile = ({ item }: { item: (typeof FEATURES)[number] }) => {
const { t } = useTranslation()
return (
<>
<h3>{t(`home.why.items.${item.key}.title`)}</h3>
<p>{t(`home.why.items.${item.key}.body`)}</p>
</>
)
}
// the mapped grid rides a decl-@Stagger Body atom; key on the callsite
@Stagger({ className: 'mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-4' })
const WhyStripBody = () => FEATURES.map((item) => <FeatureTile key={item.key} item={item} />)
// the section is pure composition
@SectionShell({ bare: true, id: 'why' })
export function WhyStrip() {
const { t } = useTranslation()
return (
<>
<SectionHeading eyebrow={t('…')} title={t('…')} />
<WhyStripBody />
</>
)
}At section scale this becomes the full-strip shape: module-scope tiles (stable identity), the mapped grid on a decl-@Stagger Body atom, and the section reduced to pure composition. One wrapper per atom with the decl form maximized — a return-position directive carries only what genuinely reads hooks or props, riding the consuming component's own return.
Picking a form for atoms
All-static attributes take the decl call form. Any attribute that reads props, hooks, or locals — an onClick, a computed to, a t() in an attribute, a className merge — rides the return-position JSX form instead. And when a per-instance decl wrapper is wanted anyway: there is no props channel into decl arguments, so specialize statically — several zero-prop atoms, each pinning its own values.
// no props channel into decl args — specialize statically instead
@Link({ to: '/work', viewTransition: true, className: BACK_LINK })
export const WorkBackLink = () => <BackLinkLabel />
@Link({ to: '/insights', viewTransition: true, className: BACK_LINK })
export const InsightsBackLink = () => <BackLinkLabel />Bake universal wrappers
When every usage of a component pairs it with the same wrapper, decorate the component's declaration once and delete the per-callsite directives — the pairing becomes part of the definition.
// every callsite paired SectionHeading with @(<Reveal />) — bake it
// into the declaration once and delete the per-callsite directives
@Reveal
export function SectionHeading(props: SectionHeadingProps) {
return <HeadingBlock {...props} />
}And no pass-through atoms: a component that only renders another bare atom is indirection to delete — react-decor/no-passthrough-atom rejects the layer outright.
Identity, live
The tiles below are module-scope decorated atoms. Click them to give them local state, then force parent re-renders — the counters survive, because the tiles' identity never changes.
Local tile state survives parent re-renders — stable identity is exactly what module-scope tiles buy.
// ✗ the tiles would remount on EVERY parent render — counters reset
export function IdentityDemo() {
@Button({ variant: 'outline' }) // react-decor/no-local-atom
const ClickTile = ({ id }: { id: string }) => <TileBody id={id} />
return TILE_IDS.map((id) => <ClickTile key={id} id={id} />)
}Declared locally instead, every parent render would remount the tiles and reset the counters — react-decor/no-local-atom rejects that shape at lint time.
The Slot boundary
Radix asChild wrappers clone their child element to inject className, handlers, and aria — so that child must stay a literal element.
A decorated atom compiles to a wrapper around <Name_base {...props} />. Put an asChild wrapper on the declaration and Slot clones the _base element — the injected props land on the label instead of the wrapper. It compiles green and renders broken, which is why no-aschild-decl-decorator exists (proven by a preserved RED fixture in the library's test suite).
// ✗ compiles green, renders broken: Slot clones <CtaLink_base />, so
// the Button's className/handlers land on the LABEL, not the wrapper
@(<Button asChild size="lg" />) // react-decor/no-aschild-decl-decorator
const CtaLink = () => <Link to="/docs">{'Read the docs'}</Link>// ✓ the pair stays in one return — the Link remains Slot's literal child
const DocsCta = () => {
const { t } = useTranslation()
@(<Button asChild size="lg" />)
return <Link to="/docs">{t('cta.docs')}</Link>
}The asChild wrapper itself may be a directive — only its direct child must remain literal. One escape exists: an atom that spreads its props onto its root element forwards the injection through, so the innermost decl-position asChild is allowed there (the lint rule detects the spread). Zero-prop atoms keep the return-position pair.
Footguns, cataloged
Each of these was hit in a real sketch before the rules existed. The chip names the rule (or config) that rejects the shape now.
key as a tile prop
react-decor/no-key-propReact reserves key: the prop arrives undefined, and dev builds log a console error on access. The map-side key is the only key React uses.
// ✗ React reserves key — it arrives undefined (dev logs an error)
const Tile = ({ key, item }: TileProps) => <ItemCard item={item} />
// ✓ the key rides the map's callsite element
ITEMS.map((item) => <Tile key={item.slug} item={item} />)t as a prop
react-decor/no-t-propi18next context exists so t is never prop-drilled — every atom reads its own via useTranslation().
// ✗ threading t through props — context already provides it
const Tile = ({ t, item }: { t: TFunction; item: Item }) => …
// ✓ every atom reads its own
const Tile = ({ item }: { item: Item }) => {
const { t } = useTranslation()
return <p>{t(`items.${item.key}.title`)}</p>
}Props selectors in decl args
react-decor/no-props-selector-declDecl arguments are module-scope only: a selector like to: ({ to }) => to passes that function itself to the wrapper as the prop value.
// ✗ decl args are module-scope: this passes the FUNCTION itself
// as the prop value — it never sees the component's props
@Link({ to: ({ to }) => to }) // react-decor/no-props-selector-decl
const NavLink = ({ to }: { to: string }) => <NavLabel />Static return directives
react-decor/no-static-return-directiveA return-position wrapper that reads nothing from function scope belongs on the declaration — scope analysis proves it (asChild pairs are exempt).
// ✗ reads nothing from scope — it belongs on the declaration
const Cta = () => {
@(<Reveal className="mt-10" />) // react-decor/no-static-return-directive
return <CtaButton />
}
// ✓ @Reveal({ className: 'mt-10' }) above the declaration insteadLocal atoms
react-decor/no-local-atomA decorated declaration inside a function body gets a new identity every render — its whole subtree remounts. Hoist it to module scope and pass what it closed over as props.
// ✗ new identity every render — the subtree remounts each time
export function Grid({ items }: Props) {
@(<Card />) // react-decor/no-local-atom
const Tile = (item: Item) => <span>{item.name}</span>
return items.map((item) => <Tile key={item.id} {...item} />)
}Disabling react/jsx-key
eslint-comments/no-restricted-disableBanned outright by the repo config: a keyed tile — or a key on the returned element, which the transform hoists onto the emitted wrapper — always removes the need for the disable.
// ✗ banned outright by the repo config
// eslint-disable-next-line react/jsx-key
return items.map((item) => <Tile item={item} />)
// ✓ a keyed tile — or key the returned element: the transform
// hoists it onto the emitted wrapper (the array element)How the toolchain stays green
Raw @ on a function component is invalid TypeScript (TS1206 is checker-level; no compiler flag exists). But TypeScript's parser accepts it — so a same-length, position-preserving swap turns every directive into valid, type-checkable code.
// raw source what the type-checker sees (same length, same spans)
@Labeled({ text }) !Labeled({ text }) // call: props type-checked
@Card ;Card // bare
@(<Badge tone="new" />) ;(<Badge tone="new" />) // JSX form
@(<TintCard />) { <TintCard />} // inside a treeDeclarations and returns become expression statements: @ becomes ; — or ! for the call form, which type-checks the props object against the component while keeping ReactNode-returning call results out of no-floating-promises. Inside trees, the opener/closer pair becomes an expression container (the sample above shows the exact swap). Because every swap preserves length, positions in diagnostics map 1:1 back to your raw text. Three shims apply exactly that swap:
| Tool | Shim |
|---|---|
| tsc --noEmit | The at-tsc bin — wraps the CompilerHost, recurses solution configs, and prints code frames from your raw text. |
| the editor | react-decor-ts-plugin in tsconfig plugins — the editor shows raw text while the language service sees virtual code; hover, go-to-def, and completions work inside decorators. Use the workspace TypeScript. |
| ESLint | The react-decor/eslint flat-config helper — a virtualizing parser scoped to *.at.tsx (it also disables no-unused-expressions there). |
| Prettier | No parse is possible — *.at.tsx goes in .prettierignore; long class strings live in formatted composer components instead. |
Lint enforcement
Eight rules ship with the library. reactDecorEslint() enables the full set on *.at.tsx; the last two are worth applying to every React file, decorated or not.
| Rule | Scope | What it catches |
|---|---|---|
| no-aschild-decl-decorator | *.at.tsx | The Slot boundary at declaration position — an asChild wrapper on a declaration silently misroutes the injected props onto the label. |
| no-props-selector-decl | *.at.tsx | No props channel into decl arguments — a function-valued selector is passed through as the prop itself, never evaluated against props. |
| no-static-return-directive | *.at.tsx | Scope analysis proves a return-position wrapper reads no props, hooks, or locals — it belongs on a declaration (asChild exempt). |
| no-local-atom | *.at.tsx | Decorated declarations inside function bodies remount their subtree on every parent render — hoist to module scope. |
| no-passthrough-atom | *.at.tsx | An undecorated component that renders one bare atom is indirection to delete. |
| no-stray-at-sentinel | *.at.tsx | $at outside directive arguments is a real identifier whose runtime value is undefined — it only has meaning where the transform replaces it. |
| no-key-prop | all React files | key is reserved by React and never arrives as a prop — keys ride the map's callsite element. |
| no-t-prop | all React files | t is context, not a prop — each atom reads its own useTranslation() (flags TFunction-typed members too). |
This repo's config additionally bans disabling react/jsx-key outright (eslint-comments/no-restricted-disable), and every other disable comment requires a written reason.
Error catalog
Every error carries a precise raw-file span and surfaces identically in the Vite build (code frame), at-tsc, the IDE (squiggle), and ESLint (a fatal parse error at commit time). AT010 is unassigned.
| Code | Meaning |
|---|---|
AT000 | Unrecognized syntax error (a TypeScript parse error passed through). |
AT001 | Malformed inside-tree directive — escape literal directive-opener text with an expression (see Scoping rules). |
AT002 | Wrapper element has children — it must be self-closing or an empty pair. |
AT003 | Wrapper is not a capitalized component reference. |
AT004 | Decorated declaration is not capitalized. |
AT005 | Decorated anonymous default export. |
AT006 | Decorator on a non-component statement. |
AT007 | Dangling decorators — nothing wrappable follows. |
AT008 | Call form takes exactly one object-literal argument. |
AT009 | Unsupported property form in a call-form object. |
AT011 | Decorator above a valueless return. |
AT012 | Decorator expression is not a component reference, call, or JSX element (no fragments). |
AT013 | Inside-tree target is not a JSX element or an {expression}. |
AT014 | The generated <Name>_base identifier collides with an existing declaration. |
AT015 | More than one $at sentinel in a single directive — the target fills exactly one slot. |
AT016 | $at in an invalid position — never a prop name, a shorthand, or inside a spread. |
AT017 | An inside-tree chain includes a $at slot directive — a slotted wrapper must be its chain's only link. |