react-decor

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.

demo-widgets.at.tsx
// bare — no props; the wrapper takes the target as children
@TintCard
export const DemoBareNote = () => <p>{'Wrapped in a card.'}</p>
Live result

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).

demo-widgets.at.tsx
// 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" />
Live result

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.

demo-widgets.at.tsx
// JSX — parens required (bare @<Badge /> cannot parse)
@(<Badge variant="secondary" />)
export const DemoVersionBadge = () => <>{'v0.1.0'}</>
Live result
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>
)
Live result

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.

demo-widgets.at.tsx
// 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>
Live result
From a decorator arg.

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.

demo-widgets.at.tsx
// $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>
Live result
A named slot.

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.

why-strip.at.tsx
// 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.

back-link.at.tsx
// 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.

section-heading.at.tsx
// 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.

Live result
Parent renders: 1

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-prop

React 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-prop

i18next 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-decl

Decl 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-directive

A 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 instead

Local atoms

react-decor/no-local-atom

A 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-disable

Banned 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 tree

Declarations 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:

ToolShim
tsc --noEmitThe at-tsc bin — wraps the CompilerHost, recurses solution configs, and prints code frames from your raw text.
the editorreact-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.
ESLintThe react-decor/eslint flat-config helper — a virtualizing parser scoped to *.at.tsx (it also disables no-unused-expressions there).
PrettierNo 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.

RuleScopeWhat 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.

CodeMeaning
AT000Unrecognized syntax error (a TypeScript parse error passed through).
AT001Malformed inside-tree directive — escape literal directive-opener text with an expression (see Scoping rules).
AT002Wrapper element has children — it must be self-closing or an empty pair.
AT003Wrapper is not a capitalized component reference.
AT004Decorated declaration is not capitalized.
AT005Decorated anonymous default export.
AT006Decorator on a non-component statement.
AT007Dangling decorators — nothing wrappable follows.
AT008Call form takes exactly one object-literal argument.
AT009Unsupported property form in a call-form object.
AT011Decorator above a valueless return.
AT012Decorator expression is not a component reference, call, or JSX element (no fragments).
AT013Inside-tree target is not a JSX element or an {expression}.
AT014The generated <Name>_base identifier collides with an existing declaration.
AT015More 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.
AT017An inside-tree chain includes a $at slot directive — a slotted wrapper must be its chain's only link.