Decorators that compile away
react-decor gives React function components raw @ decorators — bare, call-form, or JSX — and compiles every one into the nested JSX you would have written by hand. No HOCs, no runtime, nothing extra in the bundle.
You write
@Card
@Labeled({ text: 'Email address' })
export function EmailInput(props: InputProps) {
return <input type="email" {...props} />
}It ships as
function EmailInput_base(props: InputProps) {
return <input type="email" {...props} />
}
export function EmailInput(props) {
return (
<Card>
<Labeled text={'Email address'}>
<EmailInput_base {...props} />
</Labeled>
</Card>
)
}Why react-decor
Composition without the pyramid
Wrappers move out of the tree and onto declarations — what remains is flat, scannable, and enforced by tooling.
Zero runtime
Decorators are erased at build time into plain nested JSX. No library code ships — the output is exactly the hand-written equivalent.
Toolchain green
at-tsc, the tsserver plugin, and the ESLint parser all see valid TypeScript — hover, go-to-def, and completions keep working inside decorators.
Lint-enforced conventions
Eight react-decor rules turn the composition conventions into errors: Slot boundaries, props channels, local atoms, pass-through layers, and more.
Readable sections
One wrapper per atom, keys at the callsite, zero-prop composition — sections read top-down instead of inside-out.
This grid is compiled from — view its source
@StaggerItem({ className: 'border-border bg-card/60 flex h-full flex-col gap-3 rounded-xl border p-5' })
const FeatureTile = ({ item }: { item: (typeof FEATURES)[number] }) => {
const { t } = useTranslation()
const Icon = item.icon
return (
<>
<div className="bg-primary/10 text-primary flex size-10 items-center justify-center rounded-lg">
<Icon className="size-5" aria-hidden="true" />
</div>
<h3 className="text-base font-semibold tracking-tight">
{t(`home.why.items.${item.key}.title`)}
</h3>
<p className="text-muted-foreground text-sm">{t(`home.why.items.${item.key}.body`)}</p>
</>
)
}
@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} />)Before / after
The same section, without the nesting
A real stats band: every wrapper as a nested element on the left; the identical section authored with react-decor on the right.
Nested wrappers
// Every wrapper nests the next — the leaf is buried four levels deep,
// and the section reads inside-out.
export function StatsBand() {
const { t } = useTranslation()
return (
<SectionShell data-section="stats" className="border-y">
<Region label={t('stats.region')}>
<Stagger className="grid gap-8 sm:grid-cols-4">
{STATS.map((stat) => (
<StaggerItem key={stat.key} className="flex flex-col gap-2">
<StatValue stat={stat} />
</StaggerItem>
))}
</Stagger>
</Region>
</SectionShell>
)
}Decorated declarations
// Identical output. Wrappers ride declarations; the key rides the map's
// callsite; the one hook-reading wrapper rides the return.
@StaggerItem({ className: 'flex flex-col gap-2' })
const StatTile = ({ stat }: { stat: Stat }) => <StatValue stat={stat} />
@Stagger({ className: 'grid gap-8 sm:grid-cols-4' })
const StatsBandList = () => STATS.map((stat) => <StatTile key={stat.key} stat={stat} />)
@SectionShell({ 'data-section': 'stats', className: 'border-y' })
export function StatsBand() {
const { t } = useTranslation()
@(<Region label={t('stats.region')} />)
return <StatsBandList />
}The decorated column compiles into exactly the markup of the nested column — the decorators are erased at build time.
Quick start
Four files and you're green
react-decor rides your existing Vite + TypeScript + ESLint setup — one plugin per tool, scoped to *.at.tsx files.
Vite plugin
The transform runs enforce:'pre' and only touches *.at.tsx files. Import it by its bare specifier — the whole chain is plain CommonJS.
// BARE specifier by design — the chain is plain CommonJS;
// a relative import would get bundled into the config.
import reactDecor from 'react-decor/vite'
export default defineConfig({
plugins: [reactDecor(), reactRouter(), tailwindcss()],
})tsserver plugin
The editor shows your raw text while the language service type-checks the virtualized code. Select the workspace TypeScript once.
// The editor shows raw text; the language service sees
// valid code (use the workspace TypeScript).
"plugins": [
{ "name": "@react-router/dev" },
{ "name": "react-decor-ts-plugin" }
]ESLint helper
reactDecorEslint() scopes the virtualizing parser and the full rule set to *.at.tsx — while no-key-prop and no-t-prop guard every React file.
// Virtualizing parser + full rule set on *.at.tsx;
// no-key-prop / no-t-prop apply to every React file.
import { reactDecorEslint } from 'react-decor/eslint'
export default tseslint.config(
/* …type-aware blocks… */
reactDecorEslint(),
prettier,
)at-tsc + Prettier
at-tsc wraps the compiler for CLI type-checks, with code frames from your raw text. Prettier can't parse the files — ignore them and hand-format.
// package.json — at-tsc wraps tsc with raw-text code frames
"typecheck": "react-router typegen && at-tsc -p tsconfig.app.json --noEmit"
// .prettierignore — no parse possible, hand-format these
*.at.tsxConventions
The patterns the docs teach
Three load-bearing conventions carry most of the value — each linked to its full treatment in the reference.
Ship the JSX, skip the pyramid
Read the full reference — grammar, placements, conventions, toolchain shims, and the complete error catalog.