Home / Articles

Frontend Debt as Change Cost: Diagnosing a Growing React Codebase

Learn to spot the compounding decisions that make large React, Redux, TypeScript and Next.js apps expensive to change, and how to pay that debt down incrementally.

Publisher: REACTAPP.TOP18 min read4156 words

This article is published in English.

Sources: Frontend Technical Debt: The Problems Nobody Notices Until the React App Becomes Huge

Large React applications almost never become hard to maintain because of one catastrophic commit. They degrade through a long series of small, individually sensible choices: an extra useEffect, a slice of Redux state that did not need to be global, a "temporary" 500-line component, a shared widget that keeps gaining props, a utils file that absorbs whatever has no obvious home, an API type pasted into three modules, a styling convention nobody dares to revisit. None of these decisions looks wrong on the day it is made. The trouble starts when hundreds of them pile up and begin to interact.

That accumulation is what we mean by frontend technical debt, and the core idea of this guide is simple: the biggest frontend problems are rarely caused by a single bad piece of code, but by minor decisions that compound. Below you will find the most common forms this debt takes in React, Redux, TypeScript and Next.js codebases, why each one becomes expensive, and a practical way to reduce it without stopping product work for a rewrite.

How a clean repository drifts

A new project usually starts with a layout anyone can read at a glance:

src/
  components/
  pages/
  hooks/
  services/
  store/

A developer who has never seen the repository can guess where a new hook or service should go. Then the product expands, the team expands and the feature list expands. A few years later the same tree often looks like this:

src/
  components/
    Button/
    Modal/
    Table/
    UserTable/
    UserTableNew/
    CommonTable/
    GenericTable/
    SharedTable/
    ...
  hooks/
    useUser.ts
    useUsers.ts
    useUserData.ts
    useFetchUser.ts
    useUserDetails.ts
    ...
  utils/
    helpers.ts
    common.ts
    misc.ts
    formatter.ts
    newHelpers.ts
    oldHelpers.ts
  store/
    user/
    users/
    userData/
    userProfile/
    ...

Notice the pattern: several tables with overlapping purposes, five hooks that all fetch "a user" in slightly different ways, helper files distinguished only by the words "new" and "old", and four store folders for what is arguably one domain. No one designed this. It grew, one pull request at a time, which is exactly why this kind of debt is dangerous: there is never a single moment when it obviously needs fixing.

The shortcut that quietly becomes permanent

The most familiar source of debt is the deferred cleanup. A feature has to ship today; the tidy implementation would take two days, the quick one three hours. Choosing the three-hour version is not automatically poor engineering. Sometimes it is precisely the right business call.

The risk appears when the shortcut outlives its reason. Temporary code has a strong tendency to turn into production infrastructure, and the decisions that create it usually sound harmless in the moment:

"Let's just put this in the existing component." "We'll refactor this after the release." "For now, let's keep it in Redux." "We can generalize it later." "Nothing else will ever need this."

Half a year on, the context has evaporated. Nobody recalls why the code was written that way, the person who wrote it may have moved to another team, the requirement it served has shifted, and three other features now rely on its behavior. Undoing a three-hour shortcut can then take three weeks.

When one component owns an entire feature

The "fat component" is one of the most recognizable symptoms. It tends to begin with a reasonable-looking list of jobs sketched inside a single function:

function Checkout() {
  // fetch products
  // validate coupon
  // calculate totals
  // manage address
  // handle payment
  // track analytics
  // show modals
  // handle errors
  // render UI
}

Each new requirement lands in the same place because that is where the related code already lives. Eventually the component handles:

  • talking to the API
  • business rules and validation
  • state management
  • analytics and permission checks
  • navigation and error handling
  • styling and rendering

React is not really at fault here. The actual issue is that too many responsibilities have leaked across the component boundary. A component is a good place for rendering and for UI state that belongs to that piece of UI; it should not become the container for every concern a feature touches. Pricing rules, coupon validation and analytics can live in plain modules or custom hooks that are testable without rendering anything.

Reusable components that cost more than they save

Reusability feels like an unambiguous good early in a career. With experience it becomes clear that every abstraction carries a price. Consider a table that has been made "configurable":

