Want to refactor a large React component with AI tools without making it worse? You know how this starts. A UserDashboard component that was 50 lines six months ago.
Now it's 500. State declarations at the top, a useEffect that fetches user data and
updates three different state variables, a form with validation logic inline, and three
visual sections that all read from the same sprawling local state.
AI can help you fix this. But if you just paste the component into ChatGPT and say "make this cleaner," you'll get back 500 lines of slightly rearranged code. Same complexity, new arrangement. The problem isn't that AI is bad at refactoring, it's that architecture requires a decision you have to make first.
Here's the decision: find the seams. Then hand the execution to AI. The AI is an aid for the grunt work, not a substitute for that first call.
Check the quality framework for how this fits into a broader TypeScript code health strategy.
Quick Take: Large React components fail because they mix concerns, not because they're long. Identify the boundaries between data fetching, form state, validation, and rendering first, then use specific AI prompts to extract each piece. TypeScript prop types make every split explicit. According to the React docs (React Docs, Thinking in React, 2024), the foundation is always identifying the minimal UI state and where it should live.
What Is a "Seam" in a React Component?
A seam is a boundary where two distinct responsibilities touch inside one file. In React components, the most common seams are: state logic sitting next to render logic, data fetching tangled with display, and form handling mixed with validation. Identifying seams takes five minutes and saves hours of confused AI output.
Ask three questions before you touch any code:
- What state could live in a custom hook instead of this component?
- What sections of JSX could be their own components without needing to share state?
- What logic (validation, calculations, transforms) doesn't actually need React at all?
Here's what a tangled component looks like in practice. This is pseudo-code, but I've refactored versions of this exact shape more times than I can count:
// UserDashboard.tsx, ~500 lines, before
function UserDashboard({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [formData, setFormData] = useState({ name: '', email: '' });
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
const [activeTab, setActiveTab] = useState('profile');
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => { setUser(data); setLoading(false); });
}, [userId]);
function handleSubmit(e: React.FormEvent) {
const errors: Record<string, string> = {};
if (!formData.name.trim()) errors.name = 'Name is required';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email))
errors.email = 'Invalid email';
if (Object.keys(errors).length) { setFormErrors(errors); return; }
fetch(`/api/users/${userId}`, { method: 'PATCH', body: JSON.stringify(formData) });
}
// ... 400 more lines of JSX, tab logic, and a settings form
}
Three seams are visible immediately. Data fetching with loading state. Form state with validation. And two or three distinct visual sections that share almost no state.
Why Extract Custom Hooks First?
Data fetching logic, form state, and complex derived calculations all belong in hooks, not because it's a rule, but because moving them there forces the component to declare exactly what it needs. According to Kent C. Dodds (Kent C. Dodds, Writing Resilient Components, 2023), resilient components do one thing and accept what they need as props or return values from hooks.
Extract hooks before splitting render. Here's why: if you split render first, the new sub-components still depend on the same tangled state from the parent. You're just spreading the coupling across files instead of fixing it.
Here's the useUserProfile hook extracted from the component above:
// hooks/useUserProfile.ts
interface UseUserProfileReturn {
user: User | null;
loading: boolean;
error: string | null;
}
function useUserProfile(userId: string): UseUserProfileReturn {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => { setUser(data); setLoading(false); })
.catch(err => { setError(err.message); setLoading(false); });
}, [userId]);
return { user, loading, error };
}
The component now calls const { user, loading } = useUserProfile(userId) and the
fetch logic is gone from its body. Clean interface. Independently testable.
The AI prompt pattern that works: "Extract the data fetching and loading state into a
custom hook named useUserProfile. Keep the interface minimal, only expose what the
component actually uses. Do not add optional parameters or generic abstractions that
aren't already in this code."
That last constraint, "do not add abstractions beyond what exists", is the one I
keep adding back. Without it, AI tools will add retry logic, a caching layer, and an
onSuccess callback the original code never asked for.
How Do You Split the Render Into Sub-Components?
After hooks are extracted, look at the JSX. Render sections that share no state, meaning they don't read from the same useState variables, are good candidates for extraction. According to the React Docs, the test is whether each component has a single reason to change.
Explicit TypeScript prop types matter here. They're not boilerplate, they're the contract. If writing the props interface feels awkward, the split is probably wrong.
// components/UserProfileSection.tsx
interface UserProfileSectionProps {
user: User;
onEditClick: () => void;
}
function UserProfileSection({ user, onEditClick }: UserProfileSectionProps) {
return (
<section>
<h2>{user.name}</h2>
<p>{user.email}</p>
<button onClick={onEditClick}>Edit profile</button>
</section>
);
}
// components/UserSettingsSection.tsx
interface UserSettingsSectionProps {
userId: string;
defaultEmail: string;
}
function UserSettingsSection({ userId, defaultEmail }: UserSettingsSectionProps) {
const { formData, formErrors, handleSubmit } = useProfileForm(userId, defaultEmail);
return (
<form onSubmit={handleSubmit}>
{/* ... */}
</form>
);
}
AI prompt for this phase: "Extract lines 120-190 into a separate component named
UserProfileSection. Define explicit TypeScript props, no object spreading, no
implicit dependencies. The component should not import from parent scope."
No object spreading in props. That's worth repeating. {...props} hides dependencies
and makes the contract invisible to anyone reading the component later.
See TypeScript clean code patterns for naming conventions and interface design that scale as you add more components.
What Logic Should Move Out of React Entirely?
This is the step most refactoring guides skip. Validation, calculations, and transformations that don't need React state are better off as plain TypeScript functions, not hooks, not components. Just functions. I've found this step produces bigger readability gains than splitting components, and it's almost always overlooked.
// utils/validateUserForm.ts
interface UserFormData {
name: string;
email: string;
}
interface ValidationResult {
valid: boolean;
errors: Record<string, string>;
}
function validateUserForm(data: UserFormData): ValidationResult {
const errors: Record<string, string> = {};
if (!data.name.trim())
errors.name = 'Name is required';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email))
errors.email = 'Invalid email address';
return { valid: Object.keys(errors).length === 0, errors };
}
Pure functions have clear input and output types. No component state bleeds into them. They're trivial to unit test without mocking React context or rendering anything.
Why does this matter for real teams? Every team I've worked with has rewritten the same
email validation regex in three different components. Once it's a standalone function
with a clear signature, it gets reused. The component just calls
validateUserForm(formData). Done.
How Do You Know the Refactor Is Done?
TypeScript catches most regressions automatically. Missing props, wrong types, unused state, these surface at compile time during the split, not three weeks later in production. That's the real argument for explicit prop interfaces: you can't quietly drop a required field.
The refactor is complete when you can verify three things:
- Each file has one responsibility you can describe in a sentence without "and"
- No prop drilling goes deeper than two levels
- Every custom hook works in isolation, writable as a test without rendering a parent component
Run this AI review prompt after finishing: "Review this refactored component for any remaining prop drilling beyond two levels, and flag any mixed concerns, state logic sitting in a render function, or fetch calls outside a hook."
TypeScript catches structural errors. The AI review catches the architectural ones.
Pair this with automated code review in your CI pipeline to catch regressions on every pull request.
AI is a strong executor and a weak architect. I've seen developers paste a 500-line component into an AI tool and get back 12 files, technically split, but with the same tangled dependencies spread across more imports. The seam identification step is the part that requires you.
Five minutes asking those three questions upfront saves hours of untangling AI output afterward.
Start with one seam. Extract one hook. Run the TypeScript compiler. It'll tell you what broke. Fix it. Then move to the next seam.
Related Tutorials
- AI Code Review for TypeScript: CodeRabbit + GitHub Actions
- AI coding tools 2026
- blocking AI crawlers
- TypeScript AI agents