Skip to blog

React · Architecture · TypeScript · TanStack Query

How to Scale a React App Without Losing Your Mind

Your folders were fine at 5k lines. At 100k they are a crime scene. Vertical slices keep a React codebase readable.

A tangle of black scribbles on the left, an arrow curving toward a tidy src folder tree of features, ui, routes and shared on the right, and a Read the Article button below the title

Starting a React project is the easiest thing in the world.

You run one command, you get a main.tsx, you put everything in it, and it works. No architecture meetings. No RFC. No one asking you where the domain layer is. Just you, a component, and vibes.

Then the app grows a little, and you do the responsible thing. You make folders:

And honestly? That works too. For a while. It works so well that you start to believe you have solved software.

You have not solved software.

The part where it quietly goes downhill

Somewhere past mid-size, the folders stop helping and start hiding. Usually, that is when your app has real users, real deadlines, and more than three people touching it.

You know the symptoms. You have lived them.

The frustrating part is that nobody did anything wrong. Every individual decision was reasonable. The structure just does not scale, because it is organized by what a file is instead of what a file is for.

Okay, so what is the fix?

Vertical slices.

That is it. You can close the tab, refactor something, and tell your team you read a whole blog post.

Still here?

Good. “Use vertical slices” is the conclusion, and conclusions are useless without the reasoning behind them. The interesting part is everything around it: naming, nesting, where state goes, and when to stop abstracting.

Scaling is five problems wearing a trench coat

Before touching a folder, be honest about what you are optimizing for. “Clean code” is not a goal. These are:

Parallel work without merge wars

Five people shipping five features should touch five different directories, not the same four.

Findability

A new hire should locate ‘delete team’ in under ten seconds, without asking anyone.

Composition over god-components

Small pieces that snap together beat one component with fourteen props and a boolean called isSpecial.

LLM-friendly files

Your AI pair-programmer reads files too. A 1,400-line file burns tokens and context for a ten-line change.

Deletability

Killing a feature should be ‘delete the folder’, not a two-day archaeology dig.

Under all five sits the least glamorous thing in software: naming.

Good names mean you do not need a comment explaining what a file does. The name is the comment. Unlike the comment, it cannot quietly go out of date.

Names that do the explaining for you

Here is a small slice of a teams feature. Read the file names and try to guess what the UI looks like:

teams-list.tsx
teams-list-item.tsx
teams-list-item-menu.tsx
teams-list-skeleton.tsx
teams-list-filters.tsx
teams-list-empty.tsx
teams-list-schema.ts
teams-list-types.ts
teams-list-query-options.ts
teams-list-query-params.ts
use-teams-list-filters.ts
team-details.tsx
team-details-header.tsx
team-details-members.tsx
team-details-skeleton.tsx
team-details-query-options.ts
team-details-types.ts
use-team-details.ts

You already know there is a list, that each row has a menu, that there is a loading state, an empty state, and a filter bar. You know it before opening a single file.

The composition tree is visible in the file tree. teams-list renders teams-list-item, which renders teams-list-item-menu. The names are prefixed, so they sort together, and so Cmd+P becomes a genuinely good navigation tool. Type teams-list and everything about that screen is in front of you.

Yes, the names are long

teams-list-item-menu.tsx is more typing than Menu.tsx. You type it once and read it four hundred times. Optimize for the four hundred.

Use cases first, file types second

Here is the mindset shift.

teams
├─ components
├─ hooks
├─ schemas
├─ types
├─ queries
├─ mutations
└─ utils

Organized by what things are. To understand one feature, you open seven folders.

The second one reads like a product spec, because it is one. Those are the things a user can do with a team.

At 5,000 lines the difference is cosmetic. At 100,000 lines it is the difference between a codebase people enjoy and a codebase people avoid.

Here is the full picture for a mature teams feature:

create-team-dialog.tsx
create-team-form.tsx
create-team-schema.ts
create-team-types.ts
create-team-mutation-options.ts
use-create-team.ts
index.ts

Now imagine a ticket lands: the create-team dialog should validate names against a two-character minimum.

In the old structure, you go on a tour:

components/create-team-form.tsx
schemas/create-team.schema.ts
hooks/use-create-team.ts
api/mutations.ts
types/team.types.ts

Five folders, five mental context switches, and a decent chance you miss one.

In the new structure, you open create/ and everything is right there. That locality is the biggest thing vertical slices buy you.

