DEV Community

Ozan Ceylan
Ozan Ceylan

Posted on

React Code Smells: 5 Tiny Patterns That Blow Up Your App Later

It always starts the same way.

You’re building a component. It’s a small feature. Maybe a modal, a form, a list. You write it fast, ship it faster. Everything works. You move on.

A few weeks later, you’re back.

Nothing’s technically “broken,” but something feels… off.

The logic is tangled. The UI is buggy in weird ways.

You try to refactor it and realize: “What the hell was I thinking?”

Congratulations. You’ve just met a frontend smell.

Wait! What’s a “smell”?

A smell is not a bug. It’s worse.

It’s a tiny design flaw that:

  • feels harmless at first,

  • slowly spreads,

  • and eventually makes your codebase hard to maintain, scale, or even read.

Frontend smells aren’t always obvious. They hide inside reusable components, inline styles, overused state, and clever-looking hacks that come back to bite you.

Who is this for?

This post is for frontend developers who:

  • build fast,

  • care about clean code (but don’t always have the time),

  • and want to avoid long-term pain.

If you’ve ever said:

“I’ll clean this up later.”

or

“It’s just one component, no big deal.”

This post is especially for you.

TL;DR

Your frontend code may start clean, but small bad habits quickly snowball into big problems. This post covers 5 common frontend code smells — from inline functions and overused global state to magic strings — explaining why they cause trouble and how to replace them with cleaner, more maintainable patterns. If you’re a fast-moving junior dev or anyone who cares about code quality, this guide will help you avoid future headaches and write smarter code today.

Let’s talk about 5 innocent-looking patterns

Each of these smells is small.

They’re not dramatic. They won’t throw errors.

But if you don’t catch them early, they’ll rot your code from the inside out.

Let’s start with one of the most common culprits:

Smell #1 — Anonymous Functions Everywhere

“Inline everything. What could possibly go wrong?”

The Smell

Inline arrow functions inside JSX are incredibly common:

<button onClick={() => doSomething()}>Click me</button>
Enter fullscreen mode Exit fullscreen mode

Looks clean. One-liner. Easy to write.

But here’s the catch: every time your component re-renders, that function is re-created from scratch. And if you’re passing it to child components, they also re-render — even if nothing else changed.

Why It’s a Problem

  • Breaks memoization — Tools like React.memo, useMemo, and useCallback become ineffective because the reference keeps changing.

  • Causes unnecessary re-renders — Especially dangerous in large lists or deeply nested UIs.

  • Hides logic — JSX becomes cluttered with logic that should live elsewhere.

Bad Example — Inline Handler in a List

