React Components vs Spaghetti: 5 Signs Your UI Is Becoming Unmaintainable
How to recognize—and fix—the warning signs before your codebase becomes a maintenance nightmare.
Introduction: The Component You're Afraid to Open
We've all been there. You open a React component file, scroll through hundreds of lines of tangled logic, mixed concerns, and mysterious state variables—and immediately close it again. Not because it's complex, but because it's messy.
That feeling? That's technical debt accumulating in real-time. And unlike credit card debt, you don't get a monthly statement warning you about it. You discover it the hard way: when a simple feature request takes three days instead of three hours.
The good news: spaghetti code doesn't happen overnight. It follows recognizable patterns. And once you know what to look for, you can refactor your way back to sanity.
In this guide, we'll walk through the 5 clearest signs that your React component has crossed the line from "a bit messy" to "actively hostile," plus practical strategies to fix each one—with code examples you can use today.
Sign #1: Your Component Has More Than 300 Lines
The Problem
There's no magic number for component size, but when you're scrolling past 300 lines, something's wrong. Long components usually suffer from:
- Multiple responsibilities: The component is doing too many things
- Buried logic: Important business rules are hidden in the middle of JSX
- Testing nightmares: You can't test one feature without mocking the entire universe
Here's what this typically looks like:
// ❌ Bad: A 400-line monstrosity doing everything
function UserDashboard() {
const [users, setUsers] = useState([]);
const [filters, setFilters] = useState({});
const [selectedUser, setSelectedUser] = useState(null);
const [editMode, setEditMode] = useState(false);
const [formData, setFormData] = useState({});
const [errors, setErrors] = useState({});
const [loading, setLoading] = useState(false);
const [notifications, setNotifications] = useState([]);
// 50 lines of data fetching logic
useEffect(() => {
async function fetchUsers() {
// complex fetching with filters, pagination, error handling...
}
fetchUsers();
}, [filters]);
// 30 lines of user selection logic
const handleUserSelect = (user) => {
// validation, state updates, side effects...
};
// 40 lines of edit form logic
const handleEditSubmit = (e) => {
// form validation, API calls, error handling...
};
// 100+ lines of inline JSX with nested conditionals
return (
<div className="dashboard">
{loading && <Spinner />}
{!loading && users.length === 0 && <EmptyState />}
{users.map(user => (
// Complex nested rendering...
))}
{selectedUser && (
// Modal with its own inline logic...
)}
</div>
);
}
The Fix: Extract Components and Hooks
Break your component into focused pieces:
// ✅ Good: Focused components with clear responsibilities
// Custom hook for data fetching
function useUsers(filters) {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
async function fetchUsers() {
setLoading(true);
try {
const response = await api.getUsers(filters);
setUsers(response.data);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
}
fetchUsers();
}, [filters]);
return { users, loading, error };
}
// Separate components for different concerns
function UserList({ users, onSelect }) {
return (
<ul className="user-list">
{users.map(user => (
<UserItem key={user.id} user={user} onClick={() => onSelect(user)} />
))}
</ul>
);
}
function UserEditModal({ user, onClose, onSave }) {
const [formData, setFormData] = useState(user);
// Form logic isolated here
return (
<Modal onClose={onClose}>
<UserForm data={formData} onChange={setFormData} onSubmit={onSave} />
</Modal>
);
}
// Main component is now orchestration only
function UserDashboard() {
const [filters, setFilters] = useState({});
const [selectedUser, setSelectedUser] = useState(null);
const { users, loading, error } = useUsers(filters);
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
if (users.length === 0) return <EmptyState />;
return (
<div className="dashboard">
<FilterBar filters={filters} onChange={setFilters} />
<UserList users={users} onSelect={setSelectedUser} />
{selectedUser && (
<UserEditModal
user={selectedUser}
onClose={() => setSelectedUser(null)}
onSave={handleSave}
/>
)}
</div>
);
}
Rule of thumb: If you can't describe what a component does in one sentence, it's doing too much.
Sign #2: You're Using useEffect for Everything
The Problem
useEffect has become the duct tape of React. Need to sync state? useEffect. Need to respond to a click? useEffect. Need to calculate something? useEffect.
This leads to:
- Race conditions: Effects firing in unexpected orders
- Memory leaks: Cleanup that never runs
- Infinite loops: State updates triggering effects that update state
// ❌ Bad: Effect soup
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [filteredResults, setFilteredResults] = useState([]);
const [sortBy, setSortBy] = useState('relevance');
// Effect for fetching
useEffect(() => {
fetchResults(query).then(setResults);
}, [query]);
// Effect for filtering - unnecessary!
useEffect(() => {
const filtered = results.filter(r => r.active);
setFilteredResults(filtered);
}, [results]);
// Effect for sorting - also unnecessary!
useEffect(() => {
const sorted = [...filteredResults].sort((a, b) =>
sortBy === 'relevance' ? b.score - a.score : a.name.localeCompare(b.name)
);
setFilteredResults(sorted);
}, [filteredResults, sortBy]);
return <ResultList results={filteredResults} />;
}
The Fix: Derive State, Don't Sync It
Most derived values don't need effects—they just need regular JavaScript:
// ✅ Good: Derive values during render
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [sortBy, setSortBy] = useState('relevance');
// Single effect for fetching - the only real side effect
useEffect(() => {
let cancelled = false;
fetchResults(query).then(data => {
if (!cancelled) setResults(data);
});
return () => { cancelled = true; };
}, [query]);
// Derive during render - no effects needed!
const filteredResults = results.filter(r => r.active);
const sortedResults = useMemo(() => {
return [...filteredResults].sort((a, b) =>
sortBy === 'relevance' ? b.score - a.score : a.name.localeCompare(b.name)
);
}, [filteredResults, sortBy]);
return (
<div>
<SortControl value={sortBy} onChange={setSortBy} />
<ResultList results={sortedResults} />
</div>
);
}
When useEffect IS appropriate:
- API calls and subscriptions
- Timer-based operations
- Integration with non-React code
When useEffect is NOT appropriate:
- Transforming data (use
useMemoor compute during render) - Responding to user events (use event handlers)
- Syncing state that could be derived
Sign #3: Props Are Being Passed Through Multiple Components
The Problem
The "prop drilling" antipattern: passing data through components that don't need it, just to get it to a deeply nested child.
// ❌ Bad: Prop drilling through multiple layers
function App() {
const [currentUser, setCurrentUser] = useState(null);
return <Layout currentUser={currentUser} />;
}
function Layout({ currentUser }) {
// Layout doesn't use currentUser, just passes it down
return (
<div>
<Header currentUser={currentUser} />
<Main currentUser={currentUser} />
</div>
);
}
function Header({ currentUser }) {
// Header doesn't use it either
return <Navigation currentUser={currentUser} />;
}
function Navigation({ currentUser }) {
// Finally, someone uses it!
return (
<nav>
{currentUser ? <UserMenu user={currentUser} /> : <LoginButton />}
</nav>
);
}
The Fix: Context API or Composition
Option 1: Context for global data
// ✅ Good: Context for cross-cutting concerns
const UserContext = createContext(null);
function App() {
const [currentUser, setCurrentUser] = useState(null);
return (
<UserContext.Provider value={currentUser}>
<Layout />
</UserContext.Provider>
);
}
function Navigation() {
const currentUser = useContext(UserContext);
return (
<nav>
{currentUser ? <UserMenu user={currentUser} /> : <LoginButton />}
</nav>
);
}
// Layout and Header don't need to know about currentUser at all
function Layout() {
return (
<div>
<Header />
<Main />
</div>
);
}
Option 2: Composition for structural flexibility
// ✅ Good: Pass components, not data
function App() {
const [currentUser, setCurrentUser] = useState(null);
return (
<Layout
header={<Header user={currentUser} />}
main={<Main />}
/>
);
}
function Layout({ header, main }) {
return (
<div>
{header}
{main}
</div>
);
}
Rule of thumb: If you're passing a prop through more than two components that don't use it, reach for Context or composition.
Sign #4: Your Component Has More Than 5 State Variables
The Problem
A component with too much state is like a room with too many moving parts—something's always going wrong, and it's hard to tell what caused it.
// ❌ Bad: State explosion
function CheckoutForm() {
const [step, setStep] = useState(1);
const [email, setEmail] = useState('');
const [shippingName, setShippingName] = useState('');
const [shippingAddress, setShippingAddress] = useState('');
const [shippingCity, setShippingCity] = useState('');
const [billingSameAsShipping, setBillingSameAsShipping] = useState(true);
const [billingName, setBillingName] = useState('');
const [billingAddress, setBillingAddress] = useState('');
const [cardNumber, setCardNumber] = useState('');
const [expiry, setExpiry] = useState('');
const [cvv, setCvv] = useState('');
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
// 13 state variables and counting...
}
This leads to:
- Complex state update logic
- Inconsistent states (what if shipping is set but billingSameAsShipping changes?)
- Impossible-to-track bugs
The Fix: Group Related State
// ✅ Good: Group related state into objects
function CheckoutForm() {
const [step, setStep] = useState(1);
const [formData, setFormData] = useState({
email: '',
shipping: {
name: '',
address: '',
city: '',
},
billingSameAsShipping: true,
billing: {
name: '',
address: '',
},
payment: {
cardNumber: '',
expiry: '',
cvv: '',
},
});
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
// Single update function
const updateField = (section, field, value) => {
setFormData(prev => ({
...prev,
[section]: {
...prev[section],
[field]: value,
},
}));
};
// Or use a form library like react-hook-form
}
Even better: Use useReducer for complex state transitions:
// ✅ Good: Reducer for complex state machines
const checkoutReducer = (state, action) => {
switch (action.type) {
case 'UPDATE_FIELD':
return {
...state,
[action.section]: {
...state[action.section],
[action.field]: action.value,
},
};
case 'SET_BILLING_SAME_AS_SHIPPING':
return {
...state,
billingSameAsShipping: action.value,
billing: action.value ? state.shipping : { name: '', address: '' },
};
case 'SUBMIT_START':
return { ...state, isSubmitting: true, errors: {} };
case 'SUBMIT_ERROR':
return { ...state, isSubmitting: false, errors: action.errors };
default:
return state;
}
};
function CheckoutForm() {
const [state, dispatch] = useReducer(checkoutReducer, initialState);
// Cleaner state management with clear transitions
}
Sign #5: You Can't Test a Feature in Isolation
The Problem
When your component imports global contexts, triggers side effects, and has 15 props with required relationships—testing becomes an exercise in mock architecture rather than behavior verification.
// ❌ Bad: Untestable component
function UserPreferences() {
const { user, updateUser } = useAuth(); // Global context
const { theme, setTheme } = useTheme(); // Another context
const { notify } = useNotifications(); // And another
const location = useLocation(); // Router dependency
useEffect(() => {
analytics.track('preferences_viewed', { userId: user.id });
}, []);
// How do you even test this?
}
The Fix: Dependency Injection and Pure Components
// ✅ Good: Testable component with explicit dependencies
function UserPreferences({
user,
onUpdateUser,
theme,
onThemeChange,
onNotify,
analytics,
}) {
useEffect(() => {
analytics.track('preferences_viewed', { userId: user.id });
}, [analytics, user.id]);
return (
<div className={theme}>
<ThemeSelector value={theme} onChange={onThemeChange} />
<UserForm user={user} onSubmit={onUpdateUser} />
</div>
);
}
// Wrapper handles context/wiring
function UserPreferencesContainer() {
const { user, updateUser } = useAuth();
const { theme, setTheme } = useTheme();
const { notify } = useNotifications();
return (
<UserPreferences
user={user}
onUpdateUser={updateUser}
theme={theme}
onThemeChange={setTheme}
onNotify={notify}
analytics={analytics}
/>
);
}
// Test is now straightforward
describe('UserPreferences', () => {
it('tracks analytics on mount', () => {
const mockAnalytics = { track: jest.fn() };
render(
<UserPreferences
user={{ id: '123' }}
onUpdateUser={jest.fn()}
theme="light"
onThemeChange={jest.fn()}
onNotify={jest.fn()}
analytics={mockAnalytics}
/>
);
expect(mockAnalytics.track).toHaveBeenCalledWith(
'preferences_viewed',
{ userId: '123' }
);
});
});
Real-World Scenarios: Before and After
Scenario 1: The E-commerce Product Page
Before: A 600-line ProductPage component handling reviews, recommendations, cart management, pricing calculations, inventory checks, and image galleries.
After:
ProductPage/
├── index.js (orchestration)
├── ProductGallery.js
├── ProductInfo.js
├── PriceBlock.js
├── AddToCart.js
├── Reviews/
│ ├── index.js
│ ├── ReviewList.js
│ └── ReviewForm.js
└── Recommendations.js
Each component is under 100 lines, testable in isolation, and reusable across the site.
Scenario 2: The Dashboard Widget
Before: A widget that displayed charts, tables, and filters in one file with 15 state variables.
After: Extracted into:
-
useDashboardDatahook (data fetching) -
DashboardFilterscomponent (filter controls) -
ChartWidgetcomponent (visualization) -
TableWidgetcomponent (data display) -
Dashboardcomponent (composition only)
Testing became trivial—each piece could be verified independently.
FAQ: Common Questions About Component Refactoring
Q: How do I know when to split a component?
A: Apply the "one responsibility" test. If your component is doing both data fetching AND rendering AND user interaction AND state management, split it. Each component should have one primary job.
Q: Won't extracting components make my codebase bigger?
A: More files, yes. But not "bigger" in the way that matters. Smaller, focused components are:
- Easier to understand
- Easier to test
- More reusable
- Faster to debug
File count is not complexity. Cognitive load is complexity.
Q: What about performance? More components means more overhead?
A: React is optimized for this. The overhead of additional component wrappers is negligible compared to the cost of:
- Re-rendering massive components unnecessarily
- Developer time spent understanding tangled code
- Bugs introduced because logic was hidden in a 400-line file
Q: How do I convince my team to refactor existing spaghetti components?
A: Start small. Pick one painful component (we all have that one file everyone avoids) and demonstrate:
- How long it takes to understand the current version
- How long a refactor takes
- How much faster adding a feature becomes afterward
Show, don't tell.
Q: Should I use a state management library instead?
A: Before reaching for Redux, Zustand, or similar, ask: is the problem actually complex state, or is it poorly organized state? Often, grouping related state and using Context appropriately is enough. Libraries add their own complexity—make sure the tradeoff is worth it.
Conclusion: Spaghetti is a Choice, Not a Destiny
Every spaghetti component started with good intentions. "I'll just add this one feature here." "This logic is related, why separate it?" "I'll clean it up later."
Later never comes—until you make it come.
The 5 signs we've covered:
- Lines over 300 → Extract components and hooks
- Effect overuse → Derive state during render
- Prop drilling → Use Context or composition
- State explosion → Group related state, use reducers
- Untestable code → Dependency injection and pure components
These aren't arbitrary rules. They're patterns that emerge from experience—hard-won lessons from developers who've felt the pain of unmaintainable code.
The good news? You don't need to refactor everything at once. Pick one sign. Fix one component. Feel the relief. Then do it again.
Your future self—the one opening that file at 11 PM on a Friday—will thank you.
What's the worst spaghetti component you've encountered? Share your horror stories and refactoring wins in the comments.
Top comments (0)