Barrel Files: The Import You Love and the Bundle You Hate

There's a specific kind of import that feels like good engineering:
import { Button, Card, Modal, Tooltip } from '@/shared/ui';
One line. One path. Clean. Compare it to the honest version:
import { Button } from '@/shared/ui/Button'; import { Card } from '@/shared/ui/Card'; import { Modal } from '@/shared/ui/Modal'; import { Tooltip } from '@/shared/ui/Tooltip';
The first one looks like a tidy public API. The second looks like clutter. So of course teams reach for the first — they add an index.ts that re-exports everything, and the whole folder gets a single front door. That file is a barrel, and it's one of the most quietly expensive habits in a React codebase. Not because it's wrong. Because it's right just often enough that people stop questioning it.
I brought this up at the end of the shared/ article and promised to come back to it. Here's the come-back.
What a barrel actually is
A barrel is just a file — almost always index.ts — whose only job is to re-export other modules:
// shared/ui/index.ts export { Button } from './Button'; export { Card } from './Card'; export { Modal } from './Modal'; export { Tooltip } from './Tooltip';
Now @/shared/ui resolves to this file, and consumers import named things from one place. The appeal is real: imports get shorter, the folder gets a defined surface, and you can move Button.tsx around internally without breaking any consumer, as long as the barrel keeps pointing at the right place. That last part — decoupling the internal file layout from the public import path — is a genuine architectural benefit, not just cosmetics. It's the same "public API of a folder" idea that makes feature boundaries work.
So far this sounds like something you'd want everywhere. That's exactly the trap.
The cost hides where you're not looking
Here's the problem. When you write import { Button } from '@/shared/ui', you are not importing Button. You are importing the barrel, and the barrel imports everything. Button, Card, Modal, Tooltip, and whatever those pull in — the whole folder loads to hand you one component.
"But tree-shaking removes the unused ones," you say. Sometimes. Tree-shaking is real, but it's fragile, and a barrel is exactly the shape that breaks it. All it takes is one module in the barrel with a side effect — a file that runs code at import time, registers something, mutates a global, or is marked as having side effects in package.json — and the bundler can no longer prove the other exports are safe to drop. To stay correct, it keeps them. Your one-line Button import just pulled in Modal, its focus-trap library, and the animation dependency Tooltip uses.
You won't see it in code review. The import line looks identical whether the tree shakes cleanly or not. You'll see it in a bundle analyzer six months later, staring at a route that weighs 400KB and wondering why a page with two buttons is importing a charting library. The answer is almost always a barrel somewhere in the chain, quietly gluing unrelated things together.
There's a second cost that shows up sooner: build and dev-server speed. Every barrel is a fan-out. Import one thing through @/shared/ui and the module graph has to resolve every file the barrel touches, and every file those touch. In a big app with barrels re-exporting barrels re-exporting barrels, you get a dependency graph that explodes on almost any import. Cold dev starts crawl. Hot updates touch more than they should. The tooling spends its time walking a graph you created for the sake of a shorter import line.
A barrel trades a real cost you can't see for a cosmetic benefit you can.
The worst offender: the app-wide barrel
The pattern that does the most damage is the ambition to have one barrel at a high level that re-exports a whole layer:
// features/index.ts — please don't export * from './edit-policy'; export * from './create-claim'; export * from './billing-dashboard'; export * from './user-settings'; // ...twenty more
Now import { EditPolicyForm } from '@/features' reaches into a barrel that transitively imports every feature in the app. Import one feature and you've referenced all of them. Code-splitting — the entire point of which is that a route loads only what it needs — quietly stops working, because from the bundler's view everything is connected to everything through this one file. I've watched a lazy-loaded route pull in the whole app because a single import went through a top-level barrel instead of reaching for the specific module.
The export * form is especially nasty. It's not just eager; it forces the bundler to load a module just to find out what names it exports, so it can figure out which star-export a given name came from. Named re-exports (export { X } from './x') are at least explicit. export * is a blank check.
When a barrel earns its place
I'm not anti-barrel. I use them. The question is never "barrels: yes or no." It's "does this specific barrel pay for itself." A barrel earns its place when three things are true at once.
It wraps a genuine, stable public API. A design-system package where Button, Card, and Modal are the whole point and consumers legitimately pull from across it — that's a real interface, and a barrel is the right way to express it. The barrel isn't hiding a mess; it's naming a surface that was always meant to be used as a whole.
It's small and internally cohesive. A barrel over five related components in one feature is cheap — importing one probably means you're near the others anyway, and the fan-out is tiny. The cost of a barrel scales with what's behind it. Small folder, small cost.
Nothing behind it has surprising weight or side effects. Pure, light modules tree-shake fine through a barrel. The danger is heavy or effectful modules — a component that drags in a 200KB dependency, a file that runs setup at import. One of those behind a barrel poisons every import that passes through it.
Miss any of those and the barrel is working against you. A top-level @/features barrel fails all three: not a stable surface, not small, and full of heavy things. A shared/ui/index.ts over a real design system passes all three. Same mechanism, opposite verdict — which is the whole reason "always use barrels" and "never use barrels" are both wrong.
What I actually do
My rules are boring, which is how I like architecture rules.
Import from the specific file by default. import { Button } from '@/shared/ui/Button' is my normal. The import is three words longer and I never think about it again. No fan-out, no tree-shaking gamble, no accidental coupling.
// default: reach for the exact thing import { Button } from '@/shared/ui/Button'; import { formatCurrency } from '@/shared/lib/money';
Add a barrel only at a real boundary I've decided to publish. A feature's public entry point, a design-system package's root — places where "this is the surface, use it as one" is a deliberate architectural statement, not a convenience. And when I do, I write explicit named re-exports, never export *:
// features/edit-policy/index.ts — a deliberate public surface export { EditPolicyForm } from './ui/EditPolicyForm'; export { useEditPolicy } from './model/useEditPolicy'; // internal helpers are NOT re-exported — they stay private
That barrel is doing architectural work: it defines what the feature exposes and hides everything else, so the rest of the app can't reach into the feature's guts. That's a barrel as an encapsulation boundary, which is worth having. A barrel as a typing convenience usually isn't.
Never barrel a whole layer. No @/features, no @/entities mega-barrel. Layers are not modules; they're categories. Giving a category a single import door is how code-splitting dies.
Verify with the analyzer, not with faith. Tree-shaking is a claim, not a guarantee. Run a bundle analyzer, find the route that's too heavy, and trace it. More often than not the culprit is a barrel doing exactly what I described — and the fix is changing one import from the barrel to the specific file.
The real lesson isn't about barrels
Strip away the mechanics and this is the same idea as the shared/ article, pointed at a different symptom. A barrel is a public API for a folder. Public APIs are worth having when there's a real boundary to protect and a real surface to name. They're pure cost when you add them out of habit, to make imports prettier, over a pile of things that were never meant to be used as a unit.
So the question to ask isn't "should this folder have an index.ts." It's "does this folder have a public API worth defining — and am I willing to pay the fan-out to define it." When the answer is yes, barrel it, name the surface, and hide the rest. When the answer is "I just want shorter imports," reach for the specific file and move on. The prettier import was never worth the bundle you couldn't see.
Next in this stretch on architecture, I want to get at the thing underneath both of these articles: how modules should depend on each other at all — the direction of the arrows, who's allowed to import whom, and why a dependency graph is the most honest picture of your app's health. If you've got a bundle that mysteriously balloons on one route, open the analyzer and go hunting for a barrel — then tell me what you found. I'm always curious how far the fan-out reached.