DEV Community

Cover image for React Components vs Spaghetti: 5 Signs Your UI Is Becoming Unmaintainable
Gavin Cettolo
Gavin Cettolo

Posted on

React Components vs Spaghetti: 5 Signs Your UI Is Becoming Unmaintainable

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”

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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} />
}
Enter fullscreen mode Exit fullscreen mode

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 (...)
}
Enter fullscreen mode Exit fullscreen mode

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 />
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 />
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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 />
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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 (17)

Collapse
 
trinhcuong-ast profile image
Kai Alder

The guard clause approach for conditional rendering is something I wish I'd adopted earlier. I spent way too long nesting ternaries in JSX before realizing how much cleaner early returns make things.

One thing I'd add — the "God Component" problem gets even worse when you're working with useEffect. I've inherited codebases where a single component had 4-5 effects all depending on different slices of state, and figuring out which effect fires when was basically impossible without console.log debugging.

What helped me was treating effects the same way you'd treat the component itself: if an effect is doing more than one thing, extract it into a custom hook with a clear name. useAutoSave() tells you way more than a 30-line useEffect block.

Curious — do you have a threshold for component line count? I've been loosely using ~150 lines as my "time to split" signal but wondering if others have different numbers.

Collapse
 
gavincettolo profile image
Gavin Cettolo

Thanks so much for this @trinhcuong-ast, really thoughtful addition

That’s such a good callout on useEffect. Honestly, that’s where things tend to go off the rails fastest.

I really like your framing of treating effects the same way as components. If it’s doing more than one job, it probably is more than one concern. Extracting to something like useAutoSave() or useFetchUser() not only improves readability, it also makes the side effects testable and reusable, which is a huge win.

On the “which effect runs when” problem: I’ve seen that exact situation too, and it usually comes down to two issues:

  • Effects depending on too many pieces of state
  • Effects that implicitly depend on each other (timing coupling)

Splitting them into focused hooks tends to naturally fix both, because each hook ends up with a much tighter dependency surface.

Collapse
 
gavincettolo profile image
Gavin Cettolo

On the line count question:
I think your ~150 lines is a very reasonable heuristic, but I’d treat it more as a smell indicator than a rule.

Personally, I look for a mix of signals:

  1. Cognitive load > line count: If I can’t understand the component without scrolling up and down multiple times, it’s already too big, even if it’s only 80-100 lines.

  2. Multiple responsibilities: If the component is doing 2-3 of these, it’s a split candidate:

    • Data fetching
    • State orchestration
    • Rendering complex UI
    • Handling side effects
    • Business logic
  3. “Scroll test”: If I need to scroll to see these things…it’s usually time to extract something:

    • the full JSX and
    • the state/effects that drive it
  4. Reusable logic hiding inside: The moment you think: “I might need this logic elsewhere”, that’s your cue to pull out a hook or helper, not later.

A pattern that’s helped me: Instead of thinking “max lines”, I try to aim for:

  • Component = composition + layout
  • Hooks = behavior + side effects
  • Utils = pure logic

So ideally a component reads almost like:

function ProfilePage() {
  const { user, isLoading } = useUser();
  const { save } = useAutoSave(user);

  if (isLoading) return <Spinner />;

  return <ProfileView user={user} onSave={save} />;
}
Enter fullscreen mode Exit fullscreen mode

If it starts drifting away from that shape, that’s usually my signal to refactor.

TL;DR
Your 150-line rule is solid, but the real threshold is:

“How hard is this to reason about in one pass?”

Once that breaks, it’s already time to split, regardless of the number.

Curious back: do you tend to extract UI subcomponents first or hooks first when things start getting messy?

Collapse
 
lucaferri profile image
Luca Ferri

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”?

Collapse
 
gavincettolo profile image
Gavin Cettolo

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:

  • Keep it as one component when:
    • It represents a single, clear responsibility
    • Variations are minor (styling, small behavior tweaks)
    • The API feels predictable (you don’t need a manual to use it)
  • Split it when:
    • You start adding boolean flags like isX, hasY, variantZ that change behavior significantly
    • There are multiple rendering branches that feel like different components glued together
    • You need comments to explain when to use which props
    • You’re afraid to change it because it might break 5 unrelated use cases

A simple rule I like:

  • If you’re adding a prop that fundamentally changes what the component is, not just how it looks, split it.

Another practical test:

  • If you can’t give the component a short, precise name, it’s probably doing too much.

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.

Collapse
 
lucaferri profile image
Luca Ferri • Edited

Thanks Gavin for answering my question

Collapse
 
elenchen profile image
Elen Chen

Great read, Gavin! 🚀

As someone who spends most of my time in the world of distributed systems and Rust, it’s fascinating how the same "spaghetti" patterns show up in UI code.

The point about "Conditional Rendering Hell" really resonated. In backend architecture, we try to avoid "God Objects" that do too much, and it’s clear that React components suffer the same fate when we start overloading them with boolean flags like isX, hasY, and variantZ.

I’ve found that once I start needing a manual (or a long scroll) to understand a component's rendering branches, I've already lost the battle against cognitive load. Your tip on the DataState wrapper is a brilliant way to restore that single entry point for behavior.

