React imposes no structure — that is its strength and at the same time its trap. An empty project can be organised any way you like, and while the file count is small any arrangement works. But when there are ten screens and a team of three, the absence of discipline makes itself felt: nobody knows where the right file lives, and changing one button touches code in five places.
This article is about how to avoid that outcome from the start.
Type-based layout — why it falls short
The first instinct is to sort files by what they are: all components in components/, all hooks in hooks/, all utilities in utils/. It looks tidy until the first real feature.
The problem: one "Orders" screen requires a component from components/, a hook from hooks/, a server call from api/, and types from types/. Understanding a single feature means jumping across four folders. When the feature is removed, the files stay scattered because someone forgot to sweep through every folder.
This is called organising by technical type rather than by meaning.
Feature-based layout — the "co-locate" rule
The idiomatic approach for React projects: everything that belongs to one feature lives in one folder.
src/
features/
products/
ProductList.tsx
ProductCard.tsx
useProducts.ts // feature data loading
api.ts // server calls
types.ts // feature types
orders/
OrderList.tsx
useOrders.ts
api.ts
types.ts
shared/
ui/ // Button, Input, Modal — reused everywhere
lib/ // shared utilities: formatDate, formatPrice
app/
router.tsx
App.tsx
Three zones:
features/— each feature lives in its own folder. Feature code does not reach into the internals of another feature.shared/— what several features need: primitive UI components and common helper functions.app/— the assembly point: router, root component, global providers.
Deleting a feature is now simple: delete the folder. Everything that belonged only to it goes with it.
Component as a unit
A component in React is a function that accepts data and returns markup. The basic rule: one component — one responsibility.
type ProductCardProps = {
name: string;
price: number;
onOpen: (id: string) => void;
};
export function ProductCard({ name, price, onOpen }: ProductCardProps) {
return (
<article className="product-card">
<h3>{name}</h3>
<p>{price} $</p>
</article>
);
}
The component receives data via props and communicates user actions via callbacks (onOpen). It does not know where the data came from or what will happen after the click — that is not its concern. Such a component can be reused anywhere and is easy to verify in tests.
Component names are PascalCase (ProductCard, not productCard). Each component lives in a separate file with the same name.
When a component grows and starts doing too much — that is the signal to split it into several smaller ones.
Typing props
TypeScript in a React project is not an extra tool — it is a way to lock down the contract between components. Props are described explicitly: what the component accepts, what is required, what is optional.
type ButtonProps = {
variant: "primary" | "secondary";
disabled?: boolean; // ? marks an optional field
onClick: () => void;
children: React.ReactNode;
};
A few techniques that pay off immediately:
- Union string literals (
"primary" | "secondary") instead of plainstring— the compiler will not allow passing an invalid value. disabled?with a question mark — the field is optional, the component works without it too.children: React.ReactNode— the standard type for anything nested inside a component.
Strict mode in tsconfig.json ("strict": true) should be enabled from day one: it catches errors that would otherwise surface only in the browser.
Logic and markup — two different levels
A component is responsible for how the screen looks. Logic — data loading, computations, state management — lives in hooks.
// hook — data loading logic
function useProducts() {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchProducts().then(data => {
setProducts(data);
setLoading(false);
});
}, []);
return { products, loading };
}
// component — rendering only
function ProductList() {
const { products, loading } = useProducts();
if (loading) return <p>Loading...</p>;
return (
<ul>
{products.map(p => <ProductCard key={p.id} {...p} />)}
</ul>
);
}
When logic is in a hook rather than scattered through the component — it can be reused in another component and tested independently of the UI.
The server boundary
Everything that comes from the server is kept in api.ts and types.ts inside the feature folder. Components do not make server requests directly — they work with data the hook has already fetched.
products/
api.ts ← fetchProducts(), fetchProductById()
types.ts ← Product, ProductListResponse
useProducts.ts ← calls api.ts, returns data to the hook
ProductList.tsx ← uses useProducts()
If server response types are described in an OpenAPI contract they can be generated automatically — the boundary between frontend and backend then becomes type-safe: a contract change is immediately visible at both ends.
In short
- Feature-based layout: everything related to one task lives in one folder.
- Three zones:
features/(domains),shared/(common),app/(assembly point). - A component is a function with one responsibility; it receives data via props and communicates actions via callbacks.
- Props are typed explicitly; TypeScript strict mode is enabled from day one.
- Logic — data loading, computations — is moved into hooks; the component stays focused on rendering.
- Server calls and response types are kept in
api.ts/types.tsinside the feature folder.
What to read next
- Components — how to build reusable components with proper boundaries.
- State — when local state is needed and when global state is.
- Data fetching — patterns for working with the server from a React application.