Home / Articles

Decomposing Oversized React Components Without Production Regressions

A step-by-step approach to breaking up large React components safely: mapping responsibilities, extracting pure logic, owning state, and shipping in small steps.

Publisher: REACTAPP.TOP17 min read3880 words

This article is published in English.

Sources: React Components Are Getting Too Big: How to Refactor Without Breaking Production

Every mature React codebase has one: a component that works, ships revenue and is feared by everyone who has to touch it. It began as a modest screen, then gained filtering, pagination, API calls, permissions, validation, dialogs, exports, analytics and a growing collection of effects. This guide shows how to shrink that kind of component by reducing its complexity rather than just its file size, and how to do it in steps that never put production behaviour at risk.

A typical example has quietly absorbed every concern of the feature:

function ProductManagement() {
  // API calls

  // Redux selectors
  // Local state
  // Filters
  // Pagination
  // Sorting
  // Selection
  // Permissions
  // Validation
  // Business rules
  // Export
  // Modal handling
  // Analytics
  // Hundreds of lines of JSX
}

At a thousand lines or more it looks like the obvious problem is length. It is not. A long component is not automatically a problem; a component with many unrelated reasons to change is. The work described below is not about cutting one file into ten, but about lowering the cost and risk of the next change.

Line count is the wrong metric

Rules such as "refactor anything over 300 lines" are easy to enforce and poor at finding real risk. Picture 500 lines of mostly declarative markup with little logic. Now picture 200 lines that mix API calls, state transitions, permission checks, validation, effects, business rules and nested conditional rendering. The shorter file is almost certainly the harder one to change safely.

What matters is cognitive load. A useful question: how much context does a teammate need before they can modify this component with confidence?

Symptoms that complexity has become a liability

Watch for these signals:

  • several unrelated useEffect calls
  • a long list of independent state variables
  • API requests made directly from UI components
  • business rules buried inside click handlers
  • deeply nested conditional rendering
  • permission logic interleaved with presentation
  • the same data transformation repeated in several places
  • different responsibilities reading and writing the same state
  • long chains of props passed through intermediate components
  • duplicated network requests
  • tests that must mount a whole page to check one small rule
  • frequent merge conflicts in the same file

The most telling symptom is social rather than technical: people avoid the file. Once "don't touch that, something always breaks" becomes team folklore, the component is no longer a style issue but an engineering risk.

Map responsibilities before touching the code

The first step is not extracting JSX in the editor. It is writing down what the component actually does. For the product management screen, the inventory might look like this:

ProductManagement
│
├── Fetch products
├── Transform API response
├── Filter products
├── Sort products
├── Manage selection
├── Validate permissions
├── Export data
├── Open/close dialogs
├── Handle errors
└── Render UI

That list already reveals that the component plays several roles at once. Grouped by kind, the responsibilities fall into data, behaviour and presentation:

              ProductManagement
                     │
       ┌─────────────┼─────────────┐
       │             │             │
       ▼             ▼             ▼
     Data         Behavior      Presentation
       │             │             │
       ▼             ▼             ▼
     API       Filters/Rules      Table
     State     Selection          Dialog
               Validation         Actions

This grouping is where a good refactor starts. You are looking for boundaries, and only afterwards for files.

The target: layers with clear ownership

Splitting one big file into five smaller ones changes the layout on disk but not the design:

BigComponent.tsx
      ↓
5 smaller .tsx files

The real goal is a layered structure in which UI, behaviour, domain logic and data access each have their own place:

ProductPage
     │
     ├─────────────── UI
     │
     ├─────────────── Hooks / Behavior
     │
     ├─────────────── Domain Logic
     │
     └─────────────── Data Access

In practice, a feature-oriented folder is a convenient way to express that:

features/products/
│
├── components/
│   ├── ProductFilters.tsx
│   ├── ProductTable.tsx
│   ├── ProductActions.tsx
│   └── ProductDialog.tsx
│
├── hooks/
│   ├── useProducts.ts
│   ├── useProductFilters.ts
│   └── useProductSelection.ts
│
├── services/
│   └── productService.ts
│
├── utils/
│   ├── productMapper.ts
│   └── productValidator.ts
│
└── types.ts