Quick question for you—when you start to feel that "entropy" creeping in, do you prefer to extract the business logic into custom hooks first, or do you focus on breaking down the UI subcomponents to simplify the render tree?

Collapse
 
gavincettolo profile image
Gavin Cettolo

Thank you @elenchen!
Really appreciate the parallel with backend “God Objects”, that’s exactly the same smell.

For your question, I usually let the pain guide the first move.

If I’m struggling to understand what’s happening because of too many conditions, I extract a custom hook first. That helps collapse all those booleans into a single, explicit state and makes the component readable again.

If instead the issue is what I’m looking at because the JSX is huge or messy, I split subcomponents first to make the structure clearer.

In practice, both usually happen, but I tend to start with hooks because once the logic is clean, the UI decomposition becomes much more obvious.

So short answer: logic confusion → hooks first, visual complexity → components first

Collapse
 
elenchen profile image
Elen Chen

Thank you @gavincettolo , I really appreciate your response and fully agree with your point.

Collapse
 
harsh2644 profile image
Harsh

The bonus signs" section hit differently especially you've said let's not touch this more than once. That's honestly the most honest metric for unmaintainable code I've seen. No linter catches it, no PR review flags it, but every team feels it.

One more signal I'd add: when a new teammate asks "where does X get called from?" and nobody has a quick answer. At that point the component isn't just unmaintainable, it's invisible. The DataState wrapper pattern you showed is a great fix for exactly that it makes behavior findable.

Collapse
 
gavincettolo profile image
Gavin Cettolo

Thank you @harsh2644 , that’s such a good addition.
“Invisible components” is exactly the right way to describe that feeling.

I’ve seen that moment a lot too: someone asks “where does this come from?” and the answer is either silence or a 5-minute archaeology session through the codebase. At that point, the problem isn’t just complexity, it’s lost navigability. And once you lose that, everything slows down.

What’s interesting is that this aligns with a broader pattern in UI decay: complexity doesn’t usually explode, it becomes implicit. Logic gets spread across hooks, effects, props, and conditionals until the behavior is no longer traceable in one place . Your “invisible” signal is basically the human version of detecting that.

I also really like how you connected it to the DataState wrapper. That pattern works not just because it cleans up rendering, but because it restores a single, obvious entry point for behavior. It answers the question “where does this happen?” without needing to think.

If I had to extend your idea: unmaintainable code is often not the most complex, it’s the code where you can’t build a mental map anymore.

And your signal catches that earlier than most “best practices” ever will.

Collapse
 
marina_eremina profile image
Marina Eremina

Really liked the refactoring examples! I was just wondering, what if user or posts are underfined? Although, if Dashboard is only rendered after checking user, it should be fine 🙂

Collapse
 
gavincettolo profile image
Gavin Cettolo • Edited

Great question @marina_eremina and yes, that’s exactly the kind of edge case that can easily turn into spaghetti if not handled intentionally 🙂

In this specific example, you're right: if Dashboard is rendered only after validating user, then we're safe by design.

That said, in real-world apps I try not to rely only on parent guarantees. Data can be async, partial, or temporarily undefined (especially with API calls), so I usually add a small guard at the component level as well.

Something like:

if (!user) return null;
Enter fullscreen mode Exit fullscreen mode

or even better, explicitly handling states:

if (!user) return <Loading />;
// or <EmptyState /> / <ErrorState />
Enter fullscreen mode Exit fullscreen mode

React actually treats null as “render nothing”, which is a common and perfectly valid pattern for conditional rendering ()

So the rule of thumb I follow is:

  • Parent decides when to render
  • Component defends itself from incomplete data

That balance keeps things both safe and maintainable as the UI grows

Collapse
 
marina_eremina profile image
Marina Eremina

I have to say this is one issue that I struggled with the most when working with React components: deciding at what stage the check should happen to determine whether the data is ready or not.

It’s great that you mentioned the rules of thumb, they basically reflect the conclusion I came to, and they can save someone a ton of time spent on trial and error while getting a sense of how to approach it.

Collapse
 
gavincettolo profile image
Gavin Cettolo

I’m really curious!
Have you ever looked at one of your React components and thought: “how did this turn into such a mess? 😅”

Collapse
 
vuleolabs profile image
vuleolabs

Nice article!
I've been building a SaaS landing page recently using Next.js and Tailwind.
It's interesting how modular components can make landing pages much easier to maintain.

Collapse
 
gavincettolo profile image
Gavin Cettolo

Totally agree, that’s exactly where components really shine.

With landing pages especially, it’s easy to end up with a big “one-file hero + sections mess”, but breaking things into smaller, purpose-driven components (hero, features, testimonials, CTA, etc.) makes everything way easier to reason about and evolve.

I’ve found the biggest win isn’t just reuse, but isolation: each section owns its logic and styling, so changes don’t ripple unexpectedly across the page. That’s what really keeps things maintainable as the page grows

And with something like Next.js + Tailwind, that combo almost nudges you toward good composition patterns by default.

Out of curiosity: did you structure your landing as reusable sections from the start, or refactor into components later?