How to Use /#PURE/ Annotations for Side-Effect-Free Calls

This guide covers a precise tool in Tree Shaking and Dead Code Elimination, part of JavaScript Bundle Optimization & Code Splitting. Bundlers can remove an unused export const Button = ... when the right-hand side is a literal or a function expression. When it is a call — createIcon(...), styled.button(...), defineComponent(...), memo(Component) — the bundler cannot know whether the call has side effects, so it keeps it, along with everything the call references, even if Button is never imported.

The /*#__PURE__*/ (or /*@__PURE__*/) comment placed immediately before a call tells bundlers and minifiers that the call has no side effects: if its result is unused, the whole call can be removed. It is understood by Rollup, webpack (via Terser), esbuild, SWC and Terser. Compilers like Babel's React preset and styled-components' Babel plugin emit it automatically for JSX and styled declarations; hand-written factory code usually lacks it.

An unused export with and without the annotation Comparison of how a bundler treats an unused export initialised by a function call, with and without a PURE annotation. An unused export with and without the annotation export const X = factory() • Call might have side effects • Kept even if X is unused • factory and its dependencies kept export const X = /#PURE/ factory() • Call declared side-effect free • Dropped if X is unused • factory can be removed too if unused elsewhere

Rapid Diagnosis

  • Find module-scope calls in shared libraries. Search for export const \w+ = \w+\( patterns in design systems, icon packages and utility libraries.
  • Test removal. Import one export from the module in a test entry; if other exports' factory calls remain in the output, annotations (or sideEffects) are missing.
  • Check generated code. Compiled output from Babel or TypeScript may wrap expressions in helpers (_interopRequireDefault(...), __decorate(...)) without annotations.
  • Check the minifier output. With annotations present, minifiers drop unused calls even within a single chunk.

Root Cause Analysis

1. Calls are presumed effectful. Any call could mutate globals, register things or perform I/O; tools must be conservative.

2. Higher-order components and factories. memo(), forwardRef(), connect()(...), withStyles()(...) and component factories all produce values through calls.

3. Compiled helpers. Transpilers inject helper calls (class creation, decorators, interop) that are not annotated.

4. Static class members. class A { static config = buildConfig(); } runs the call when the class is defined — effectively module-scope code.

Icon package output when importing 12 of 1,400 icons Bar chart of bundle size for a generated icon package with and without PURE annotations on its factory calls. Icon package output when importing 12 of 1,400 icons No annotations 298KB sideEffects false only 296KB PURE on createIcon calls 5KB sideEffects alone did not help because all icons live in one module; the PURE annotations let the minifier drop the unused calls within it.

Step-by-Step Resolution

1. Annotate module-scope factory calls

javascript
// icons.js — generated: one export per icon.
export const SearchIcon = /*#__PURE__*/ createIcon('search', 'M10 2a8 8 0 1 0 5.3 14l5 5 1.4-1.4-5-5A8 8 0 0 0 10 2z');
export const CloseIcon  = /*#__PURE__*/ createIcon('close',  'M5 5l14 14M19 5L5 19');
// trade-off: the annotation is a promise. If createIcon ever starts registering
// icons globally, unused icons will silently disappear from the registry.

Expected outcome: unused icons, components or styled declarations are removed by the minifier.

2. Annotate higher-order component wrappers

javascript
export const Card = /*#__PURE__*/ memo(/*#__PURE__*/ forwardRef(function Card(props, ref) {
  return <div ref={ref} className="card" {...props} />;
}));
// trade-off: annotations must sit directly before each call expression; an
// annotation before memo() does not cover the inner forwardRef() call.

Expected outcome: unused wrapped components are dropped.

3. Let tooling add annotations where it can

Enable compiler options that emit annotations automatically: React's JSX runtime calls are annotated; babel-plugin-styled-components (with pure: true) and the SWC styled-components plugin annotate styled declarations; Rollup's treeshake.manualPureFunctions lets you declare functions as pure by name without editing call sites.

javascript
// rollup / vite config
export default { build: { rollupOptions: { treeshake: { manualPureFunctions: ['createIcon', 'styled', 'defineMessages'] } } } };
// trade-off: manualPureFunctions applies to every call of those names in the
// build, including third-party code that might use the same name differently.

Expected outcome: annotations without touching every file.

Where PURE annotations come from Sources of PURE annotations in a typical build and whether they are automatic. Where PURE annotations come from Source Automatic? Notes JSX compiled by Babel/SWC/esbuild yes Element creation calls annotated styled-components plugin with pure option Babel or SWC plugin Rollup manualPureFunctions config By function name Hand-written factories no Add comments or rely on config

4. Combine with sideEffects for file-level removal

Annotations work within modules; sideEffects works on whole modules. Large libraries need both: sideEffects so unused files are skipped, annotations so unused exports inside used files are dropped.

Verification

Build a fixture that imports one export and inspect the minified output: other annotated calls should be gone. Run your test suite against the production build to confirm no behaviour depended on a removed call. For libraries, add the fixture to CI as described in building tree-shakeable libraries with Vite library mode.

Worked Example: A Message Catalogue

An internationalisation library defined messages with export const greeting = defineMessage({ id: 'greeting', defaultMessage: 'Hello' }) across hundreds of modules. Each defineMessage call simply returned its argument, but bundlers kept every one, along with the default strings, inflating each route by tens of kilobytes of unused messages. Adding defineMessage to manualPureFunctions removed unused messages from every route without editing a single call site. The team confirmed with the library authors that defineMessage had no side effects before relying on it.

Common Mistakes

  • Annotating calls that do have side effects. The code disappears and the side effect with it — a subtle, production-only bug.
  • Placing the comment in the wrong spot. It must immediately precede the call expression (/*#__PURE__*/ fn()), not the declaration or the export.
  • Losing annotations in your own build. Some minifier or transpiler settings strip comments before bundling; check that annotations survive into the bundler's input.
  • Expecting annotations to remove used code. They only affect calls whose results are unused.

Edge Cases Worth Knowing

Annotations on new expressions. /*#__PURE__*/ new Map() is valid and lets minifiers drop unused instances — useful for module-level caches and registries that may never be used.

Annotations lost in transpilation. Some transforms rewrite call expressions (for example, optional-call lowering or decorator transforms) and drop leading comments. If annotations seem ignored, inspect the code the bundler actually receives, not your source.

Arguments with side effects. An annotated call is removed together with its arguments. If an argument expression itself has side effects — /*#__PURE__*/ wrap(registerGlobal()) — that side effect disappears too. Keep arguments to annotated calls free of effects.

Interaction with sideEffects. A module listed as side-effect free can be dropped entirely if none of its exports are used, regardless of annotations. Annotations matter for modules that are included because some of their exports are used — they remove the unused rest.

FAQ

Is /*@__PURE__*/ different from /*#__PURE__*/?

They are equivalent; tools accept both. The # form avoids clashing with JSDoc-style @ tags and is the more common convention today.

What about the newer __NO_SIDE_EFFECTS__ annotation?

Rollup and some other tools support /*#__NO_SIDE_EFFECTS__*/ on a function declaration, marking every call of that function as pure — the declaration-level equivalent of annotating each call. Support is less universal than PURE, so check your toolchain before relying on it.

Do annotations help webpack without Terser?

webpack's own module-level analysis uses sideEffects; removing an unused annotated call within a module is done by the minifier (Terser or esbuild via a plugin). Without minification, annotated but unused calls remain in the output.

Can a linter check that annotated functions really are pure?

Not in general — purity is a semantic property. What helps is convention: keep annotated factories in a small, reviewed module, document that they must not touch globals, and add unit tests asserting that calling them leaves no observable state behind (no registry entries, no globals). Review changes to those factories with the annotation in mind.

Do annotations affect development builds?

No in practice. Development builds typically skip minification and aggressive tree shaking, so annotated unused calls remain and run as normal. That is another reason to test the production build: a call that should not have been annotated only disappears there.