August 6, 2026
Barrel Files: Why index.ts Re-Exports Hurt Tree Shaking, Next.js Dev Memory, and tsc (2026)
A barrel file is an index.ts whose only job is to re-export other modules, so consumers can write one tidy import instead of five. Almost every TypeScript codebase has them; almost every npm library ships one as its entry point. They look like free organization — and for years the ecosystem treated them that way.
They are not free. Barrel files are quietly behind three of the most common performance complaints in modern React development: bundles that don’t tree-shake the way you expect, Next.js dev servers and tsc runs that get slower and hungrier as the app grows, and circular-dependency bugs that surface as Cannot access 'X' before initialization. We maintain @reactuses/core, a library of 120+ React hooks behind exactly one such barrel, and we recently had to rebuild our entire dist layout because of it — a Next.js dev page importing a single hook was pulling a 552 kB client chunk that dropped to 64 kB once the barrel was fixed. This post explains the mechanics behind all three problems and what to do about them, from both sides of the node_modules boundary.
What Is a Barrel File?
A barrel file gathers a directory’s public surface into one module:
// src/hooks/index.ts — the "barrel"
export * from './useAuth';
export * from './useCart';
export * from './useCheckout';
export * from './useAnalytics';
// …forty more
Consumers then import from the directory instead of the file:
import { useAuth } from '@/hooks'; // via the barrel
// instead of
import { useAuth } from '@/hooks/useAuth'; // direct
Published libraries do the same thing at package scale: the main/exports entry of react-use, lodash-es, @mui/material, date-fns — and yes, @reactuses/core — is a barrel that re-exports every public module. One import specifier, one autocomplete namespace, one place to define the public API. That’s the appeal.
The cost comes from a fact that’s easy to forget: a module import is not a symbol lookup, it’s a graph traversal. When any runtime or tool — a bundler, Node, tsc, the TS language server — resolves import { useAuth } from '@/hooks', it must load the barrel, and the barrel’s body says “evaluate all forty-four of my children.” The import of one symbol has become an import of everything, and everything those things import, transitively. Every problem in this post is that one sentence wearing a different costume.
Harm #1: Tree Shaking Becomes Fragile (or Silently Impossible)
Tree shaking is dead-code elimination driven by the static structure of ES modules: the bundler builds the full module graph, marks which exports are actually used, and drops the rest. In theory a barrel is transparent to this — export * is statically analyzable, and a good bundler can trace useAuth through the barrel to its home module and discard the siblings.
In practice, the theory has conditions, and barrels are where they go to die:
Side effects poison the whole barrel. A bundler may only drop a module if doing so is unobservable. If one module in the barrel runs top-level code — patches a global, registers a custom element, calls injectGlobalStyles(), even constructs a Map the bundler can’t prove pure — the bundler must keep it, along with anything it imports. The sideEffects: false field in package.json is the library author’s promise that lets bundlers skip this analysis; forget it (or set it wrong) and a 200-module barrel is bundled pessimistically. One misbehaving module taxes every consumer of every other module, because the barrel wired their fates together.
CommonJS output turns the shaking off entirely. Tree shaking needs ESM’s static import/export. If your package’s entry resolves to CJS (an old main field, a misconfigured exports map, a tool that transpiled your ESM to require calls), the bundler sees dynamic property access on module.exports and keeps everything. A CJS barrel of 120 hooks is your bundle, whatever you imported.
Transpiler artifacts defeat purity analysis. Class fields, decorators, and enums often compile to top-level IIFEs and assignments that look side-effectful. Without /*#__PURE__*/ annotations, the bundler keeps them — and in a barrel, “them” means every module in the graph, not just the one you imported.
And in dev mode, none of this runs anyway. This is the part that surprises people: tree shaking is a production optimization. Dev servers — webpack in dev, Next.js dev, Vite’s on-demand transform of a pre-bundled dependency — do not shake. They resolve and execute the graph as written. Importing one hook through a barrel in dev means loading, transforming, and evaluating the entire library, every cold start, on every page that touches it. Which brings us to the second harm.
Harm #2: Next.js Dev and tsc Pay the Full Graph, in Time and Memory
Here is the measurement that forced our rebuild. A Next.js App Router page in dev mode, importing exactly one hook:
'use client';
import { useDebounce } from '@reactuses/core';
The dev-mode client chunk for that page: 552 kB. Not because useDebounce is big — it’s a few hundred bytes around a setTimeout — but because the package entry was a barrel and dev mode doesn’t shake, so the page compiled and shipped all 120+ hooks, including the heavyweight ones that drag in QR-code generation and file-saving dependencies the page never referenced.
Multiply this pattern across a real app — a few component libraries, an icon package, a date library, your own @/components and @/utils barrels — and you get the familiar symptoms that people rarely attribute to their imports:
- Slow cold compiles and route transitions in dev. Next.js compiles pages on demand; every barrel in a page’s import graph multiplies the number of modules to resolve, transform, and cache. Thousands of extra modules per page is common. The webpack-based dev server also holds all of these module records, transformed source, and source maps in memory — this is a big slice of the multi-gigabyte
next devprocesses people complain about, and why memory climbs as you visit more routes. tsctime and memory scale with the graph, not your code. The type checker must load, bind, and check every file reachable from your entry points. Barrels make everything reachable. A type-only reference to one hook still parses 120 modules and their.d.tsdependency chains. The same applies to the TS language server in your editor — the “why does VS Code need 4 GB for this project” problem is very often a module-graph problem, and barrels are the graph’s fan-out points.- Test startup pays it too. Jest and Vitest resolve imports per test file. A unit test importing one helper through a barrel evaluates the whole barrel — a classic reason trivial test suites take seconds per file to boot.
optimizePackageImports — and the catch we hit
Next.js ships a direct countermeasure: optimizePackageImports. Listing a package there makes the compiler rewrite barrel imports into direct per-module imports at build time:
// you write
import { useDebounce } from '@reactuses/core';
// the compiler resolves through the barrel and emits (conceptually)
import { useDebounce } from '@reactuses/core/dist/useDebounce/index.mjs';
Best of both: ergonomic imports in source, no barrel traversal in the compiled graph. Many popular libraries (lucide-react, @mui/icons-material, date-fns, …) are on the default list.
But there’s a precondition the docs understate, and it’s the one that bit us: the optimizer can only unroll a barrel into files that actually exist. It works by statically analyzing the package’s entry and mapping each named export to the real module file that defines it. Until recently, @reactuses/core’s published dist was one inlined bundle — our source had per-hook files, but the build tool (bunchee) compiled the whole library into a single index.mjs. From the optimizer’s perspective every export was defined in the entry itself. There was nothing to unroll, no matter what the consumer configured. A barrel is only optimizable if it’s a thin barrel — pure re-exports over real per-module files — all the way into dist.
Harm #3: Barrels Breed Circular Dependencies
The third cost isn’t performance, it’s correctness. Barrel files are the single most common way import cycles enter a codebase, because they add a hidden edge to every import that goes through them.
The trap looks like this:
// hooks/index.ts
export * from './useAuth';
export * from './useCart';
// hooks/useCart.ts — author wants useAuth, imports it "the tidy way"
import { useAuth } from '.'; // ← through the barrel, not './useAuth'
export function useCart() { const user = useAuth(); /* … */ }
The cycle is now index.ts → useCart.ts → index.ts. The author never wrote “useCart depends on the whole hooks directory,” but that’s what the import says — and every future module added to the barrel silently joins useCart’s dependency graph, and vice versa. Auto-import makes this worse: editors happily complete from the barrel, so cycles accrete without anyone choosing them.
Sometimes the cycle is harmless and you never notice. Whether it bites depends on evaluation order — which module the runtime happens to start evaluating first — and that’s exactly the kind of thing that differs between your bundler, Node, and Jest:
- ESM: imports are hoisted live bindings, so mutually recursive functions work — but a
const/arrow-function export read during the cycle throws the infamousReferenceError: Cannot access 'useAuth' before initialization(temporal dead zone). Typically it appears only in one tool (“works in Vite, dies in Jest”) because evaluation order differs. - CJS: no TDZ, something worse — the partially-initialized
exportsobject. Mid-cycle imports are silentlyundefined, and you getTypeError: useAuth is not a functionat call time, far from the actual cause, orextends undefinedfor classes.
Cycles also degrade tooling quietly: bundlers can’t code-split modules locked in a cycle (they must land in the same chunk), and HMR invalidation spreads through cycle members, making dev updates slower. The graph problem and the correctness problem are the same problem.
What To Do Instead
In application code
- Import from the module, not the barrel, inside the same package. The rule that prevents both the graph blowup and the cycles: barrels are for external consumers; internal code imports siblings directly (
./useAuth, not.). Lint it:import/no-cyclecatches the cycles (worth its cost in CI), andeslint-plugin-no-barrel-files/import/no-internal-modulescan enforce a policy either way. - Question each barrel’s existence. A barrel that groups five cohesive files is fine. App-wide
components/index.tswith 300 exports is a bomb wired to every page. If a barrel exists only so imports “look clean,” TypeScript path aliases (@/components/Button) give you short imports with none of the graph cost. - In Next.js, list heavy barrel packages in
optimizePackageImports— and verify it worked by checking the dev chunk size, because (as above) not every package ships an optimizable dist.
As a library author
This is our side of the fence, and what #216 changed in @reactuses/core:
-
Ship per-module files, not an inlined bundle. In Rollup terms
preserveModules; in tsdown it’s one flag. Our whole config:// tsdown.config.ts import { defineConfig } from 'tsdown'; export default defineConfig({ entry: ['src/index.ts', 'src/useQRCode/index.ts'], format: ['esm', 'cjs'], dts: true, unbundle: true, // one output file per module — the entry stays a real barrel target: 'es2015', platform: 'neutral', });distnow mirrorssrc:dist/useDebounce/index.mjs,dist/useLocalStorage/index.mjs, …, withdist/index.mjsa genuinely thin barrel of re-exports. (We switched tools to get this: bunchee couldn’t emit unbundled output, and OOM’d when we tried to fake it with 120 separate entry points.) -
Declare
sideEffects: falseinpackage.json— true for a hooks library, and the single highest-leverage line for your consumers’ bundles. -
Add a subpath wildcard to
exports, so consumers who want to bypass the barrel entirely can:"./*": { "import": { "types": "./dist/*/index.d.mts", "default": "./dist/*/index.mjs" }, "require": { "types": "./dist/*/index.d.ts", "default": "./dist/*/index.js" } }Which enables the zero-barrel import form:
import { useDebounce } from '@reactuses/core/useDebounce'.
The result: the same Next.js dev page importing useDebounce went from a 552 kB chunk (every hook, because the barrel was an inlined bundle) to 64 kB (useDebounce and its actual dependency chain) — an 88% cut, with no change to the consumer’s code. optimizePackageImports finally had files to point at.
Takeaways
- A barrel file converts “import one thing” into “traverse everything.” That’s its entire cost model; every symptom follows.
- Tree shaking can see through barrels, but only if every module is side-effect-clean, ESM, and
sideEffectsis declared — and it never runs in dev, where you pay the full graph in compile time and memory (Next.js dev,tsc, TS server, Jest alike). - Never import through your own barrel from inside the package — that’s how cycles start, and cycles are why you see
Cannot access 'X' before initializationin one tool but not another. - Library authors: thin barrel over per-module dist files,
sideEffects: false, subpath exports. That combination is what makes consumers’ optimizers (likeoptimizePackageImports) actually work — an inlined single-file dist defeats them even when your source layout is perfect.
@reactuses/core ships 120+ SSR-safe, TypeScript-first hooks — since v6.5.0 with per-module dist, so you get useDebounce without paying for the other 119. Browse them at reactuse.com.