Every React codebase is pleasant at feature ten. Almost none are pleasant at feature one hundred, and the difference has very little to do with how good the code was on day one.

What actually happens is that a hundred reasonable decisions accumulate. Each one made sense. Together they produce a codebase where a small change requires reading four files, and where everyone quietly avoids certain directories.

These are the habits that, in my experience, decide which side you end up on.

1. Components render. They do not also fetch, transform and decide.

The most common shape in a large codebase is the component that grew by accretion:

jsx
function MemberProfile({ memberId }) {
  const [member, setMember] = useState(null);
  const [claims, setClaims] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [editing, setEditing] = useState(false);

  useEffect(() => { /* fetch member */ }, [memberId]);
  useEffect(() => { /* fetch claims */ }, [memberId]);
  useEffect(() => { /* recalculate eligibility */ }, [member, claims]);

  // 180 more lines
}

Nothing here is wrong, exactly. But this component cannot be tested without mocking the network, cannot be reused, and cannot be understood without reading all of it. Split the responsibilities and each piece becomes testable in isolation:

jsx
function MemberProfile({ memberId }) {
  const { member, claims, isLoading, error } = useMemberProfile(memberId);

  if (isLoading) return <ProfileSkeleton />;
  if (error) return <ErrorState error={error} />;

  return <ProfileView member={member} claims={claims} />;
}

The rule I hold to: if a component has more than two useEffect calls, something in it wants to be a hook. Not always, but often enough that it is worth the second look.

2. Server state is not application state

A large share of the state management pain I have seen came from one mistake: putting fetched data into a global store and then hand-writing the caching, invalidation, refetch and staleness logic that a data-fetching library already solved.

Avoiddispatch(setMembers(await api.getMembers()))

You now own cache invalidation, race conditions, refetch on focus, and knowing when this data went stale.

Doconst { data } = useQuery(['members', filters], fetchMembers)

Caching, deduping, retries and invalidation are handled. Your store shrinks to things that are genuinely client state.

Once server state is out of it, most applications discover their remaining global state is small — the current user, the tenant configuration, a theme. That fits comfortably in context and does not need a library at all.

3. Name things after the domain, not the layout

This sounds cosmetic. It is the difference between a codebase you can search and one you cannot.

AvoidPreferWhy
<LeftPanel /><MemberSidebar />Survives a redesign that moves it right
<BlueButton /><PrimaryAction />Survives a rebrand
handleClick2submitEnrolmentSearchable, and says what it does
data, items, listclaims, dependentsTells you what you are looking at

Use the words the business uses. When a client says “accumulator” and the code says totals, every conversation needs a translation step, and eventually someone translates wrongly.

4. One way to do each common thing

The thing that makes a mature codebase feel heavy is rarely bad code. It is variety — four ways of showing a loading state, three approaches to forms, two date libraries, and no way to know which is current.

Pick one of each and write it down where people will see it:

  • One form approach, one validation library
  • One loading pattern (skeleton or spinner — choose)
  • One error boundary strategy and one error display component
  • One date library, one formatting helper
  • One way to open a modal

When you genuinely need to change one of these, migrate deliberately and remove the old one. The expensive state is not “we use the old pattern” — it is “we use both and nobody knows which is right”.

💡
A cheap way to enforce this

When a pull request introduces a pattern that does not already exist in the codebase, it needs a sentence explaining why. Not a veto — just a prompt. Most of the time the author realises there was an existing way and uses it.

5. Treat prop count as a design signal

A component with fifteen props is not configurable. It is several components that have not been separated yet, and every new requirement adds another boolean.

jsx
<MemberCard
  compact
  showActions={false}
  hideAvatar
  isAgentView
  enrolmentMode
  billingVariant
/>

Composition scales where props do not:

jsx
<MemberCard member={member}>
  <MemberCard.Avatar />
  <MemberCard.Summary />
  <MemberCard.Actions>
    <Button>Edit</Button>
  </MemberCard.Actions>
</MemberCard>

Each context includes what it needs and nothing more, and adding a new variation does not mean editing a shared component that four teams depend on.

6. Make deletion routine

Large codebases are heavy partly because nothing ever leaves. Features get switched off but the code stays. Experiments end but the flag remains. A component is replaced but the original is still imported in two places.

Two habits help. Give every feature flag an expiry date in a comment, and actually check them each quarter — a flag that has been true in production for a year is not a flag, it is dead branching. And run an unused-export check in CI; the results are always more generous than anyone expects.

Deleting code is the only refactor with no risk of introducing a bug in what remains, and it is the one teams do least.

7. Put the boring rules in the pipeline

Anything enforced by a person will drift the week that person is busy. Anything enforced by CI will not.

01

Import boundaries. Fail the build on cross-feature deep imports. This one rule prevents most structural decay.

02

Bundle size budget. So the day someone imports a chart library into the login route, the pull request says so.

03

Type checking with no escape hatches. An any that survives review becomes ten anys by next quarter.

04

Accessibility linting. Cheap to satisfy as you go, genuinely expensive to retrofit across a hundred features.

8. Test behaviour, not implementation

Tests that assert on internal state break every time you refactor, which teaches the team that refactoring is expensive — and that is how a codebase freezes.

jsx
// Breaks if you rename state or restructure the component
expect(wrapper.state('isSubmitting')).toBe(true);
jsx
// Survives any refactor that keeps the behaviour
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(await screen.findByText(/enrolment received/i)).toBeVisible();

The second test tells you the feature works. The first tells you the code has not changed. Only one of those is worth maintaining for three years.

9. Watch what people say, not just what the metrics say

The earliest signal that a codebase is decaying is not in any dashboard. It is in standup.

  • “I would rather not touch that file.”
  • “It is easier to copy the existing one.”
  • “That should be a small change, but…”
  • “Only Priya knows how that works.”

Each of those is a location. When someone says a change is riskier than it sounds, that is the architecture telling you where it has failed — usually a year before any metric notices.

The question to ask before merging

If someone who has never seen this code needs to change it in six months, will they feel confident or afraid?

That question has done more for the codebases I work in than any lint rule. Feature one hundred is not hard because there is more code. It is hard because of how much of that code people are afraid to touch — and fear is something you can design out, one merge at a time.