function UserList({ users }) {
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>
          {user.name}
          <button onClick={() => alert(user.name)}>Show Name</button>
        </li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

Everything works… until your list gets longer, or you wrap UserListItem in React.memo.

Now every list item re-renders unnecessarily — because that inline onClick is always a new function.

Better Example — Extract the Handler

function UserList({ users }) {
  const handleShowName = (name) => () => {
    alert(name);
  };
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>
          {user.name}
          <button onClick={handleShowName(user.name)}>Show Name</button>
        </li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

This small change:

  • Makes your handler function stable

  • Enables React.memo to do its job

  • Keeps JSX clean and focused on structure

Bonus: useCallback When Needed

If handleShowName itself is passed down as a prop or used across renders, wrap it with useCallback:

const handleShowName = useCallback((name) => () => {
  alert(name);
}, []);
Enter fullscreen mode Exit fullscreen mode

That ensures the function reference doesn’t change between renders, which is especially helpful in complex UIs or performance-critical components.

The Takeaway

Inline functions feel fast and simple — but they silently ruin performance and scalability.

Get into the habit of lifting logic out of JSX early. Your future self (and your teammates) will thank you.

Smell #2 — The Reusable-But-Not-Really Component

“It’s reusable… if you don’t mind 12 props and 8 conditional classNames.”

The Smell

You build a component with reuse in mind. Let’s say a Button.

It starts simple. Then someone asks for a new color. Then a size. Then loading state.

Before you know it:

<Button
  variant="primary"
  size="small"
  isLoading
  icon="download"
  fullWidth
  align="left"
  type="submit"
  shadow="none"
  trackingId="cta_01"
/>
Enter fullscreen mode Exit fullscreen mode

What you now have is a God Component in disguise.

Reusable? Technically.

Maintainable? Barely.

Debuggable? Good luck.

Why It’s a Problem

  • Too many props = complexity explosion

    You now need a spec sheet just to use the component.

  • Business logic starts leaking in

    Suddenly you’re handling “if icon exists and isLoading and type === ‘link’” inside a UI element.

  • Testing becomes a nightmare

    Every edge case needs its own test.

Bad Example — The “Universal” Button

function Button({ variant, size, isLoading, icon, children }) {
  const className = `
    btn 
    ${variant === 'primary' ? 'btn-primary' : ''}
    ${size === 'small' ? 'btn-sm' : ''}
  `;
  return (
    <button className={className}>
      {isLoading ? 'Loading...' : icon ? <Icon name={icon} /> : null}
      {children}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

On day 1, this looks smart.

On day 30, you’re writing conditionals that cancel each other out.

On day 90, no one wants to touch this button anymore.

Better — Split, Specialize, Compose

Instead of one mega-component, split into smaller, focused components.

// PrimaryButton.jsx
function PrimaryButton({ children, ...props }) {
  return (
    <button className="btn btn-primary" {...props}>
      {children}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Or use composition:

<Button>
  <Icon name="download" />
  Download
</Button>
Enter fullscreen mode Exit fullscreen mode

Or expose slots and variants intentionally with well-defined contracts.

The Takeaway

If a component has too many props, it’s trying to do too much.

A reusable component is only good if it’s predictable.

Don’t build for “maybe we’ll need this someday.”

Build for “we need this now — and it’s easy to extend if we must.”

Smell #3 — Magic Numbers & Strings

“Why is ‘admin’ hardcoded in 6 different places? Who knows.”

The Smell

You’re deep in a feature, and you need to check the user role:

if (user.role === 'admin') {
  // show secret stuff
}
Enter fullscreen mode Exit fullscreen mode

Looks harmless. Just a quick string.

But then you see this again…

and again…

and again…

if (user.role === 'admin') { ... }
if (permission === 'read-only') { ... }
if (status === 'active') { ... }
if (theme === 'dark') { ... }
Enter fullscreen mode Exit fullscreen mode

Now your codebase is sprinkled with magic values — strings and numbers with no context, no validation, and no shared meaning.

Why It’s a Problem

  • Hard to change — Want to rename 'admin' to 'administrator'? Better hope you didn’t miss any.

  • No autocomplete or validation — Typos like 'admn' won’t be caught until runtime.

  • Poor readability — New developers have no idea what valid values are without hunting for them.

Bad Example — Hardcoded Chaos

function getDashboardConfig(role) {
  if (role === 'admin') {
    return { canEdit: true, accessLevel: 3 };
  }
  if (role === 'guest') {
    return { canEdit: false, accessLevel: 0 };
  }
  return { canEdit: false, accessLevel: 1 };
}
Enter fullscreen mode Exit fullscreen mode

It works — but it’s fragile.

One missed string and behavior breaks silently.

Better — Use Constants or Enums

// roles.js
export const Roles = {
  ADMIN: 'admin',
  GUEST: 'guest',
  USER: 'user',
};
Enter fullscreen mode Exit fullscreen mode
import { Roles } from './roles';

function getDashboardConfig(role) {
  if (role === Roles.ADMIN) {
    return { canEdit: true, accessLevel: 3 };
  }
  if (role === Roles.GUEST) {
    return { canEdit: false, accessLevel: 0 };
  }
  return { canEdit: false, accessLevel: 1 };
}
Enter fullscreen mode Exit fullscreen mode

✅ Now your IDE helps with:

  • Autocomplete

  • Refactoring

  • Type checking (if using TypeScript)

Bonus Tip: Enum + TypeScript = ❤️

enum Roles {
  Admin = 'admin',
  Guest = 'guest',
  User = 'user',
}

function getDashboardConfig(role: Roles) {
  // role must be one of the enum values
}
Enter fullscreen mode Exit fullscreen mode

This eliminates guesswork.

No more "admin" strings flying around your app like uninvited guests.

The Takeaway

If a value shows up more than once, give it a name.

Named constants beat mystery strings every time.

Magic numbers and strings might save you a few keystrokes now, but they’ll cost you hours during debugging and refactoring later.

Smell #4 — Overused Global State

“Why is everything in context? This app has 4 pages.”

The Smell

You need to share a bit of state across components.

Naturally, you reach for Context.

<AppContext.Provider value={{ user, setUser }}>
  <App />
</AppContext.Provider>
Enter fullscreen mode Exit fullscreen mode

No problem so far.

But next thing you know:

  • The modal state is global

  • The current tab index is global

  • That one-time toast message is global

Now your app has global everything, and changing a button label causes half your app to re-render.

Why It’s a Problem

  • Unnecessary re-renders — One state update can ripple through the entire app tree

  • Tight coupling — Components far apart become silently dependent on shared state

  • Hard to debug — Where is this value updated? Why did it change? Who knows!

Bad Example — Global State for Local Concerns

// In Context
const AppContext = createContext();

const AppProvider = ({ children }) => {
  const [isModalOpen, setModalOpen] = useState(false);
  return (
    <AppContext.Provider value={{ isModalOpen, setModalOpen }}>
      {children}
    </AppContext.Provider>
  );
};
Enter fullscreen mode Exit fullscreen mode

Then every component starts doing:

const { isModalOpen, setModalOpen } = useContext(AppContext);
Enter fullscreen mode Exit fullscreen mode

Now the whole app re-renders every time the modal opens — even components that don’t care about modals at all.

Better — Localize What Can Be Local

If the state is only relevant to a specific area — keep it there.

function Page() {
  const [isModalOpen, setModalOpen] = useState(false);

  return (
    <>
      <button onClick={() => setModalOpen(true)}>Open Modal</button>
      {isModalOpen && <Modal onClose={() => setModalOpen(false)} />}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Even better? Use reducer hooks or state machines when things grow.

Bonus Tip: Use Context Like a Scoped API, Not a Dumping Ground

If you must share state across unrelated components, create multiple small contexts, not one mega-provider.

<UserProvider>
  <ThemeProvider>
    <ModalProvider>
      <App />
    </ModalProvider>
  </ThemeProvider>
</UserProvider>
Enter fullscreen mode Exit fullscreen mode

Each context only does one thing, and you avoid unnecessary coupling.

The Takeaway

Not all state is app-wide state.

Just because Context exists doesn’t mean you should use it for everything.

Use local state by default. Reach for context only when:

  • Props drilling is deep and unmanageable

  • The state is truly global (e.g., auth, theme)

  • Performance is under control (or you know how to memoize properly)

Smell #5 — Clever Code That Lies To You

“Nice one-liner bro… but what does it do again?”

The Smell

At some point, we all fall into this trap:

“Look at this — destructuring, ternary, optional chaining, reduce, all in one neat line. So clean!”

And sure, it feels clean:

const result = arr?.reduce((a, { x }) => (x > 0 ? [...a, x * 2] : a), []) ?? [];
Enter fullscreen mode Exit fullscreen mode

But come back to this in two weeks and you’ll be like:

🧠❌ “Wait, what does this actually do again?”

🫠📉 “Okay… so maybe it’s filtering… and multiplying… or something?”

Why It’s a Problem

  • It’s hard to read — Clever ≠ clear.

  • It’s hard to debug — You can’t easily log or inspect the middle of a one-liner.

  • It’s hard to change — Refactoring gets risky when logic is condensed into a dense syntax salad.

  • It confuses others (and future you) — Your teammate (or yourself, 3 months later) won’t thank you.

Bad Example — Ternaryception

const label = status === 'active'
  ? '🟢 Active'
  : status === 'pending'
  ? '🟡 Pending'
  : '🔴 Inactive';
Enter fullscreen mode Exit fullscreen mode

Yes, it technically works.

But it reads like you’re trying to write JavaScript poetry.

Better — Be Explicit, Be Kind

function getStatusLabel(status) {
  if (status === 'active') return '🟢 Active';
  if (status === 'pending') return '🟡 Pending';
  return '🔴 Inactive';
}
Enter fullscreen mode Exit fullscreen mode

Now it’s:

  • Easy to follow

  • Easy to extend

  • Easy to test

No brain gymnastics required.

Bonus Tip: Split, Name, and Simplify

If your logic is slightly complex — split it into steps and name things:

const positiveNumbers = numbers.filter(n => n > 0);
const doubled = positiveNumbers.map(n => n * 2);
Enter fullscreen mode Exit fullscreen mode

Or, annotate your chains:

// Filter out invalid users and sort by last login
const activeUsers = users
  .filter(u => u.isActive)
  .sort((a, b) => b.lastLogin - a.lastLogin);
Enter fullscreen mode Exit fullscreen mode

It’s still clean. But now it communicates intention.

The Takeaway

If a line of code needs a Slack thread to explain it…

You probably shouldn’t write it that way.

Readable code is kind code.

Cleverness might impress you — but clarity helps everyone.

Conclusion: Small Smells, Big Headaches

Code doesn’t rot overnight.

It happens slowly — with innocent shortcuts, rushed decisions, and “I’ll clean this up later” promises.

Each of the five patterns we covered might seem harmless at first. But left unchecked, they snowball into bugs, confusion, and refactoring pain. The good news? Most of these are easy to avoid with a little awareness.

So next time you’re in the zone, ask yourself:

“Is this clever… or is this clear?”

“Is this temporary… or is this maintainable?”

“Am I writing for the computer… or for the next developer?”

If the answer makes you squint — it’s probably a smell.

Smell Checklist — Watch Out for These

1. 🚫 Anonymous Functions Everywhere

Don’t: Write inline functions like onClick={() => doSomething()} in every JSX element

Do: Extract named functions and reuse them for cleaner, more testable code

2. 🚫 Copy-Paste Components

Don’t: Duplicate components with slightly different props or layouts

Do: Create base/shared components with flexible props or composition

3. 🚫 Magic Numbers & Strings

Don’t: Hardcode values like 'admin', 'blue', 42 throughout your app

Do: Define constants, enums, or config objects for consistency and readability

4. 🚫 Overused Global State

Don’t: Store everything in Context or Redux just because you might reuse it

Do: Keep state local unless it’s truly shared across distant parts of the app

5. 🚫 Clever Code That Lies to You

Don’t: Write dense one-liners just to be fancy

Do: Split logic into readable steps, name things clearly, and leave breadcrumbs for future-you

👣 Final Thought

Frontend development is fast-paced. But “fast” doesn’t have to mean “fragile”.

When you catch these smells early, you:

  • Help your teammates move faster

  • Make debugging 10× easier

  • Save your future self a lot of headaches

Good code isn’t just about making it work — it’s about making it last.

Top comments (0)