Initializing Enclave...

How to Fix CRA Babel 'Experimental Decorators' Error Without Ejecting

Threat/Impact Level: HIGH | Downtime Risk: HIGH | Time to Fix: 10–20 mins


TL;DR

  • What broke: CRA's internal Babel config does not support @decorator syntax out of the box; your build dies with a parser error before a single component renders.
  • How to fix it: Install @babel/plugin-proposal-decorators and @babel/plugin-proposal-class-properties, wire them in the correct order via customize-cra or craco, and set experimentalDecorators: true in tsconfig.json.
  • Fastest path: Use our Client-Side Sandbox below to auto-refactor this — paste your babel.config.js, craco.config.js, or the offending source file and get a corrected output instantly.

The Incident (What does the error mean?)

You hit this wall:

SyntaxError: /src/stores/UserStore.ts: Support for the experimental syntax
'decorators-legacy' isn't currently enabled (12:1):

  10 | import { observable, action } from 'mobx';
  11 |
> 12 | @observable
     | ^
  13 | export class UserStore {

Add @babel/plugin-proposal-decorators (https://git.io/vb4yO) to the
'plugins' section of your Babel config to enable transformation.

Immediate consequence: Webpack compilation halts at the first decorated symbol. Your entire dev server or production build is dead. If this slips into a CI pipeline, every downstream deploy is blocked. MobX, TypeScript reflect-metadata, Angular-style DI patterns, and typeorm entities all trigger this.


The Attack Vector / Blast Radius

CRA enforces a locked, opinionated Babel config inside react-scripts. It deliberately excludes decorator plugins because the TC39 proposal has been in flux (legacy Stage 1 vs. the new Stage 3 spec). The two decorator proposals are mutually incompatible — using the wrong one against your library (e.g., MobX 6 expects legacy mode) produces either a silent mis-transform or a hard crash.

Cascading failure chain:

  1. Developer adds @observable or @Component → local build breaks.
  2. Team assumes it's a TypeScript config issue → wastes hours in tsconfig.json.
  3. Someone ejects CRA to get Babel access → you now own the entire Webpack config forever. Ejection is irreversible and turns a 3-file project into 30 files of unmaintained config debt.
  4. If using typeorm or class-validator in a monorepo with a shared tsconfig, the misconfiguration propagates to the backend build as well.

The plugin-proposal-class-properties must come after plugin-proposal-decorators in the plugins array. Reversing the order produces a different, harder-to-diagnose error about class fields.


How to Fix It (The Solution)

Option A — craco (Recommended, No Eject)

Install dependencies:

npm install -D @craco/craco @babel/plugin-proposal-decorators @babel/plugin-proposal-class-properties
# Replace react-scripts in package.json scripts with craco

craco.config.js — diff:

+ const { addBabelPlugin, override } = require('customize-cra');
+
+ module.exports = {
+   babel: {
+     plugins: [
+       ["@babel/plugin-proposal-decorators", { "legacy": true }],
+       ["@babel/plugin-proposal-class-properties", { "loose": true }]
+     ]
+   }
+ };

package.json scripts — diff:

- "start": "react-scripts start",
- "build": "react-scripts build",
- "test":  "react-scripts test",
+ "start": "craco start",
+ "build": "craco build",
+ "test":  "craco test",

tsconfig.json — diff:

  {
    "compilerOptions": {
+     "experimentalDecorators": true,
+     "emitDecoratorMetadata": true,
      "target": "ES6",
      "strict": true
    }
  }

Option B — customize-cra wrapper (legacy projects already using it)

- const { override } = require('customize-cra');
+ const { override, addBabelPlugin } = require('customize-cra');

  module.exports = override(
+   addBabelPlugin(["@babel/plugin-proposal-decorators", { legacy: true }]),
+   addBabelPlugin(["@babel/plugin-proposal-class-properties", { loose: true }])
  );

⚠️ Plugin order is load-bearing. decorators must be index 0. Swapping them produces Error: @babel/plugin-proposal-decorators must be placed before @babel/plugin-proposal-class-properties.


Enterprise Best Practice — Enforce Babel Config in Monorepo via babel.config.json at root

In an Nx or Turborepo monorepo, a per-package .babelrc is silently ignored for files outside the package root. Use a root-level babel.config.json (not .babelrc) so the transform applies to all workspace packages:

- // .babelrc (per-package, ignored for cross-package imports)
- {
-   "plugins": [["@babel/plugin-proposal-decorators", { "legacy": true }]]
- }

+ // babel.config.json (repo root — applies everywhere)
+ {
+   "presets": [["@babel/preset-env"], ["@babel/preset-typescript"]],
+   "plugins": [
+     ["@babel/plugin-proposal-decorators", { "legacy": true }],
+     ["@babel/plugin-proposal-class-properties", { "loose": true }]
+   ]
+ }

💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.


Prevention in CI/CD

1. Fail fast in the pipeline — lint Babel config on every PR:

# .github/workflows/ci.yml
- name: Validate Babel decorator plugin order
  run: |
    node -e "
      const cfg = require('./babel.config.json');
      const plugins = cfg.plugins.map(p => Array.isArray(p) ? p[0] : p);
      const di = plugins.indexOf('@babel/plugin-proposal-decorators');
      const ci = plugins.indexOf('@babel/plugin-proposal-class-properties');
      if (di === -1) { console.error('MISSING decorator plugin'); process.exit(1); }
      if (ci !== -1 && ci < di) { console.error('WRONG plugin order'); process.exit(1); }
      console.log('Babel decorator config OK');
    "

2. Enforce experimentalDecorators in tsconfig.json via a custom ESLint rule or tsc --noEmit gate:

# In CI, before build:
npx tsc --noEmit
# tsc will error on decorator usage if experimentalDecorators is absent

3. Pin Babel plugin versions in package.json with exact versions — the legacy: true vs new decorator spec behavior changed between @babel/[email protected] and 7.23. An unpinned install after a npm update can silently switch behavior:

- "@babel/plugin-proposal-decorators": "^7.21.0"
+ "@babel/plugin-proposal-decorators": "7.23.9"

4. Add a postinstall check in package.json to assert plugin presence — catches teammate installs that forget the dep:

"scripts": {
  "postinstall": "node -e \"require('@babel/plugin-proposal-decorators')\""
}

Related Diagnostics

"Part of the Syntax Utility Matrix."

View all 153 Syntax Tools →