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.
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
useEffectcalls - 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:
ProductTableis responsible for rendering the table.useProductFiltersholds filter behaviour.useProductsexposes product data to React.productServicetalks to the backend.productValidatorcontains 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.