<DataTable
  data={data}
  columns={columns}
  loading={loading}
  selectable
  searchable
  sortable
  pagination
  serverPagination
  expandable
  virtualized
  stickyHeader
  renderEmptyState={...}
  renderLoadingState={...}
  onRowClick={...}
/>

It is reusable in a technical sense. Whether it is maintainable is a different question, and the honest answer is "it depends." Every boolean flag doubles the number of combinations the component must support, and each new consumer that needs a slight variation adds another prop instead of another component.

A more useful test than "can this be reused?" is whether the abstraction will make building the next feature easier. A component carrying thirty props is often not a reusable architecture at all; it is several distinct components squeezed behind a single API. Composition, where consumers assemble smaller pieces, is frequently the better escape route, as covered in fixing React prop overload with composition and slots.

Where the utils folder hides your domain

Almost every sizeable frontend grows a utils directory, and at first that is harmless. Over time it tends to look like this:

utils/
  formatDate.ts
  formatCurrency.ts
  userHelpers.ts
  permissions.ts
  productHelpers.ts
  validation.ts
  apiHelpers.ts
  stringUtils.ts
  common.ts
  helpers.ts

Once the folder reaches this state, nobody can tell where new logic belongs, so the next helper goes into common.ts, and the one after that too. The code is organized by file type, but the business domain is invisible. Permission checks, product rules and API plumbing sit side by side with string formatting.

The better question to ask is not "which utility file should this go into?" but "which business capability owns this logic?" A feature-oriented layout makes that answer explicit:

features/
  checkout/
    components/
    hooks/
    services/
    types/
    utils/

products/
    components/
    hooks/
    services/
    types/
payments/
    components/
    hooks/
    services/
    types/

This is not a mandate to adopt feature folders everywhere. The underlying principle matters more than the directory names: code should be grouped by ownership and responsibility, not merely by what kind of file it is.

Redux as a catch-all store

Redux is a genuinely useful tool, but putting everything into global state is one of the fastest ways to build frontend debt. The store usually starts with slices that clearly deserve to be global:

auth
user
permissions
cart
products
checkout
notifications

Then it absorbs things that are really about one screen or one interaction:

modal
filters
selectedTab
searchText
formState
temporaryUIState

Eventually the store behaves like an application-wide database for anything that two components might conceivably want to read. Nearly any value can technically go into Redux, so "can it?" is the wrong question. The right one is whether the state actually needs to be global. Sorting state into three categories helps.

UI state that should stay local

Values like these usually belong next to the component that uses them, in useState or useReducer:

isModalOpen
activeTab
inputValue
hoveredItem
expandedRow

Lifting them into a global store makes every open modal or hovered row a concern for the entire app and adds indirection to trivial interactions.

Server state that needs a caching strategy

Data that the backend owns has different needs:

products
orders
users
invoices

Here the hard problems are caching, deduplication, refetching and invalidation. These call for a deliberate data-fetching approach (a dedicated server-state library or an equivalent pattern) rather than hand-written reducers that copy responses into the store. The trade-offs are explored further in rethinking server state in large apps with React Query and Redux.

State that is genuinely application-wide

Some concerns really do cut across the whole product:

authentication
permissions
application-wide preferences
cross-feature workflows

These are reasonable candidates for a global store. The lesson is not "avoid Redux"; it is that availability alone is not a reason to make state global.

TypeScript debt: types that stop meaning anything

TypeScript is one of the most valuable tools for large frontends, yet it does not design a codebase for you. It is entirely possible to sprinkle this pattern throughout an application and still call the project typed:

const data: any = response.data;

Every any switches off checking at exactly the point where data enters the app, so the compiler cannot warn you when the API shape changes. Another common failure is the same domain type declared independently in several files:

type User = {
  id: string;
  name: string;
}

Multiply that across a few teams and you get a family of near-duplicates:

User
UserType
UserModel
UserResponse
UserDTO
UserData

Developers then spend more effort deciding which type to import than benefiting from the type system. Aiming for "everything has a type" misses the point. The goal is that the important contracts in the system, such as API responses, domain entities and component interfaces, are explicit and defined once. Good TypeScript makes invalid states hard to represent; bad TypeScript just produces more files. A useful distinction is to keep a transport type for what the API returns and a domain type for what the UI works with, converted in one place, instead of six loosely related variants.

