The shared/ Folder Is Not a Place, It's a Promise

Jul 01, 2026
11 min read
The shared/ Folder Is Not a Place, It's a Promise

Open the shared/ folder in almost any React app that's been alive for a year. Count the files. Then try to explain, out loud, what the folder is for — not what's in it, what it's for. You'll stall. There's a date formatter, sure, and a Button. But there's also useUserPermissions, a formatPolicyNumber, half a checkout state machine, a legacyApiAdapter, and three hooks whose names start with use and end in a shrug.

That folder didn't get that way because someone was careless. It got that way because shared/ is the one folder in your app with no rule attached to it. Everything else has a reason to reject a file. shared/ accepts everything. And a folder that accepts everything isn't a category — it's a drain.

I already flagged this in the code structure article: shared/ is the most abused folder in React architecture. That piece told you the rule. This one is about why the rule is so hard to keep, and how to dig out once you've already lost.

Why the drawer fills up

The junk drawer isn't a discipline problem. If it were, telling people "be more careful about shared/" would fix it. It never does. The rot is structural, and it comes from three forces that all push the same direction.

The naming trap. You write a hook. Where does it go? If it has an obvious owner — features/edit-policy — the answer is easy. But a lot of code sits in the awkward middle: not clearly owned, not clearly generic. useDebouncedValue is generic. usePolicyDraftAutosave is owned. But useAutosave, the one that started generic and quietly grew a policyId parameter — that one has no obvious home. So it goes to shared/, because shared/ never says no.

The reuse reflex. The moment a second file wants something, the instinct is to "lift it up" so both can reach it. This feels like good engineering. It's usually premature. Two components in the same feature using one helper is not a reason to move that helper three layers up into shared/lib — it's a reason to keep it inside the feature. Reuse across a boundary is a real signal. Reuse within a boundary is just normal code living near the thing that uses it.

The path of least resistance. shared/ is at the bottom of the dependency graph, so everything can import from it. That makes it the safest place to drop something when you're in a hurry and don't want to think about ownership. No import will ever break. And that's exactly the problem: the folder that's safe to import from is also the folder that's frictionless to dump into.

Put those three together and you don't get a junk drawer because people are bad at their jobs. You get one because the folder is doing precisely what an unconstrained folder does under pressure. Delivery pressure always wins over categorization, and shared/ is where "I'll sort it later" goes to never be sorted.

shared/ is not a place you put things. It's a promise you make about them.

That reframing is the whole article. The promise is: this code is domain-agnostic and portable — it would make sense in a completely different product. Every file in shared/ is you signing that promise. The drawer fills up because people put things there without noticing they're signing anything.

The test that actually works

The rule I gave in the structure article still holds, and it's the only test I've found that survives contact with a real team:

A file belongs in shared/ only if you could move it to a different product at the same company and it would still make sense.

A Button? Moves fine. A formatCurrency? Moves fine. An http client with your auth interceptor? Borderline — it moves if the auth scheme is company-wide, stays if it's app-specific. A useUserPermissions hook that knows your permission strings? Doesn't move. It's dressed up as generic — it takes a permission key, it returns a boolean, it looks reusable — but it's welded to your domain's idea of what a permission is. That's not shared code. That's domain code wearing a generic costume.

Here's the tell, in code. This looks shared:

// shared/hooks/usePermission.ts export function usePermission(key: string): boolean { const { permissions } = useCurrentUser(); return permissions.includes(key); }

It isn't. useCurrentUser is a domain concept. The moment this hook reaches sideways into entities/user, it stopped being portable — it now depends on your app's notion of a user. Move it to a different product and it breaks. It belongs in entities/user, exported as part of that entity's public surface. What's left in shared/ should be the genuinely dumb primitive:

// shared/lib/includes.ts — portable, knows nothing export const hasKey = (keys: string[], key: string) => keys.includes(key);

The portability test isn't about whether code can be reused. Almost anything can be reused if you squint. It's about whether the code knows about your domain. Knowledge is the boundary, not usage.

Make the boundary a wall, not a suggestion

A rule that lives only in a code review is a rule you will lose. Reviewers get tired. New people don't know it. The pressure that fills the drawer doesn't take weekends off. So the direction of dependencies — lower layers never importing from higher ones — has to be enforced by tooling, not vigilance.

ESLint's no-restricted-imports gets you most of the way with zero extra dependencies:

// eslint.config.js export default [ { files: ['src/shared/**'], rules: { 'no-restricted-imports': [ 'error', { patterns: [ { group: [ '@/features/*', '@/entities/*', '@/widgets/*', '@/pages/*', '@/app/*', ], message: 'shared/ must not import from higher layers. If this file needs domain code, it does not belong in shared/.', }, ], }, ], }, }, ];