The folder names matter less than what each module owns:

  • ProductTable is responsible for rendering the table.
  • useProductFilters holds filter behaviour.
  • useProducts exposes product data to React.
  • productService talks to the backend.
  • productValidator contains pure validation rules.

The shape you want is a one-directional flow, where each layer depends only on the one below it:

UI
 ↓
Behavior
 ↓
Data Access
 ↓
Backend

and not a web in which every concern reaches into every other:

UI
 ↕
State
 ↕
API
 ↕
Business Logic
 ↕
UI

For a closer look at the prop-drilling side of this problem, see cutting React prop drilling and god components down to size.

Step one: move pure logic out of React

Pure functions are the lowest-risk extraction, which makes them the natural starting point. A status helper like this one is typically defined inside the component:

const getProductStatus = (product: Product) => {
  if (!product.active) {
    return "Inactive";
  }

  if (product.stock <= 0) {
    return "Out of Stock";
  }
  return "Available";
};

Nothing in it depends on React: no useState, no useEffect, no Redux, no context. There is no reason for the component to own it, so move it to its own module:

utils/productStatus.ts

Once it lives there, the rule can be tested directly, without rendering anything:

describe("getProductStatus", () => {
  it("returns inactive for disabled products", () => {
    expect(
      getProductStatus({
        active: false,
        stock: 10
      })
    ).toBe("Inactive");
  });
});

That is a significant gain for very little risk: the business rule is now decoupled from rendering, and a failing test points straight at the rule rather than at a page. The guiding principle is simple: if some logic does not need React, React should not be responsible for it.

Step two: extract React behaviour into hooks

Next come the pieces that genuinely are React: state and effects that work together. Loading products is a typical cluster of three state variables and one effect:

const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

useEffect(() => {
  setLoading(true);
  fetchProducts()
    .then(setProducts)
    .catch(setError)
    .finally(() => setLoading(false));
}, []);

Moved verbatim into a custom hook, it becomes a single unit with a small return value:

function useProducts() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetchProducts()
      .then(setProducts)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);
  return {
    products,
    loading,
    error
  };
}

The screen then consumes it in one line:

function ProductManagement() {
  const {
    products,
    loading,
    error
  } = useProducts();

// composition
}

The component now contains less implementation detail and reads more like a description of what the screen is built from. Moving code verbatim is deliberate: the extraction should not change behaviour. Improvements such as setting loading to true initially (so the first render does not briefly look "loaded"), ignoring responses after unmount, or adopting a server-state library belong in a separate, later change.

Not everything should become a hook

This is where refactors tip into over-engineering. Some codebases end up with a hook for every small function:

useProductName()
useProductStatus()
useProductLabel()
useProductColor()
useProductFormatter()

That is not an architectural improvement. A custom hook earns its existence when it encapsulates React behaviour: state, effects, context, other hooks, subscriptions or lifecycle concerns. If all you need is a formatter,

formatProductName(product)

then a plain function call is exactly right:

formatProductName(product)

Wrapping it as a hook,

useProductName(product)

adds the Rules of Hooks, prevents calling it conditionally or inside loops, and gains nothing. Abstractions should lower the mental load of a codebase, not add to it.

Decide who owns each piece of state

Where state lives is one of the most consequential decisions during a refactor. A useful classification separates server data, global UI state and local UI state:

Application State
│
├── Server State
│   ├── Products
│   ├── Orders
│   └── Customers
│
├── Global UI State
│   ├── Authentication
│   ├── Theme
│   └── Application Preferences
│
└── Local UI State
    ├── Modal
    ├── Selected Tab
    └── Temporary Form State

Neither extreme works: pushing everything into Redux creates a bloated global store, and keeping everything local leads to duplication and prop chains. For each piece of state, answer:

  • Which components read it?
  • How long must it live?
  • Is it owned by the server or by the client?
  • Must it survive navigation?
  • Does a source of truth for it already exist?

A reliable heuristic: keep state as close to its consumers as possible, but not closer than their shared needs allow.

Avoid creating a "god parent"

Splitting a component can produce a new problem. The children are small, but the parent now holds all the state and passes it down:

<ProductPage
  products={products}
  filters={filters}
  setFilters={setFilters}
  selected={selected}
  setSelected={setSelected}
  permissions={permissions}
  loading={loading}
  error={error}
  onExport={handleExport}
  onDelete={handleDelete}
  onEdit={handleEdit}
/>

The file is shorter, yet the coupling is identical; it has simply moved up a level. Before reaching for context or a store to hide those props, ask whether the state is genuinely shared or whether the component boundaries are drawn in the wrong place. Context is appropriate when several descendants truly need the same value, and a global store when state has application-wide ownership. Neither should paper over boundaries that do not match the domain.

Replace the component gradually with the strangler pattern

Rewriting the whole component in one go is the riskiest option. Replacing it piece by piece is far safer. At the start, the old component does everything:

BigComponent
└── Everything

Then one capability, such as filtering, moves into its own component while the rest stays untouched:

BigComponent
├── ProductFilters
└── Everything else

The table follows in the next iteration:

BigComponent
├── ProductFilters
├── ProductTable
└── Everything else

Eventually the page is pure composition, and the legacy implementation is gone:

ProductPage
├── ProductFilters
├── ProductTable
├── ProductActions
└── ProductDialog

This is the Strangler Fig pattern, usually described for migrating backend systems, applied to a frontend: new pieces grow around the old implementation, each taking over one capability while the rest of the application keeps working. The benefit is that production never has to leap from

100% Legacy

straight to

100% New

in a single release. Every intermediate state is shippable and reversible.

Keep the public contract stable

In larger organisations, a component like this is often rendered by several screens with a specific set of props:

<ProductManagement
  clientId={clientId}
  onSelect={handleSelect}
/>

Keep that interface unchanged throughout the refactor. Behind it, you are free to reorganise completely:

Existing Public API
        │
        ▼
 ProductManagement
        │
   ┌────┼────┐
   ▼    ▼    ▼
 Hooks  UI  Services

Consumers never need to know that the internals moved to hooks, UI components and services, which keeps the blast radius of each step small. This discipline matters most for shared component libraries, design systems, micro-frontends, enterprise applications and anything consumed by more than one team. If the API does need to change, do it as a separate, explicit migration.

Untangle effects that hide multiple responsibilities

Large components frequently contain effects that do several unrelated things:

useEffect(() => {
  fetchData();

  updateAnalytics();
  validatePermissions();
  calculateSomething();
}, [dependency]);

The issue is not the number of statements but that they likely represent four separate responsibilities, each with its own real dependencies. Split them so each effect declares what it actually reacts to:

useEffect(() => {
  fetchData();
}, [dependency]);

useEffect(() => {
  updateAnalytics();
}, [analyticsDependency]);

While you are there, look for effects that only compute values. Storing a filtered list in state and syncing it from an effect costs an extra render and invites stale data:

const [filteredProducts, setFilteredProducts] = useState([]);

useEffect(() => {
  setFilteredProducts(
    products.filter(product => product.active)
  );
}, [products]);

Deriving it during render is simpler and always consistent:

const filteredProducts = products.filter(
  product => product.active
);

Only cache or synchronise when there is a real reason, such as a measured expensive computation (where useMemo is the tool, not an effect).

Separate server state from UI state

In larger screens, distinguishing these two categories pays off quickly:

                     ProductPage
                        │
            ┌───────────┴───────────┐
            │                       │
       Server State             UI State
            │                       │
      ┌─────┴─────┐          ┌──────┴──────┐
      │           │          │             │
   products    loading     filter        modal
   error       status      selected      tab

The product list and whether a dialog is open are fundamentally different kinds of data. The backend owns the products; the browser owns the dialog. Server data needs caching, refetching, loading and error handling, while UI state is ephemeral and local. Mixing them in one bag of useState calls makes components harder to reason about. How this plays out with dedicated tools is covered in React Query and Redux: rethinking server state in large apps.

A refactoring workflow, not a rewrite

A production refactor is a loop:

Identify
   ↓
Understand
   ↓
Protect
   ↓
Extract
   ↓
Test
   ↓
Validate
   ↓
Deploy
   ↓
Measure
   ↓
Repeat

It is not this:

Delete component
      ↓
Rewrite everything
      ↓
Hope production works

That contrast is what distinguishes refactoring a live system from starting a greenfield project.