When useEffect becomes the default escape hatch

Effects are another frequent source of slow-burning debt. It begins with a single effect that reacts to one value:

useEffect(() => {
  // do something
}, [something]);

Then a second effect keeps two values in sync:

useEffect(() => {
  // synchronize something
}, [a, b]);

Then a third exists mainly to correct the behavior of an earlier one:

useEffect(() => {
  // fix previous effect
}, [data]);

The component turns into a chain of effects triggering other effects, which is hard to follow and prone to extra renders and ordering bugs. At this stage, being good at React depends less on API knowledge and more on a clear picture of how data flows through the component.

A typical example is storing derived data in state and syncing it with an effect:

const [fullName, setFullName] = useState('');

useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

This renders once with a stale empty string, then runs the effect, then renders again. The value can simply be computed during render instead:

const fullName = `${firstName} ${lastName}`;

It looks like a trivial difference, but across an application these small choices add up. Effects are for synchronizing with systems outside React, such as subscriptions, timers or the DOM, not for deriving one piece of state from another. A deeper treatment lives in stop syncing state with useEffect.

The server/client boundary in Next.js

Modern Next.js adds a new dimension to architectural decisions. Besides asking which component renders something, you must decide where the code runs: on the server, on the client, or both. That boundary is itself an architectural choice.

A frequent mistake is converting a large part of the tree into Client Components because one small element needs interactivity. The directive goes at the top of the page file:

"use client";

and the whole page now ships to the browser as client-side JavaScript:

export default function ProductPage() {
  // entire page becomes client-side
}

all because a single button needs a piece of local state:

const [isOpen, setIsOpen] = useState(false);

That is convenient, but the convenience at one boundary has a cost elsewhere: more JavaScript sent to the browser, and data fetching that could have stayed on the server now has to happen on the client. In App Router terms, everything imported from a "use client" file becomes part of the client bundle, so the placement of the directive determines far more than the one component you meant to make interactive.

It helps to think in layers, with interactivity pushed as far down the tree as possible:

Server
   ↓
Data fetching
   ↓
Feature boundary
   ↓
Interactive client component
   ↓
Small local state

rather than the flat shortcut:

Everything → Client Component

In practice this usually means extracting the interactive button into its own small Client Component and keeping the page, its data loading and most of its markup on the server.

Styling debt is still debt

Teams often treat technical debt as a JavaScript and TypeScript concern. Styling accumulates debt just as reliably, especially in long-lived products that combine several approaches at once: CSS-in-JS and styled-components, CSS modules, utility classes, old global stylesheets, and more than one design system. Sooner or later the component library contains something like this:

Button
ButtonV2
PrimaryButton
NewButton
StyledButton
CommonButton
LegacyButton

and nobody can say which button is the correct one. The styling technology is rarely the root cause. The real gap is a lack of design-system ownership and clear component boundaries. A mature frontend has answers to questions such as:

  • Which components are part of the public, shared set?
  • Which ones exist only for a single feature?
  • Which team is responsible for each of them?
  • What is the approved way to add a variant?
  • How do breaking changes get rolled out?
  • What qualifies for inclusion in the design system?
  • What must stay local and never be promoted to shared?

When duplication beats abstraction

This lesson is often hardest for experienced developers to accept. Suppose there are two components that currently look about 90% alike:

ComponentA
ComponentB

The instinct is to merge them into one:

<UniversalComponent />

Yet duplication is sometimes the cheaper option, because you do not yet know whether the two will keep evolving in the same direction. If they diverge, the merged component fills up with conditionals, and every change to one caller risks breaking the other. A little duplication is healthy; a premature abstraction can create a coupling that lasts for years.

A practical default: write it twice, study the pattern, and abstract only when a third use case confirms the shape. It is not a universal law, but it is a sensible starting rule.

Dependencies that no longer earn their place

Large frontends collect dependencies quickly. A five-year-old codebase can easily include libraries for:

  • state, forms and validation
  • dates, charts and animations
  • UI kits and styling
  • data fetching
  • testing
  • general-purpose utilities

