DesignDev.io logoDesignDev.io logo

  1. Home
  2. /React Foundations
  3. /Stop Colocating Everything — A Better Way to Structure React Features
← Back to home

Stop Colocating Everything — A Better Way to Structure React Features

Alex Chen

Alex Chen

Cover Image for Stop Colocating Everything — A Better Way to Structure React Features
Alex Chen

Alex Chen

January 6, 2026·5 min read
Updated April 26, 2026
Series:React Foundations
Level:Intermediate
Tags:reactweb
Assumes:Comfortable with React components, hooks, and basic project structure

Colocation is good advice. "Colocate everything" is how you end up with a feature folder that imports from six other feature folders and nobody can delete anything without breaking something else. Here's the structure that actually scales.


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:

  1. Import from another feature — coupling that's invisible until something breaks
  2. Copy the hook into each feature — duplication that diverges over time
  3. 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


More Stuff

Building a Badge Component — Variants, Sizes, and Dot Indicators
April 3, 2026·3 min read

Building a Badge Component — Variants, Sizes, and Dot Indicators

Badge is the first purely presentational primitive in this series — no form integration, no event handling, no focus management. That simplicity is deceptive. A Badge that looks right in isolation often breaks inside a nav item, a table cell, or an avatar. The decisions that make it composable are worth getting right now.

Muhammad Athar

Muhammad Athar

Building a Label Component That Actually Connects to Its Field
April 3, 2026·3 min read

Building a Label Component That Actually Connects to Its Field

We've referenced a Label component in every form field article so far without building it. That's intentional — you need to understand what the Label connects to before you understand what it needs to do. Now that we have Input, Textarea, Checkbox, RadioGroup, Select, and Toggle, the Label's job is clear: it's not just styled text. It's the accessible name of a form field, and it carries required indicators, optional markers, and disabled state as part of that responsibility.

Muhammad Athar

Muhammad Athar

DesignDev.io logoDesignDev.io logo

Explore

  • Home
  • Search
  • Authors
  • Series
  • About Us

Account

  • Sign in

Legal

  • Privacy Policy
  • Terms of Service

© 2026 DesignDev.io · All rights reserved

Built with Next.js · Tailwind · Sanity · Vercel