Let’s talk data fetching, because that is where it always breaks

Most of the mess in a big React app is not components. It is the tangle of queries, mutations, keys, and invalidations around them.

The pattern that has aged best for me: keep query and mutation config in plain exported files, not hooks.

ts
export const teamDetailsQueryOptions = (teamId: string) =>
  queryOptions({
    queryKey: teamQueryKeys.detail(teamId),
    queryFn: () => getTeam({ teamId }),
    staleTime: 30_000,
  });

Then use it wherever you need it:

tsx
const { data: team } = useSuspenseQuery(
  teamDetailsQueryOptions(teamId),
);

Mutations get the same treatment:

ts
export const createTeamMutationOptions = () =>
  mutationOptions({
    mutationFn: createTeam,
    onSuccess: async (_, __, ___, context) => {
      await context.client.invalidateQueries({
        queryKey: teamQueryKeys.lists(),
      });
    },
  });

Why not wrap it in a hook? The moment it is a hook, it can only be used inside a component. As a plain object, the same config works in a route loader, a prefetch on hover, an SSR call, a test, or a component. One definition, five consumers.

The hook tax

I would push back on this reflex:

ts
useTeams()
useTeam()
useCreateTeam()
useUpdateTeam()
useDeleteTeam()

...when the whole implementation is:

ts
export const useTeam = (id: string) =>
  useQuery(teamQueryOptions(id));

That is not an abstraction. That is a rename with extra steps and one more file to open.

Rule of thumb

A hook should exist because there is behavior inside it: orchestration, stateful UI logic, derived state, or side effects. It should not exist because “we always make a hook.”

Hooks that earn their keep:

use-team-selection.ts       // multi-select across a list
use-team-permissions.ts     // role logic, derived flags
use-team-dnd.ts             // drag-and-drop wiring
use-team-member-search.ts   // debounce + filter + highlight
use-teams-list-filters.ts   // URL state sync + defaults

Every one has real logic in it. That is the bar.

Schemas: one per intent, not one per model

There is a strong temptation to write a single team-schema.ts that describes the team forever, in all contexts. Do not. Reading a team and creating a team are different contracts with different rules.

ts
export const teamSchema = z.object({
  id: z.string(),
  name: z.string(),
  description: z.string().nullable(),
  memberCount: z.number(),
  createdAt: z.string(),
});

This is the shape of a team as the server sends it. It has an id, a memberCount, and read-only truth.

Force these into one schema and you end up with a pile of .optional() and .partial() and a type that is true nowhere in particular.

Do not create types.ts just because TypeScript exists

If a type is used by exactly one component, it lives in that component.

tsx
type TeamsListItemProps = {
  team: Team;
};

export function TeamsListItem({ team }: TeamsListItemProps) {
  // ...
}

Promote it to team-types.ts only when multiple files in the feature genuinely share it. A types file that exists “for organization” is a file you now have to keep in sync.

utils.ts is where code goes to be forgotten

I have strong feelings about these four filenames:

utils.ts
helpers.ts
lib.ts
common.ts

They start innocent. Someone adds a formatting function. Someone else adds a permission check. Six months later it is 900 lines of unrelated code with no owner, and nobody can delete anything because nobody knows what imports what.

The fix is embarrassingly simple: name the file after what it does.

team-mappers.ts
team-permissions.ts
team-sort.ts
team-filters.ts
team-formatters.ts
team-member-rules.ts
team-query-params.ts

Compare the import lines:

ts
// tells you nothing
import { canManageTeam } from '../utils';

// tells you everything
import { canManageTeam } from '../shared/team-permissions';

The second one survives a code review at 11pm.

Where does state actually go?

Reaching for Zustand or Redux the moment you have state is a common self-inflicted wound in React. Most state has a better home. Here is the ladder I walk down, in order:

URL

Filters, pagination, sorting, active tab, and selected ID. If a user should be able to share the link and see the same thing, it belongs in the URL. This also gives you back-button behavior for free.

TanStack Query

Anything that came from a server. It is a cache of someone else’s state. Do not copy it into a store; you will spend the rest of the project keeping the copy honest.

React state

Dialog open/closed, hover, or a temporary selection inside one component tree. Local, boring, correct.

React Hook Form

Form values, touched fields, and validation errors. Forms are their own small state machine; let a library that specializes in it do the work.