Some are hardly used anymore, and others have been superseded by browser features or by capabilities built into the framework. Removing any of them feels risky, so they stay, and the manifest gradually turns into an architecture document nobody wants to read:

package.json

Dependency hygiene is not only about applying security patches. It means periodically asking whether each dependency still justifies its existence, given the maintenance, upgrade and bundle cost it brings.

The real price: slower, riskier change

Debt is not a problem because engineers find untidy code unpleasant. It is a problem because it alters the economics of shipping software. In a healthy application, a request flows through a short pipeline:

Feature request
      ↓
Small change
      ↓
Tests
      ↓
Deploy

In a heavily indebted one, the same request becomes an investigation:

Feature request
      ↓
Find where this behavior lives
      ↓
Understand 5-year-old code
      ↓
Check Redux dependencies
      ↓
Check shared component
      ↓
Check side effects
      ↓
Check styling overrides
      ↓
Change code
      ↓
Something else breaks
      ↓
Fix regression
      ↓
Add another workaround

Each step in that second chain is time spent understanding rather than building, and the last two steps are the telling ones: a change breaks something unrelated, and the fix adds yet another workaround. That is the codebase charging interest, and it is what technical debt actually is.

Why "it still works" is a misleading signal

An application can run flawlessly while carrying enormous debt. The pages load, users sign in, orders go through, the dashboard renders and CI is green, so everyone assumes the code is in good shape. Then a new feature arrives, and an apparently simple change takes two weeks. That is the moment the debt becomes visible.

"Does the application work?" is therefore a weak health check. A far more useful question, particularly as systems grow, is: how expensive is it to change this application safely?

Paying debt down without a rewrite

The tempting response to a messy codebase is a full rebuild. The typical outcome, six months later, is predictable:

  • the legacy app is still running in production
  • the replacement is only partly finished
  • two systems now need maintenance
  • the team is burned out
  • product work has stalled

Incremental architectural improvement almost always works better. It starts with finding where the pain actually is.

Locate the expensive areas

Resist the urge to refactor everything. Instead, identify the parts of the product where developers repeatedly struggle, for example:

Checkout
User Management
Search
Dashboard
Reporting

For each candidate, look for these signals:

  • Bugs that keep resurfacing in the same area
  • Files people are nervous to edit
  • Components that appear in almost every pull request
  • Parts of the product that new hires take longest to learn
  • Features whose changes spill into unrelated modules

The areas that come up again and again are your debt hotspots.

Measure what change costs today

Sophisticated tooling is not required. Tracking a handful of indicators is enough:

Average time to implement feature
Average PR size
Number of files touched
Regression frequency
Build time
Test execution time
Deployment frequency

Tying debt to engineering outcomes like these makes it far easier to prioritize, because you can show that a particular area slows delivery rather than arguing that its code is unattractive.

Define dependency rules before picking a pattern

Before debating Clean Architecture or Feature-Sliced Design, settle a more basic question: what is allowed to depend on what? A simple layered direction might look like this:

app
 ↓
features
 ↓
shared

Within that, a feature may depend on shared building blocks:

features/product
      ↓
shared/ui
shared/lib

but shared code must never reach back into a feature:

shared/ui
      X
features/product

The specific architecture can change over time; the direction of dependencies is what keeps it coherent. Once shared UI starts importing from a product feature, it is no longer shared, and every consumer silently depends on that feature. These rules are also easy to enforce automatically with lint rules on import paths, which turns an architectural intention into something CI checks on every pull request.

A feature-oriented layout for React and Next.js

No single structure is correct for every project, but organizing by feature gives you natural boundaries:

src/
  app/
    routes/
    providers/
    layouts/


features/
    checkout/
      components/
      hooks/
      services/
      state/
      types/
    products/
      components/
      hooks/
      services/
      state/
      types/
    users/
      components/
      hooks/
      services/
      state/
      types/
  shared/
    ui/
    hooks/
    lib/
    types/

Copying these folder names is not the point. What matters is that ownership becomes explicit: app wires routes, providers and layouts together, each feature owns its components, hooks, services, state and types, and shared contains only what is truly generic. For a comparison of alternatives, see a practical comparison of React folder structure patterns.

