29 React Codebase Red Flags Every Developer Should Know

After years of working across React codebases of varying quality, certain patterns have become immediate signals that a project is going to be frustrating to maintain. Some are obvious, some are subtle — but all of them compound over time.
Here are 29 things worth watching for.
1. Pulling in a library for something vanilla JS handles fine
It's tempting to install a utility package the moment you hit a small problem. But every dependency is a liability — it has to be updated, it adds to your bundle, and it can silently break after a React or TypeScript upgrade. If the native language can handle it, prefer that. == null beats importing isNil. Intl.DateTimeFormat beats shipping a date library for a single use case.
Rule of thumb: Always ask if the platform already does this before reaching for npm.
2. Choosing a heavy package when a lightweight one would do the same job
Package weight matters more than most developers realize until they've seen a performance audit. The difference between moment.js (75KB) and day.js (3KB) is significant — especially for users on slower mobile connections. The native fetch API handles most HTTP needs that axios is commonly installed for.
Before adding any new dependency, run it through bundlephobia.com. If a slimmer alternative exists with comparable functionality, that's almost always the right call.
3. No shared linter or formatter in the repo
A codebase without automated formatting rules turns every pull request into a style argument. Tabs vs. spaces, quote style, trailing commas — none of these debates should exist in 2026. Configure ESLint and Prettier at the repo level, commit the config, and enforce it in CI. One setup decision eliminates an entire category of noise from code reviews.
4. No agreed-upon folder structure
When each developer follows their own mental model for where files go, the project quickly becomes disorienting. Finding where to put a new file — or where to look for an existing one — turns into guesswork.
// 🚩 No clear system
src/
components/Button.tsx
pages/home/helpers.ts
utils/userStuff.ts
lib/api.ts
shared/Button.tsx // duplicate?
Pick a convention — feature-based is generally the most scalable — document it in the README, and hold each other to it in review.
5. A utils.ts file that does everything
This file starts innocently: three helper functions, then five, then twenty. Eventually it becomes a graveyard of unrelated logic with no clear owner. Nobody refactors it because nobody fully understands it.
// 🚩 Unrelated functions sharing a file
export function formatDate(d: Date) { /* ... */ }
export function parseQueryString(s: string) { /* ... */ }
export function calculateTax(amount: number) { /* ... */ }
export function debounce(fn: Function, ms: number) { /* ... */ }
export function hexToRgb(hex: string) { /* ... */ }
// ...35 more
Split by domain: date-utils.ts, string-utils.ts, math-utils.ts. When a file requires a table of contents to navigate, it needs to be broken up.
6. Files for the same feature spread across the project
Splitting a component's file, styles, tests, and helpers into four separate top-level directories creates constant context-switching. Every time you work on a feature, you're bouncing around the tree.
// 🚩 Feature spread across directories
src/
components/UserProfile.tsx
styles/UserProfile.css
__tests__/UserProfile.test.tsx
utils/userProfileHelpers.ts
// ✅ Everything together
src/
features/user-profile/
UserProfile.tsx
UserProfile.css
UserProfile.test.tsx
helpers.ts
Keeping related files together also makes cleanup trivial — deleting a feature means deleting one folder, not tracking down orphaned files across four directories.
7. Wildcard barrel re-exports
Barrel files feel like a convenience at first, but export * from creates invisible tight coupling across every file in the folder. The bundler has to process all of them together, which slows down hot reload and can cause confusing compilation errors from modules you're not even importing directly.
// 🚩 Wildcard re-exports
export * from './Button'
export * from './Modal'
export * from './Table'
// ...50 more
If barrel files are necessary, be intentional — name each export explicitly rather than dumping everything out at once.
8. Components that do too many things
A component handling data fetching, state management, form validation, and rendering all at once is a maintenance problem waiting to explode. Any change to one concern risks breaking another because everything shares the same scope. If you have to scroll for a while to reach the return statement, the component has grown too large. Decompose it so each unit has a single, clear responsibility.
9. Passing whole objects when only specific fields are needed
// 🚩 Entire object passed down
<UserAvatar user={user} />
// ✅ Only what's needed
<UserAvatar avatarUrl={user.avatarUrl} name={user.name} />
When a child receives a full object, it re-renders any time that object changes — even for fields it never touches. It also couples the component to a specific data shape, making reuse harder. Pass only the props a component actually needs.
10. Syncing derived values into state
// 🚩 Derived value stored in state
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [fullName, setFullName] = useState('')
useEffect(() => {
setFullName(`${firstName} ${lastName}`)
}, [firstName, lastName])
// ✅ Compute it directly
const fullName = `${firstName} ${lastName}`
Any value that can be derived from existing state or props isn't state — it's a calculation. Storing it in state introduces a window where the value is out of sync, because the syncing effect runs after the render. Compute it inline. Reach for useMemo only if the computation is genuinely expensive.
11. Reaching for useState when a ref is the right tool
Not all mutable values need to drive re-renders. Interval IDs, scroll positions, previous render values, animation flags — these are implementation details, not UI state. Storing them in useState causes unnecessary re-renders on every update.
// 🚩 Triggers a re-render on every change
const [timerId, setTimerId] = useState(null)
// ✅ Mutable without triggering a re-render
const timerIdRef = useRef(null)
A useful heuristic: if changing the value should not update what's on screen, reach for useRef.
12. Putting local UI state in the global store
Global state is for data that multiple disconnected parts of the application need to share. Whether a dropdown is open, or a tooltip is visible, rarely qualifies. Storing that in Redux or Context adds boilerplate and indirection for zero benefit.
// 🚩 Global state for something only one component uses
dispatch(setTooltipVisible(true))
// ✅ Keep it local
const [isVisible, setIsVisible] = useState(false)
If nothing outside the component needs to know about it, it belongs inside the component.
13. One monolithic Context provider for everything
// 🚩 Single context for unrelated concerns
const AppContext = createContext({
user: null,
theme: 'light',
locale: 'en',
sidebarOpen: false,
notificationCount: 0,
})
Updating any one of these values causes every consumer to re-render — including components that only care about the user, or only care about the theme. Separate contexts by responsibility: UserContext, ThemeContext, UIContext. They're independent concerns and should be managed independently.
14. Weak TypeScript usage
Using any throughout the codebase trades away everything TypeScript is supposed to provide. But the problem goes deeper — skipping discriminated unions allows logically impossible states to exist in your type system, and unexhaustive switch statements become silent bug sources as the codebase grows.
// 🚩 Multiple booleans allow contradictory states
type FormState = {
isSubmitting: boolean
isSuccess: boolean
errorMessage: string | null
}
// isSubmitting: true AND isSuccess: true — both valid per the type
// ✅ Discriminated union — only valid states are representable
type FormState =
| { status: 'idle' }
| { status: 'submitting' }
| { status: 'success' }
| { status: 'error'; message: string }
Strong types eliminate entire categories of runtime bugs. The upfront investment is worth it.
15. Suppressing the exhaustive-deps linter warning
// 🚩 Warning silenced, underlying problem ignored
useEffect(() => {
fetchData(userId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
That comment is hiding a real bug. The effect fires once on mount but never when userId changes — so users can end up looking at data that belongs to someone else. Worse, if someone later adds a dependency inside fetchData, there's no warning and the stale behavior ships silently.
The lint rule is a guide, not noise. Either add userId to the dependency array, or refactor with useCallback. Don't bury the signal.
16. Using list indices as React keys
// 🚩 Index used as key
{items.map((item, index) => (
<TodoItem key={index} item={item} />
))}
When an item is removed from the list, React compares keys and sees that key={0} still exists. Rather than unmounting the old component, it reuses the DOM node — carrying over stale internal state. This surfaces as inputs that don't clear, form values persisting after deletion, or animations firing on the wrong element.
// ✅ Stable unique ID as key
{items.map((item) => (
<TodoItem key={item.id} item={item} />
))}
If your data doesn't have an ID, generate one when it enters the system — not at render time.
17. Wrapping a pure function in a custom hook
Custom hooks exist for logic that depends on React internals — state, effects, context, refs. If your function doesn't call any of those, it doesn't need the use prefix and doesn't need to follow hook rules.
// 🚩 Hook with no React dependencies
function useFormatCurrency(amount: number) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount)
}
// ✅ Plain function — callable anywhere, easier to test
function formatCurrency(amount: number) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount)
}
Plain functions can be called inside conditionals, event handlers, and loops. Hooks can't.
18. Hand-rolling a data fetching layer
// 🚩 Manual fetch logic
useEffect(() => {
setLoading(true)
fetch(`/api/users/${id}`)
.then(res => res.json())
.then(data => {
setData(data)
setLoading(false)
})
.catch(err => {
setError(err)
setLoading(false)
})
}, [id])
This looks manageable at first. Then comes caching, deduplication, background refetching, retry logic — and there's already a race condition in the code above when id changes quickly. The first response can overwrite the second without an AbortController.
Libraries like TanStack Query and SWR have solved all of this already. Use them rather than gradually rebuilding the same functionality with more edge cases left unhandled.
19. No error boundaries
Without error boundaries, a single unhandled exception can wipe the entire UI — navigation, sidebar, everything. Isolate risky sections so a failure in one area doesn't cascade into a full app crash.
// ✅ Contain failures to the affected section
<ErrorBoundary fallback={<p>Something went wrong.</p>}>
<UserDashboard />
</ErrorBoundary>
20. Silent catch blocks
// 🚩 Error swallowed with no trace
try {
await saveData()
} catch (e) {}
This is how you end up chasing a bug report with no trail to follow. The error happened — nothing recorded it. You have to ship new instrumentation code just to find out what went wrong. At minimum, log it. Ideally, surface something actionable to the user so they know the operation failed.
21. Only handling the happy path
Assuming the API will always respond quickly and successfully means users will inevitably see blank screens with no explanation. Every async operation needs three states handled: loading, error, and success.
// 🚩 Only the success case is handled
function UserList() {
const [users, setUsers] = useState([])
useEffect(() => {
fetch('/api/users').then(r => r.json()).then(setUsers)
}, [])
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>
}
Show a skeleton or spinner while loading. Show a clear message with a retry option on failure. Don't leave users guessing.
22. Inline default values that silently break memoization
// 🚩 New array reference on every render
<ItemsList items={items ?? []} />
// ✅ Stable reference defined once outside the component
const EMPTY_ITEMS: Item[] = []
<ItemsList items={items ?? EMPTY_ITEMS} />
[] looks harmless, but it produces a brand-new array reference on every render. When passed as a prop to a memoized child, React.memo sees a different value every time and skips its optimization entirely. The same issue applies to inline {} objects and arrow functions passed as props.
23. Conditional rendering that requires a mental decoder ring
// 🚩 Chained ternaries
{isLoading ? <Spinner /> : error ? <Error /> : data ? <Content /> : null}
// 🚩 Long boolean chains
{isLoggedIn && hasPermission && !isExpired && featureFlag && <SecretPanel />}
Logic like this forces readers to hold several states in their heads simultaneously. Flattening it makes intent immediately clear:
// ✅ Each condition handled on its own line
if (isLoading) return <Spinner />
if (error) return <Error />
if (!data) return null
return <Content />
24. Deeply nested conditionals instead of early returns
Nesting if blocks three or four levels deep creates a pyramid that's hard to read and harder to change. The guard-clause pattern — return early for edge cases, handle the main case last — produces much flatter, more readable code.
// 🚩 Pyramid of doom
function UserProfile({ user }) {
if (user) {
if (user.isActive) {
if (user.hasProfile) {
return <Profile data={user.profile} />
} else {
return <CreateProfile />
}
} else {
return <InactiveMessage />
}
} else {
return <NotFound />
}
}
// ✅ Guard clauses — flat and readable
function UserProfile({ user }) {
if (!user) return <NotFound />
if (!user.isActive) return <InactiveMessage />
if (!user.hasProfile) return <CreateProfile />
return <Profile data={user.profile} />
}
25. Unexplained magic values
setTimeout(retry, 3000) — is 3 seconds a deliberate UX decision, an API contract, or a guess? pageSize: 36 — is that grid-driven or arbitrary? When the origin of a value isn't obvious, the next developer won't know whether it's safe to change.
// 🚩 Values without context
setTimeout(retry, 3000)
const PAGE_SIZE = 36
// ✅ Named constants with explanatory comments
const RETRY_DELAY_MS = 3000 // Aligns with p95 API response time
const PAGE_SIZE = 36 // 4 columns × 9 rows in the default grid layout
26. Duplicated permission checks scattered across the codebase
// 🚩 Same logic repeated in multiple places
{user.role === 'admin' && <DeleteButton />}
{user.role === 'admin' && <AdminPanel />}
{user.role === 'admin' || user.role === 'moderator' && <ModTools />}
// ✅ Centralized permission helpers
function canDelete(user: User) { return user.role === 'admin' }
function canModerate(user: User) { return ['admin', 'moderator'].includes(user.role) }
Inline role checks scattered across a dozen components become a maintenance trap the moment requirements change. Centralizing logic into named permission helpers means a single update propagates everywhere — and the intent is readable at the call site.
27. Importing third-party libraries directly throughout the UI layer
// 🚩 Library tightly coupled to feature components
import { LineChart, Line, XAxis, YAxis } from 'recharts'
// ...used in 20 different components
// ✅ Own abstraction over the library
import { Chart } from '@/components/Chart'
<Chart type="line" data={data} xKey="date" yKey="revenue" />
Direct imports scattered across the codebase make library migrations expensive. A thin wrapper component costs almost nothing to write, but pays off significantly when you need to switch libraries or absorb a breaking API change in one place rather than twenty.
28. Untouchable legacy files everyone works around
Most teams have one — a component that's grown past a thousand lines, has no test coverage, and that nobody touches unless absolutely forced to. New features get bolted on rather than refactored in. The dread compounds.
The fix is incremental, not heroic. Pull out one pure function. Write a test for it. Then another. Momentum builds quickly once the file stops feeling untouchable. The goal isn't a full rewrite — it's stopping the accumulation.
29. Passing a new object reference as a Context value on every render
// 🚩 New object created on every provider re-render
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
// ✅ Stable reference via useMemo
const value = useMemo(() => ({ user, login, logout }), [user, login, logout])
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
An inline object literal creates a fresh reference on every render. Every component consuming that context sees a changed value and re-renders — even when the actual data is identical. For a context near the top of the tree, this means a large portion of the app re-rendering for no reason. One line of useMemo with the right dependencies keeps the reference stable.
None of these are catastrophic in isolation. Most codebases carry a few of them at any point — that's normal. The problem is when they stack up and start reinforcing each other.
If you're spotting several of these in the same project, treat it as a signal worth acting on. Start with whatever is causing the most friction right now, fix it properly, and move to the next. That's how codebases actually get better.
