Most frontend codebases are pleasant for the first few months. Then the team grows, features pile up, and suddenly a small change touches nine files and breaks a page nobody was looking at. The decisions that prevent that are rarely about frameworks. They are about boundaries, data flow and habits.
Organise by feature, not by file type
A components/ folder with two hundred files, a hooks/ folder and a utils/ folder tells you what things are, not what they are for. Grouping by feature keeps related code together and makes ownership obvious:
features/
checkout/
checkout-page.tsx
payment-form.tsx
use-cart-total.ts
checkout.api.ts
checkout.test.tsx
profile/
...
shared/
ui/ # buttons, inputs, dialogs
lib/ # formatting, dates, fetch client
A simple rule keeps this healthy: features may import from shared, but not from each other. When two features need the same thing, promote it to shared deliberately.
Keep state close to where it is used
Global state is convenient on day one and expensive on day three hundred. Ask where each piece of state really belongs:
- Server data belongs in a data layer with caching, such as React Query, SWR or server components, not copied into a global store.
- URL state such as filters, tabs and pagination belongs in the URL, so it survives refreshes and can be shared.
- Local UI state such as an open dropdown belongs in the component.
- Truly global client state, like the current theme or a 3D scene’s settings, is the small remainder that justifies a store.
When high-frequency values, such as pointer position in a render loop, change every frame, keep them out of React state entirely and read them where they are needed.
Make components boring to use
A good shared component has a small, predictable API. Prefer composition to configuration: a Card with slots ages better than a Card with twenty boolean props.
// Hard to extend
<Card title="Plan" showBadge badgeText="New" footerAlign="right" />
// Easy to extend
<Card>
<Card.Header>Plan <Badge>New</Badge></Card.Header>
<Card.Footer className="justify-end">...</Card.Footer>
</Card>
Let types document the edges
TypeScript pays off most at boundaries: API responses, component props and shared utilities. Validate data where it enters the app, then trust the types inside. A runtime schema at the API boundary catches backend changes before they become blank screens:
import { z } from "zod";
const Post = z.object({
id: z.number(),
title: z.string(),
publishedAt: z.string().datetime(),
});
export type Post = z.infer<typeof Post>;
export async function getPost(id: number): Promise<Post> {
const response = await fetch(`/api/posts/${id}`);
return Post.parse(await response.json());
}
Test behaviour, not implementation
Tests that assert on internal state break every refactor and catch few real bugs. Tests that act like a user (type into the field, click the button, expect the message) survive refactors and protect what matters. A practical mix:
- Unit tests for pure logic: pricing, formatting, reducers.
- Component tests with Testing Library for important interactions.
- A handful of end-to-end tests for the flows that make money or keep users safe.
Automate the arguments away
Formatting, import order and common mistakes should never come up in code review. A formatter, a strict linter and type checks in CI free reviewers to discuss design and behaviour. Add a performance budget too: a bundle-size check on pull requests catches the accidental 300 KB dependency before it ships.
Write down the decisions
The hardest knowledge to recover is why something was built a certain way. A short decision record (context, options, choice and consequences) takes ten minutes and saves hours when someone later wonders whether that odd pattern is safe to remove.
In short
- Group by feature; keep cross-feature imports out.
- Put state where it belongs: server, URL, component, then store.
- Design components for composition.
- Validate at the edges, trust types inside.
- Test what users do.
- Automate style; document decisions.
Architecture is not about predicting the future. It is about making the next change easy, and the one after that.