Habits to avoid on a large frontend

When joining or maintaining a big React codebase, be wary of the following:

  • Globalizing state by default. Most values never need to leave their component.
  • Generalizing too soon. Understand the variations first, then abstract.
  • Adding an effect as a reflex. Think through the data flow before synchronizing anything.
  • Opening another utils.ts. Find the domain that should own the logic.
  • Marking whole pages as client code. In the App Router this is especially costly.
  • Installing a package for something the platform or framework already does. Each one becomes a long-term maintenance obligation.
  • Refactoring before measuring. Tidiness is not a business outcome; lower change cost is.

From writing better code to building changeable systems

The most significant shift that comes with experience is a change of goal rather than a new technique. Early on, the natural question is how to write a given piece of code better. Later, the question becomes how to leave the system easier for whoever changes it next. That reframing reshapes how you approach components and abstractions, state management with Redux, TypeScript, Next.js, styling, tests, overall architecture and dependencies. Instead of polishing individual files, you start optimizing the system's capacity to evolve.

Debt is a tool, not an enemy

You cannot eliminate technical debt, and trying to is a mistake. Every product makes trade-offs, and many of them generate debt by design: shipping quickly, supporting several business requirements at once, keeping legacy browsers working, changing product direction, or absorbing an acquired application. Even an excellent architectural decision can become debt once the surrounding system changes.

So "zero debt" is the wrong target. The realistic goal is debt the team understands, keeps under control, and can pay down once it starts to affect delivery.

What actually makes a large app hard to work in

It is worth being precise about the root causes, because they are rarely the things people blame:

  • Many components are not the problem; relationships between them that nobody can reason about are.
  • Redux existing is not the problem; nobody knowing what belongs in it is.
  • Lots of TypeScript types are not the problem; types that no longer represent meaningful boundaries are.
  • Next.js offering Server and Client Components is not the problem; a team that does not know where the boundary belongs is.
  • Old code is not the problem; old decisions that can no longer be changed safely are.

Put briefly, technical debt is not code that looks ugly. It is code that makes change unnecessarily expensive, and the best frontend architecture is not the one that looks perfect on day one but the one that still lets the team move quickly five years later.

A pre-feature checklist

Before adding the next feature to a React codebase, run through these questions:

  • Is there a real reason for this state to be global?
  • Does the backend own this data, or is it purely UI state?
  • Is the component taking on a second or third responsibility?
  • Is the new abstraction solving a need, or does it just look tidier?
  • Which feature is the owner of this business rule?
  • Could this code run on the server in Next.js instead?
  • Is an Effect truly required here?
  • Will this add another package to maintain?
  • Is the component genuinely used across features?
  • Could a newcomer follow this code without asking whoever wrote it?

These questions tend to be worth more than yet another list of React best practices.

Topics worth exploring next

Several threads from this guide deserve deeper treatment on their own:

  • When Redux is actually the right choice, compared with local state, Context, Redux Toolkit, RTK Query, dedicated server-state handling, URL state and form state.
  • Refactoring a 1,000-line component without breaking production, as a step-by-step case study.
  • Whether reusable components are making an app worse, covering prop explosion, composition and design systems.
  • Server versus Client Components in Next.js, focused on boundary decisions rather than on what "use client" does.
  • TypeScript at scale, including any, generics, DTOs, domain and API types, duplication and inference.
  • What should replace the utils folder as a home for logic.
  • Knowing when an application needs an architecture refactor, ideally with a scoring model built on criteria such as these:
Change cost
Bug frequency
Dependency coupling
Component size
State complexity
Build time
Testability
Developer onboarding

Key takeaways

  • Frontend debt is the sum of small, reasonable decisions; watch the trend, not individual commits.
  • Judge codebase health by the cost and risk of change, not by whether the app currently works.
  • Keep state local by default, treat server data as a caching problem, and globalize only what is truly cross-cutting.
  • Prefer boundaries and dependency direction over folder names or named methodologies.
  • Pay debt down incrementally in measured hotspots rather than betting on a rewrite.