What the resulting data flow looks like

With responsibilities separated, a user interaction travels through well-defined layers and back:

User
 │
 ▼
ProductPage
 │
 ▼
useProductFilters
 │
 ▼
useProducts
 │
 ▼
ProductService
 │
 ▼
API
 │
 ▼
Backend
 │
 ▼
ProductService
 │
 ▼
useProducts
 │
 ▼
ProductTable
 │
 ▼
Updated UI

Condensed, the principle is a straight dependency line:

Presentation
      ↓
Behavior
      ↓
Data Access
      ↓
Backend

The table should not know how authentication works, the API service should not know how the table renders, and a business rule should not care whether the UI uses a modal or a drawer. That is separation of concerns with a concrete purpose: each change stays inside one layer.

Watch for duplicated API requests

This regression is common enough to deserve its own warning. Before the refactor, one component fetches the data:

ProductPage
     │
     └── fetchProducts()

After extraction, both the page and a child call the same data hook:

ProductPage
   │
   ├── useProducts()
   │
   └── ProductTable
          │
          └── useProducts()

The code looks tidier, but a plain fetch-in-effect hook now runs twice, doubling requests and possibly showing the two components different snapshots. Whenever you move data-fetching logic, ask who owns that server state. There should be a single source of truth. If several components need the same remote data, either fetch once and pass it down, or use a caching server-state layer that deduplicates requests, rather than letting each consumer fetch independently.

Tests are the safety net

Before changing a critical component, list the behaviours the business depends on:

Product Management
│
├── Filter products
├── Select products
├── Edit product
├── Delete product
├── Export products
└── Permission handling

Then protect them at the right level:

  • Unit tests suit validation, transformations, business rules, formatting and mapping.
  • Component tests suit rendering, interactions, and loading and error states.
  • End-to-end tests suit critical workflows, behaviour spanning several components, and backend integration.

A refactor does not require 100% coverage. It requires confidence in the behaviour that matters. Tests written against the current behaviour before you start are the most valuable ones, because they prove that nothing changed.

Small pull requests are part of the design

Review cost and rollback cost scale with the size of a change. Compare a series like this:

Extract ProductFilters

followed by

Extract ProductTable

and then

Extract product-fetching behavior

with a single change titled

Refactor ProductManagement

that touches

4,000 lines changed

Small PRs are easier to review and to revert, shrink the surface for regressions, leave a readable Git history, simplify debugging and reduce merge conflicts. For a tech lead, this is less a code-quality preference than a way to let a team keep moving in parallel.

Keep refactoring separate from other changes

Resist bundling a refactor with everything else on the wish list:

React upgrade
      +
State migration
      +
API changes
      +
UI redesign
      +
CSS migration
      +
Component refactoring

If production breaks after such a release, finding the cause is painful because any of the six changes could be responsible. Sequence them instead:

Refactor
   ↓
Stabilize
   ↓
Measure
   ↓
Optimize

One significant change at a time keeps every regression attributable.

Feature flags for high-risk replacements

For critical workflows, a feature flag adds another layer of safety by letting both implementations coexist:

return useNewProductExperience
  ? <NewProductManagement />
  : <LegacyProductManagement />;

You can then widen exposure step by step while watching error rates and business metrics:

1%
 ↓
5%
 ↓
25%
 ↓
50%
 ↓
100%

This is most useful for high-traffic screens, revenue-critical flows, enterprise applications, large micro-frontends and risky architectural changes. Treat the flag as temporary: once the new path has proven itself, delete the legacy implementation and the flag, or you end up maintaining both.

Before and after

The starting point, condensed, keeps data, filtering, selection, export and dialogs in one function:

function ProductManagement() {
  const [products, setProducts] = useState([]);
  const [filter, setFilter] = useState("");
  const [selected, setSelected] = useState([]);
  const [loading, setLoading] = useState(false);
  const [showDialog, setShowDialog] = useState(false);

  useEffect(() => {
    // Fetch products
  }, []);
  const filteredProducts = products.filter(...);
  const handleSelect = (...) => {
    // Selection logic
  };
  const handleExport = (...) => {
    // Export logic
  };
  return (
    <>
      {/* hundreds of lines of JSX */}
    </>
  );
}

