Stop Colocating Everything — A Better Way to Structure React Features

Colocation became React orthodoxy around 2019. Put your component, its styles, its tests, and its types in the same folder. Makes sense. When you delete the component, you delete everything related to it. No orphaned files left behind in a distant styles/ directory.
The advice is good. The problem is what happens when you apply it without a rule for where colocation ends.
A codebase I worked in last year had a features/checkout/ folder with forty-three files. The checkout feature imported from features/auth/, features/cart/, features/products/, and features/user/. The auth feature imported from features/checkout/. Delete anything and something three folders away breaks.
That's not colocation. That's a distributed monolith with a React skin on it.
Why colocation alone isn't enough
Colocation answers the question: where do files that belong to the same thing live?
It doesn't answer: what counts as the same thing? And it doesn't answer: what happens to code that belongs to multiple things?
The second question is where codebases drift. A useAuth hook gets built inside features/auth/. Then features/checkout/ needs it. Then features/profile/ needs it. Now useAuth is colocated inside auth/ but used everywhere. You have three choices, all uncomfortable:
- Import from another feature — coupling that's invisible until something breaks
- Copy the hook into each feature — duplication that diverges over time
- Move it somewhere shared — which means deciding where shared things live
Most teams do option 1 and tell themselves they'll clean it up later.
The feature-slice model gives you a structure where this decision is made upfront.
The feature-slice model
The core idea: your codebase has two distinct zones.
Feature code — things that belong to one specific domain. The checkout flow. The authentication screens. The user profile editor. If it only makes sense in the context of that feature, it lives in the feature folder.
Shared code — things that multiple features consume. A Button component. A useDebounce hook. A date formatting utility. API client configuration. These live outside any feature.
The rule that makes it work: features can depend on shared. Features cannot depend on each other.
That single constraint eliminates the circular import problem, makes deletion safe, and tells every developer exactly where to put every new file.
The pages folder deserves a note. In this model, pages are deliberately thin — they import from features and compose them. A CheckoutPage doesn't contain checkout logic; it renders the checkout feature's components and passes route params. All the real code lives in the feature.
The index.ts public API
Each feature has an index.ts that explicitly exports what the feature makes available to the rest of the app.
This is the feature's contract with the world. Everything inside the feature folder that isn't exported here is private. Other parts of the app import from features/auth, never from features/auth/hooks/useLoginForm directly.
The second import is the one that creates invisible coupling. When you refactor useLoginForm, you break code outside the feature that never should have been depending on it.
The dependency direction rule in practice
Here's the rule applied to a real scenario. The checkout feature needs to know if the user is authenticated. There are three ways to handle this.
Wrong — feature importing from feature:
This creates a directed dependency from checkout to auth. Now you can't test, reuse, or move the checkout feature without bringing auth along.
Right — shared code:
The authenticated user context is shared code. Both auth and checkout depend on shared, but neither depends on the other.
Also right — props:
The page composes the feature with the data it needs. The checkout feature doesn't import auth — it just receives what it needs.
What moves to shared
The heuristic: if you've imported something from one feature into a second feature, it belongs in shared.
Common candidates:
UI primitives — Button, Input, Modal, Tooltip, Badge. If two features use the same Button, it's shared.
Generic hooks — useDebounce, useLocalStorage, useIntersectionObserver, useWindowSize. Not feature-specific — belong in shared/hooks.
API client configuration — the axios or fetch wrapper with auth headers, base URL, and error handling. Used by every feature's API layer.
Type utilities — ApiResponse<T>, PaginatedResult<T>, shared entity types that cross feature boundaries.
Formatting utilities — formatCurrency, formatDate, truncateText. Used everywhere, owned by no feature.
When to split a feature into a package
In a monorepo, the signal that a feature has outgrown a folder is when:
- Other apps in the same monorepo want to use it
- The feature has its own release cadence independent of the main app
- The feature's test suite takes long enough that running it separately would save meaningful time
At that point, features/auth/ becomes packages/auth/ — a proper npm workspace package with its own package.json, tsconfig, and versioning. The folder structure inside stays the same. The boundary just became explicit.
This is covered in more depth in Mira's articles on pnpm workspaces and Next.js with Turborepo — the monorepo setup that makes package-level features practical.
The migration path
You don't need to restructure everything at once. The practical path:
Week 1: Create shared/ and move one obvious candidate — a Button component or useDebounce hook that's already copied across two feature folders.
Week 2–4: As you touch feature folders for other reasons, add the index.ts public API. Don't add it to folders you're not touching — it's not worth the churn.
Ongoing: When you catch yourself importing from features/X inside features/Y, stop. Move the shared piece to shared/, or pass it as a prop, before continuing.
The structure improves incrementally. You don't need a big rewrite to get the benefit.
The test that tells you if it's working
Pick any feature folder. Can you delete it — every file inside it — without breaking any other feature folder?
If yes, the dependency direction is clean. The feature is genuinely isolated.
If no, something in another feature is reaching inside it. That's the dependency to untangle before the structure can do its job.
Colocation is the right instinct. The feature-slice model is colocation with a rule that prevents it from becoming a tangled graph. One constraint — features depend on shared, never on each other — does most of the structural work so you don't have to.
Up next: How useDeferredValue and useTransition work — with real examples — the concurrent rendering tools that keep UIs responsive under load.
Related: pnpm workspaces — making monorepos actually manageable by Mira Halsted — when your features outgrow folders and become packages.
Alex Chen is a senior frontend engineer who writes about React patterns, JavaScript internals, and the decisions that separate maintainable codebases from ones that fight back. Opinionated by design.
Photo by Amsterdam City Archives on Unsplash

