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>
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
, anduseCallback
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>
);
}
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>
);
}
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);
}, []);
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"
/>
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>
);
}
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>
);
}
Or use composition:
<Button>
<Icon name="download" />
Download
</Button>
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
}
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') { ... }
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 };
}
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',
};
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 };
}
✅ 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
}
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>
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>
);
};
Then every component starts doing:
const { isModalOpen, setModalOpen } = useContext(AppContext);
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)} />}
</>
);
}
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>
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), []) ?? [];
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';
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';
}
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);
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);
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)