Now the usePermission example from earlier fails the build the instant it imports useCurrentUser. The error message isn't just "no" — it explains the promise: if this file needs domain code, it doesn't belong here. The lint rule becomes the reviewer who never gets tired and never forgets the rule. For richer needs — enforcing that features can't import each other, that entities stay isolated — a dedicated boundaries plugin does the same job with more expressive rules, but the built-in gets a small team surprisingly far.

The point isn't the exact config. It's that the promise stops depending on everyone remembering it. You made shared/ say no. That single change is the difference between a folder that stays small and one that quietly triples every quarter.

Digging out of one you already have

Prevention is easy to talk about when the drawer is empty. Most of the time it isn't. You've inherited forty files and a folder that's load-bearing in ways nobody documented. You can't stop shipping features to go on an architecture retreat, and a big-bang "fix shared/" pull request is a merge-conflict machine that reviewers will rubber-stamp out of exhaustion. So don't do that. Dig out incrementally.

First, audit without moving anything. Go through shared/ file by file and tag each one with a comment — three buckets only:

  • Portable — passes the test. Leave it. This is what shared/ is for, and seeing what legitimately qualifies is calming.
  • Owned — knows about a domain concept. It has a real home in an entity or a feature; it's just sitting in the wrong place.
  • Split — genuinely two things fused together: a portable core with a domain-specific wrapper grown around it. The useAutosave-that-grew-a-policyId case.

Then move the easy ones. The Owned bucket is pure relocation — cut the file, paste it into its real home, fix the imports, done. Your lint rule from the previous section will actually help here: as you move domain code out, anything still illegally reaching into higher layers lights up red, showing you the next thing to fix. The compiler turns into a to-do list.

Then split the hard ones. For each Split file, separate the portable core from the domain wrapper. The core stays in shared/ as a dumb primitive. The wrapper moves down to the feature that needs the domain-specific behavior:

// before — one file in shared/, quietly domain-aware // shared/hooks/useAutosave.ts export function useAutosave(policyId: string, draft: PolicyDraft) { /* ... */ } // after — the portable half // shared/lib/useAutosave.ts — generic, no domain knowledge export function useAutosave<T>(key: string, value: T, save: (v: T) => Promise<void>) { /* ... */ } // after — the domain half, where it belongs // features/edit-policy/model/usePolicyAutosave.ts export function usePolicyAutosave(policyId: string, draft: PolicyDraft) { return useAutosave(`policy:${policyId}`, draft, savePolicyDraft); }

Notice the generic useAutosave got better by being forced portable — it now takes its save function as an argument instead of hard-coding savePolicyDraft, which makes it genuinely reusable for the first time. Enforcing the boundary didn't just tidy the folder. It improved the primitive.

Then delete. The satisfying part. Once the Owned and Split files are gone, you'll almost always find a handful of files nothing imports anymore — helpers that existed only to serve code that moved. Delete them. A shrinking shared/ is the clearest signal the dig-out is working.

You don't have to finish in one sitting. Do a bucket a week. The direction matters more than the speed: as long as shared/ is getting smaller and more portable over time instead of larger and vaguer, you're winning.

A word on the barrel

While you're in here, you'll be tempted to add an index.ts that re-exports everything so imports look clean: import { Button, formatCurrency } from '@/shared'. Resist making that reflexive. A barrel over a genuinely small, stable, portable shared/ is fine. A barrel over the junk drawer just gives the junk drawer a tidy front door — and it can quietly drag your whole shared/ tree into every bundle that touches one export. The clean import is cosmetic; the coupling underneath is real. Fix the contents first. The public API of a folder is only worth having once the folder actually keeps a promise. That trade-off — barrels, bundles, and what a folder's public surface should really be — is a whole topic on its own, and one I want to come back to.

The folder was never the problem

It's easy to read all this as "shared/ is bad, avoid it." That's the wrong lesson. Every non-trivial app needs a home for genuinely portable code, and shared/ is a perfectly good name for it. The folder was never the enemy.

The missing promise was. A junk drawer is just a drawer that nobody agreed on the contents of. The fix isn't a better folder name or a stricter reviewer — it's making the promise explicit, and then making a machine enforce it so the promise survives the next deadline. Do that, and shared/ stops being the place your architecture goes to die and goes back to being the boring, dependable bottom of the stack. Boring is the goal. In shared/, boring is the whole point.

Next, I want to pick up the barrel thread I brushed past earlier: why the tidy index.ts that gives a folder a clean front door can quietly drag half your app into a bundle that only needed one function — and when it's still worth it.

If you've got a shared/ folder you're a little afraid to open — or a war story about one that got completely out of hand — I'd genuinely like to hear it. Reach out; I collect these.

Found this helpful?

Let's discuss your project needs.

Get in touch