None of that is necessarily wrong. The problem is that one component is responsible for too many things. After the refactor, the page wires hooks to presentational components:

function ProductManagement() {
  const {
    products,
    loading
  } = useProducts();

const {
    filter,
    setFilter,
    filteredProducts
  } = useProductFilters(products);
  const {
    selected,
    handleSelect
  } = useProductSelection();
  return (
    <ProductPage>
      <ProductFilters
        value={filter}
        onChange={setFilter}
      />
      <ProductTable
        products={filteredProducts}
        selected={selected}
        onSelect={handleSelect}
        loading={loading}
      />
      <ProductActions
        products={selected}
      />
    </ProductPage>
  );
}

The page now describes composition, which is the healthiest job a page-level component can have. Each hook and child can be read, tested and changed on its own.

Smaller does not mean faster

It is tempting to assume that turning

1 large component

into

10 smaller components

improves performance. It does not by itself, and it can make things worse by introducing extra renders, repeated calculations, noisy context updates, duplicate requests, needless useMemo and useCallback, and props that change identity on every render. Architecture and performance are related but separate concerns. Measure before optimising, and use profiling and production telemetry to confirm whether anything actually improved.

Judge success by the cost of the next change

A shrinking line count is a pleasant side effect:

1,200 lines → 300 lines

but it is not the objective. Compare the component before and after on dimensions such as:

  • cognitive complexity and coupling, which should drop
  • testability, which should rise
  • duplicated API calls and production error counts
  • render timings in milliseconds
  • regression rate after releases
  • effort required for a typical change

The directions you are aiming for:

Complexity       ↓
Coupling         ↓
Regression risk  ↓
Testability      ↑
Maintainability  ↑
Developer speed  ↑

The most valuable outcome is lower change cost, not fewer lines.

When not to refactor

Not every large component deserves this effort. Poor reasons include: the file is long, someone prefers small files, a new architecture is fashionable, you want to introduce another library, or the code does not match personal taste. If the component is stable, well tested, rarely modified, low risk and isolated, a big refactor may add more risk than it removes. The decision comes down to a trade-off:

Expected future benefit
          │
          ▼
   Is it worth the
    refactoring risk?
          │
       Yes / No

That is an engineering judgement about expected future changes, not a formatting preference.

Understanding existing behaviour is the hard part

Writing the new implementation is rarely the difficult bit. The difficulty is understanding current behaviour well enough to preserve what users rely on. Production components carry knowledge that is not obvious from reading them: unwritten product assumptions, access rules, hooks into older systems, odd backend responses it tolerates, tracking events someone downstream relies on, workarounds for particular browsers, callers nobody remembers, patches from past incidents and expectations held by other teams. That hidden knowledge is why full rewrites are so much riskier than incremental decomposition. The priority order to keep in mind: preserve behaviour, then improve structure, then optimise.

A production refactoring checklist

Before starting:

  • Find every consumer of the component.
  • Document its current behaviour and map its responsibilities.
  • Establish who owns each piece of state and which APIs it depends on.
  • Identify critical workflows, review existing tests and capture baseline production metrics.

While refactoring:

  • Keep the public contract stable.
  • Extract pure functions first, then React behaviour.
  • Keep state near its consumers and skip abstractions that do not pay for themselves.
  • Leave unrelated changes out, watch for duplicate requests and keep PRs small.

Before merging:

  • Type checking, unit, component and end-to-end tests, and the build all pass.
  • No duplicated requests and no unexpected rendering regressions.

After deploying:

  • Monitor errors, API traffic and performance, and compare against the baseline.
  • Keep an eye on critical user workflows.
  • Remove obsolete code once the new path has earned confidence.

Wrapping up

Big React components are not the enemy; uncontrolled complexity is. The losing strategy looks like this:

Big Component
      ↓
Rewrite Everything

The winning one is a patient loop that ends in a simpler architecture:

Big Component
      ↓
Understand
      ↓
Protect
      ↓
Extract
      ↓
Test
      ↓
Measure
      ↓
Repeat
      ↓
Simpler Architecture

The aim is not more files but safer change: releases that do not surprise anyone, and a codebase the next developer can modify six months from now without fear.