Zustand (or friends)

Only for genuine cross-tree feature state that none of the above can hold. This is the last stop, not the first.

A legitimate example of that last one: bulk selection that has to survive across a list, a toolbar, and a details panel that do not share a parent.

ts
type TeamSelectionState = {
  selectedTeamIds: Set<string>;
  select: (id: string) => void;
  deselect: (id: string) => void;
  clear: () => void;
};

Small. One job. Easy to reason about.

What not to do

Do not create teams-store.ts and pour API responses, form values, filters, loading flags, dialog booleans, and selected rows into it. That store becomes the real application, components become dumb renderers of a global blob, and every bug becomes a global bug. This is how frontend architecture quietly dies: one more field on the store at a time.

Query keys deserve one file and a little respect

Hand-written key arrays scattered across forty files are how you end up with “why didn’t the list refresh?” tickets.

ts
export const teamQueryKeys = {
  all: ['teams'] as const,

  lists: () => [...teamQueryKeys.all, 'list'] as const,
  list: (filters: TeamsListFilters) =>
    [...teamQueryKeys.lists(), filters] as const,

  details: () => [...teamQueryKeys.all, 'detail'] as const,
  detail: (teamId: string) =>
    [...teamQueryKeys.details(), teamId] as const,

  members: (teamId: string) =>
    [...teamQueryKeys.detail(teamId), 'members'] as const,
};

Now invalidation says what it means. Creating a team invalidates teamQueryKeys.lists(). Adding a member invalidates teamQueryKeys.members(teamId) and leaves everything else alone. The hierarchy is real, typed, and lives in one place.

The shared/ folder problem

Every vertical-slice codebase reaches this moment.

Three features need the same card component. Your hand moves toward shared/ on instinct. That is fine once or twice. By the fortieth time, shared/ is a 200-file junk drawer: the flat components/ folder you escaped, wearing a fake moustache.

Before you put it in shared/, ask one question: is there a parent feature hiding here?

features
├─ mammals/
├─ cat/
├─ dog/
└─ shared/
   └─ mammal-detail-card.tsx

Flat, and shared/ grows forever. Why does a global shared folder know what a mammal is?

Features can nest. Nesting gives you a natural scope for shared code, so root shared/ stays reserved for things that are genuinely app-wide: the design system, auth, and the API client.

The counterweight

Do not build a seven-level feature hierarchy for an app with nine screens. Over-engineering creates the same confusion as under-engineering; it just costs more and looks smarter in a diagram.

Abstract when the structure is obvious and already hurting. Otherwise, keep it simple.

But I already have a mess

Of course you do. Everyone does. You do not need a rewrite; you need a direction.

Stop the bleeding

New rule, starting today: every new feature goes in features/<name>/ as a vertical slice. Nothing new gets added to the old flat folders. This alone changes the trajectory.

Move one feature, end to end

Pick a mid-sized feature you understand well. Move all of it: components, schemas, queries, and types. Do it in one PR so reviewers see the shape, not fragments.

Let the rest migrate on contact

When you touch an old file for a bug or a change, move it into its slice as part of that work. The parts of the codebase you touch most get fixed first, which is the right order.

Break the god-files as you go

Every time you pull a 1,200-line component into a slice, split it along its natural seams: header, list, item, empty, and skeleton. You will usually find the seams were already there, just unmarked.

Delete utils.ts last

By the time everything else has moved, whatever remains in utils.ts is either dead code or one genuinely shared helper. Both outcomes are easy to handle.

Six months of this beats one heroic refactor that never gets merged.

Wrapping up

None of this is clever. That is the point.

Scaling a React app is not about picking the right state library or the perfect folder taxonomy. It is about making the codebase obvious: finding things is boring, changing things is local, deleting things is safe, and five people can work in parallel without stepping on each other.

If you are skimming:

  • Organize by use case, not by file type.
  • Let file names carry the explanation. Long and boring beats short and mysterious.
  • Keep query and mutation config as plain objects; make hooks earn their existence.
  • Use one schema per intent, not one schema per model.
  • Never create a folder named utils.
  • Walk down the state ladder: URL → Query → React → form library → store.
  • Nest features instead of feeding a giant shared/.
  • Know when to stop. Abstraction is a tool, not a personality.

Do this, and six months from now nobody on your team says, “Where does this go?” anymore.

That silence is the prize.