--- title: "The shared/ Folder Is Not a Place, It's a Promise" author: "Maksym Kuzmitskyi (MaximusFT)" published: "2026-07-31T23:01:29.352Z" updated: "2026-08-21T00:35:00.205Z" url: "https://zyvop.com/the-shared-folder-is-not-a-place-it-s-a-promise-7b9np" canonical: "https://ma-x.im/blog/react-playbook-shared-code" tags: ["react","React Playbook","Architecture","FSD"] --- # The shared/ Folder Is Not a Place, It's a Promise ![Cover image]() ![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: ```ts // 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: ```ts // 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: ```js // 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: ```ts // 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(key: string, value: T, save: (v: T) => Promise) { /* ... */ } // 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.