Last week I opened a React component… and immediately closed it.
Not because it was complex.
But because it felt hostile.
You know that feeling: the file keeps scrolling, props are flying around, and every small change feels like it might break something completely unrelated.
That’s not complexity.
That’s entropy.
And if you’ve been building UIs for a while, you’ve probably seen it happen slowly, almost invisibly.
Let’s talk about the signals before things get out of hand.
TL;DR
- If your React components start feeling hard to read, fragile, or unpredictable, your UI is likely becoming unmaintainable.
- The most common signals are oversized components, props drilling, unclear responsibilities, duplication, and messy conditionals.
- You don’t need a rewrite, just small, consistent refactoring habits.
Table of Contents
- The Problem with “It Still Works”
- 1. The God Component (Too Big to Understand)
- 2. Props Drilling Everywhere
- 3. Confusing Responsibilities
- 4. UI Logic Duplication
- 5. Conditional Rendering Hell
- Bonus Signs You Shouldn’t Ignore
- Mini Refactoring: From Spaghetti to Clean
- Practical Refactoring Rituals
- Final Thoughts
The Problem with “It Still Works”
Most messy UIs don’t start messy.
They start small, clear, even elegant.
Then a feature gets added.
Then another one.
Then a quick fix before a deadline.
Nothing dramatic, just small decisions that make sense in the moment.
Until one day you open a file and realize you don’t really understand it anymore.
That’s the moment where “it still works” becomes dangerous.
Because now every change carries risk.
And the cost of touching the code becomes higher than leaving it alone.
1. The God Component (Too Big to Understand)
There’s usually one file in every project that everyone avoids.
It’s big. Really big.
It handles data, UI, state, events, and probably a few side effects too.
Something like this:
export default function Dashboard({ user, posts }: Props) {
// fetching logic
// filtering logic
// UI rendering
// event handlers
// conditionals everywhere
return (
<div>
<h1>{user.name}</h1>
{/* hundreds of lines */}
</div>
)
}
The issue isn’t just the size.
It’s the lack of boundaries.
When everything lives in the same place, nothing is clearly defined.
You don’t know what’s safe to change and what might break something else.
A useful rule I rely on is simple:
If you can’t describe a component in one sentence, it’s doing too much.
Breaking it down doesn’t mean over-engineering.
It means restoring clarity.
export function Dashboard() {
return (
<div>
<UserHeader/>
<PostList/>
</div>
)
}
Now each piece has a purpose.
And more importantly, a limit.
2. Props Drilling Everywhere
Props drilling usually starts innocently.
You pass user from a parent to a child, then to another child, then another.
Until you end up here:
<Dashboard user={user}>
<Sidebar user={user}>
<UserAvatar user={user}/>
</Sidebar>
</Dashboard>
At this point, some components are just acting as pipelines.
They don’t use the data, they just pass it along.
That creates a subtle kind of friction.
Every layer becomes aware of something it doesn’t actually care about.
And any change to that data ripples through the entire tree.
The obvious solution is often:
“Let’s use Context.”
And that’s valid… but only to a point.
Context is not a silver bullet.
Overusing it can make your app harder to reason about:
- data becomes less explicit
- re-renders become harder to control
- tracing where values come from gets tricky
A better approach is to use it intentionally, when it actually solves a real problem:
- truly global data (auth, theme, user)
- deeply shared state
- repeated props drilling across many levels
Combined with a custom hook, it stays clean:
function useUser() {
return useContext(UserContext)
}
function UserAvatar() {
const user = useUser()
return <img src={user.avatar} />
}
Now the dependency lives exactly where it’s needed.
3. Confusing Responsibilities
Sometimes the problem isn’t visible at a glance.
It’s not about how much code there is, but about what kind of work is happening in the same place.
A single component handling:
- data fetching
- state management
- transformation
- rendering
…is doing too many different jobs.
The result is cognitive overload.
You open the file and constantly switch context:
“Am I reading UI? Or logic? Or data?”
That friction slows everything down.
Separating responsibilities makes the code easier to navigate and easier to trust.
A simple split already helps a lot:
function usePosts() {
// fetch + transform
}
function PostList() {
const posts = usePosts()
return (...)
}
Now the component reads like a story again, not a puzzle.
4. UI Logic Duplication
Duplication doesn’t always look like copy-paste.
Sometimes it shows up as repeated patterns:
if (isLoading) return <Spinner />
if (error) return <Error />
You write it once.
Then again.
Then again.
At some point, changing that behavior means updating multiple places.
That’s when it becomes expensive.
Extracting shared logic is less about abstraction and more about centralizing decisions.
function DataState({ loading, error, children }: Props) {
if (loading) return <Spinner />
if (error) return <Error />
return children
}
Now the behavior lives in one place.
And your UI becomes more consistent by default.
5. Conditional Rendering Hell
Conditional rendering is fine.
Until it isn’t.
We’ve all written something like this:
return isAdmin
? isActive
? <AdminPanel />
: <InactiveAdmin />
: <UserPanel />
It works.
But it’s not easy to read.
The problem isn’t React.
It’s that too much logic is living inside JSX.
A first step is to move that logic out:
function renderContent() {
if (isAdmin && isActive) return <AdminPanel />
if (isAdmin) return <InactiveAdmin />
return <UserPanel />
}
return renderContent()
Already much better.
Using Guard Clauses
Guard clauses can make this even clearer, when used carefully.
if (!isAdmin) {
return <UserPanel />
}
if (!isActive) {
return <InactiveAdmin />
}
return <AdminPanel />
This removes nesting and keeps the flow linear.
The key is balance.
- use guard clauses when they simplify the flow
- avoid stacking too many negative conditions
- keep the logic readable from top to bottom
The goal isn’t fewer lines.
It’s clarity.
Bonus Signs You Shouldn’t Ignore
Sometimes the signals are less technical and more instinctive.
- You hesitate before opening a file
- You’re afraid to change something
- Fixing a bug feels risky
- You’ve said “let’s not touch this” more than once
These are all valid warnings.
And they usually show up before bigger problems.
Mini Refactoring: From Spaghetti to Clean
A while ago I ran into a component like this.
It wasn’t broken.
It wasn’t even that complex.
But it had that feeling:
“There’s just a bit too much going on here.”
❌ Before
export default function Dashboard({ user, posts }: Props) {
const [filter, setFilter] = useState("all")
const filteredPosts = posts.filter(p =>
filter === "all" ? true : p.type === filter
)
return (
<div>
<h1>{user.name}</h1>
<select onChange={e => setFilter(e.target.value)}>
<option value="all">All</option>
<option value="tech">Tech</option>
</select>
{filteredPosts.length === 0 ? (
<p>No posts</p>
) : (
filteredPosts.map(p => <div key={p.id}>{p.title}</div>)
)}
</div>
)
}
Nothing wrong, technically.
But everything is mixed together:
- state
- logic
- UI
So I started small.
Step 1: isolate the logic
function useFilteredPosts(posts: Post[]) {
const [filter, setFilter] = useState("all")
const filtered = posts.filter(post =>
filter === "all" ? true : post.type === filter
)
return { filter, setFilter, filtered }
}
Now the component doesn’t care how filtering works.
Step 2: extract reusable UI
function PostFilter({ value, onChange }: Props) {
return (
<select value={value} onChange={e => onChange(e.target.value)}>
<option value="all">All</option>
<option value="tech">Tech</option>
</select>
)
}
Step 3: compose everything
export default function Dashboard({ user, posts }: Props) {
const { filter, setFilter, filtered } = useFilteredPosts(posts)
return (
<div>
<h1>{user.name}</h1>
<PostFilter value={filter} onChange={setFilter} />
{filtered.length === 0 ? (
<p>No posts</p>
) : (
filtered.map(p => <div key={p.id}>{p.title}</div>)
)}
</div>
)
}
Same feature.
Completely different feeling.
That’s the real goal of refactoring.
Practical Refactoring Rituals
You don’t need a full rewrite to fix spaghetti code.
You need habits.
Refactoring works best when it’s part of your workflow, not something you postpone.
A few simple ones:
- extract things early, when they start feeling “off”
- keep components focused on one responsibility
- move growing logic into hooks
- spend time naming things clearly
None of these are revolutionary.
But together, they make your codebase easier to work with.
Final Thoughts
Spaghetti UI isn’t a failure.
It’s what happens when real features meet real constraints.
But if you ignore it, it slowly turns your codebase into something you avoid instead of something you trust.
The good news is, you don’t need perfection.
Just small improvements, applied consistently.
If this article resonated with you:
- Leave a ❤️ reaction
- Drop a 🦄 unicorn
- Share the worst component you’ve ever written (or seen) in the comments
And if you enjoy this kind of content, follow me here on DEV for more.
Top comments (3)
Great topic, this hits very close to home.
The part about components slowly turning into “do-everything” monsters really resonated. I’ve seen this happen in almost every React codebase I’ve worked on: what starts as a clean, reusable abstraction ends up buried under edge cases, conditionals, and “just one more prop.”
One thing I especially liked is how you framed the warning signs, not just the solutions. A lot of articles jump straight to patterns, but recognizing the decay early is the real superpower—because once a component crosses that line, refactoring becomes painful and often avoided.
Also +1 on the implicit message: reusability isn’t the goal, clarity is. I’ve seen teams over-optimize for DRY and end up with components so abstract they’re harder to reason about than duplicated code.
Let me make you a question: Where do you personally draw the line between “this should be one flexible component” vs “split it into 2–3 simpler ones”?
Great question Luca, this is exactly where things usually go wrong.
For me, the line isn’t about size, it’s about cognitive load and intent.
If I need to mentally simulate multiple scenarios to understand what a component does, it’s already too flexible.
Here’s how I usually decide:
A simple rule I like:
Another practical test:
In those cases, I prefer 2–3 explicit components with shared smaller primitives underneath, instead of one “smart” component trying to handle everything.
You lose a bit of DRY, but you gain readability, safer changes, and faster onboarding, which usually pays off quickly.
I’m really curious!
Have you ever looked at one of your React components and thought: “how did this turn into such a mess? 😅”