Refactoring a Legacy React Codebase: Step-by-Step
You inherit a 40,000-line React app with no folder structure, business logic scattered across components, and imports that form a tangled dependency graph. The thought of refactoring is paralyzing. But chaos is profitable: you can make steady, measurable progress without rewriting. This article shows you how to refactor a legacy codebase incrementally, using escape hatches to contain risk, and ship improvements weekly. You'll learn when to extract, when to leave code alone, and how to build team confidence that refactoring works.
The Refactoring Mindset
The biggest mistake is trying to refactor everything at once. You'll break production, lose team trust, and abandon the effort. Instead: refactor at the boundary. When you touch a file, improve it. When you add a feature, add it to the new structure. Over time, the legacy code shrinks and the new code grows until the refactoring is done.
This is called the Strangler Fig pattern: new code surrounds old code, gradually replacing it.
A team refactored a 60,000-line app with this method: no major rewrites, no prolonged feature freeze. After 18 months, 80% of the codebase was new structure. After 24 months, 100%. They shipped features the entire time.
Audit the Current State
Before refactoring, understand what you're dealing with. Run these checks:
Check 1: Identify the pain points.
Ask the team: "What's the slowest, most painful part of the codebase?" Common answers:
- "Finding where code lives" → folder structure problem.
- "Changing one thing breaks three others" → circular dependencies.
- "Components are 500 lines long" → separation of concerns.
- "Tests take 5 minutes to run" → dependencies are too heavy.
- "API calls are scattered everywhere" → no API abstraction layer.
Focus on the top 3 pain points. Those are your refactoring targets.
Check 2: Measure bundle size and build time.
npm run build -- --report
# or with webpack-bundle-analyzer
Large bundles often indicate dead code or unnecessary dependencies. Build time over 30 seconds suggests heavy dependencies in hot paths. These are refactoring wins waiting to happen.
Check 3: Find circular dependencies.
npm install --save-dev circular-dependency-plugin
// webpack.config.js
const CircularDependencyPlugin = require('circular-dependency-plugin');
plugins: [
new CircularDependencyPlugin({
exclude: /node_modules/,
failOnError: false,
}),
]
Run the build. Any circular dependencies are logged. These must be untangled; they're the biggest scalability blockers.
Check 4: Count files by folder.
find src -type f | grep -E '\.(jsx?|tsx?)$' |
cut -d'/' -f2 |
uniq -c |
sort -rn
# Output:
# 145 components
# 89 utils
# 34 hooks
# 12 pages
# 8 services
Folders with 100+ files need subdivision. Create sub-categories.
Phase 1: Extract Services and Business Logic
Start here: move API calls and business logic out of components.
Step 1: Create a services/ folder.
src/
services/ # NEW
api.js # centralized API client
userService.js
orderService.js
// old structure still exists
Step 2: Identify pure functions that can be extracted.
In your components, look for functions that:
- Take inputs, return outputs, have no side effects.
- Are used by more than one component.
- Contain business logic (validation, calculation, transformation).
// BEFORE: logic inside component
export function UserForm() {
const [email, setEmail] = useState('');
const handleSubmit = () => {
// Validation logic: extract this
if (!email.includes('@')) {
setError('Invalid email');
return;
}
// API call logic: extract this
fetch('/api/users', { method: 'POST', body: JSON.stringify({ email }) })
.then(r => r.json())
.then(data => setSuccess(data));
};
return <form onSubmit={handleSubmit}>...</form>;
}
Extract:
// services/validators.js (pure functions)
export function validateEmail(email) {
return email.includes('@') && email.length > 3;
}
// services/userService.js (API layer)
export const userService = {
create: async (email) => {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify({ email })
});
return response.json();
},
};
// AFTER: component is clean
import { validateEmail } from '../services/validators';
import { userService } from '../services/userService';
export function UserForm() {
const [email, setEmail] = useState('');
const handleSubmit = async () => {
if (!validateEmail(email)) {
setError('Invalid email');
return;
}
const data = await userService.create(email);
setSuccess(data);
};
return <form onSubmit={handleSubmit}>...</form>;
}
Do this incrementally: every component you touch, extract its business logic. Within three months, most business logic is in services.
Step 3: Create service tests.
Services without UI are easy to test. Write unit tests immediately:
// services/__tests__/validators.test.js
describe('validateEmail', () => {
it('returns true for valid emails', () => {
expect(validateEmail('[email protected]')).toBe(true);
});
it('returns false for invalid emails', () => {
expect(validateEmail('notanemail')).toBe(false);
});
});
Test coverage jumps from 30% (testing components) to 70% (testing services).
Phase 2: Create Feature Boundaries
As you extract services, group them by feature. Create a features/ folder parallel to your old structure.
Step 1: Identify features.
Look at your pages and routes:
/dashboard→ dashboard feature./orders→ orders feature./auth/login→ auth feature.
Step 2: Create feature folders incrementally.
Don't move everything at once. Start with one stable, isolated feature (often auth).
src/
features/
auth/ # NEW: all auth code
pages/
LoginPage.jsx # moved from src/pages
SignUpPage.jsx
components/
LoginForm.jsx # moved from src/components/auth/
OtpInput.jsx
services/
authService.js # new, extracted from component logic
tokenManager.js
hooks/
useAuth.js
__tests__/
authFlow.test.jsx
Step 3: Update imports in related components.
After moving auth, find everything that imports auth code:
grep -r "import.*auth" src --include="*.jsx" | grep -v "features/auth"
Update imports:
// BEFORE
import { LoginForm } from '../components/auth/LoginForm';
// AFTER
import { LoginForm } from '../features/auth/components/LoginForm';
Step 4: Run tests after each feature.
After moving auth, run the full test suite. Fix any failures. This validates your refactoring and prevents breakage.
Step 5: Deploy and monitor.
You haven't changed behavior, only organization. Deploy and monitor for errors. If something breaks, it's a refactoring bug that needs fixing before moving the next feature.
Repeat for the next feature. Most teams move one feature per week. A 40,000-line app has 6–8 features; the refactoring takes 2–3 months while shipping features.
Phase 3: Fix Circular Dependencies
Once business logic and features are extracted, circular dependencies are obvious.
Step 1: Detect them.
npm run build 2>&1 | grep "cyclic"
Output:
useUser -> features/orders -> features/auth -> useUser
Step 2: Break the cycle by extracting.
The cycle exists because two features both need the same hook. Extract to shared:
src/
features/
shared/ # NEW: shared across features
hooks/
useCurrentUser.js # extracted: auth + orders both use it
Now:
features/auth/hooks/useAuth.jsimports fromfeatures/shared/hooks/useCurrentUser.js.features/orders/hooks/useOrder.jsimports fromfeatures/shared/hooks/useCurrentUser.js.- No cycle.
Step 3: Add an ESLint rule to prevent regression.
// .eslintrc.json
{
"rules": {
"import/no-cycle": "error",
}
}
Now every developer gets an error if they create a circular import.
Phase 4: Consolidate and Clean
After 3–6 months, the old structure is mostly empty. Clean it up.
# Find empty folders
find src -type d -empty
# Find unused files (files with no imports)
npm install --save-dev unimported
unimported --help
Delete empty folders and truly unused files. Consolidate the new structure.
src/
features/
auth/
orders/
dashboard/
shared/
# old folders are gone
Refactoring Tools and Tactics
Escape Hatch: A Temporary Compatibility Layer
If moving a large component is risky, use an escape hatch: create a new version in the new structure, keep the old one, and gradually migrate consumers.
// src/components/Button.jsx (old)
export function Button({ children, ...props }) {
return <button {...props}>{children}</button>;
}
// src/features/shared/components/Button.jsx (new)
export function Button({ children, ...props }) {
return <button {...props}>{children}</button>;
}
// src/components/Button.jsx (old, now a re-export)
// Deprecated: import from features/shared/components instead
export { Button } from '../features/shared/components/Button';
Over time, all imports of the old path migrate to the new path. After 6 months, delete the escape hatch.
Automated Refactoring with Codemods
For large-scale imports, use codemods (code modification scripts):
// codemod.js: replace all old imports with new ones
const transform = (file, api) => {
const j = api.jscodeshift;
const root = j(file.source);
root
.find(j.ImportDeclaration)
.filter(path => path.value.source.value.includes('components/LoginForm'))
.replaceWith(path => {
path.value.source.value = 'features/auth/components/LoginForm';
return path.value;
});
return root.toSource();
};
export default transform;
Run with jscodeshift:
npx jscodeshift -t codemod.js src --dry
# Review changes with --dry, then remove --dry to apply
This refactors thousands of imports in seconds.
Feature Flags for Gradual Rollout
If you're worried about breaking changes, use feature flags:
// features/shared/flags.js
export function useNewStructure() {
return process.env.REACT_APP_NEW_STRUCTURE === 'true';
}
// Component during refactoring
import { useNewStructure } from '../features/shared/flags';
import { OldUserCard } from '../components/UserCard';
import { NewUserCard } from '../features/user/components/UserCard';
export function UserCard(props) {
const newStructure = useNewStructure();
return newStructure ? <NewUserCard {...props} /> : <OldUserCard {...props} />;
}
Deploy with the flag off. Enable for 10% of users, then 50%, then 100%. If something breaks, flip the flag off.
Key Takeaways
- Refactor incrementally using the Strangler Fig pattern: build the new structure alongside the old, gradually replacing it.
- Start with extracting services and business logic out of components.
- Move features to a feature-based structure one feature at a time.
- Fix circular dependencies by extracting shared code to a lower layer.
- Use escape hatches and feature flags to minimize risk.
- Ship every week; never pause feature development for refactoring.
Frequently Asked Questions
What if I'm forbidden from refactoring?
Start with escape hatches: write new features in the new structure inside the old codebase. After three months of new features in the new structure, management will see it's more maintainable and safer. Refactoring becomes an obvious business win.
How do I refactor tests without breaking test coverage?
Tests are part of the refactoring. As you move code, move tests with it. Focus on end-to-end tests first (these are framework-agnostic); then unit tests of extracted services. Component tests are refactored last because they're tightest to the old structure.
Can I use TypeScript to guide refactoring?
Absolutely. Add TypeScript to services first (pure functions). Type errors will immediately reveal where old code imports from moved services. Use TypeScript's strict mode to catch breaking changes: noImplicitAny, strictNullChecks, noUnusedLocals.
How do I convince the team refactoring is worth it?
Measure and show progress: bundle size before/after, build time, test time, onboarding time for new developers. After moving the first feature, the team sees how much cleaner the new structure is. Trust builds naturally.
What if I have 200,000 lines and no tests?
This is harder but still doable. Write integration tests for critical paths first (auth, payments, reporting). Extract services without moving components until you have test coverage. You'll uncover bugs, but that's the point: better to find them now than in production.