Common React Antipatterns in Enterprise-Scale Apps
You've fixed the obvious bugs. Your Lighthouse score is green. But the codebase is quietly rotting.
Enterprise React apps rarely collapse from a single bad decision. They degrade gradually — prop chains that grow by one link a sprint, effects that "mostly work," memoization that nobody dares remove because they're not sure what it's doing. The compound cost of these antipatterns doesn't show up in a performance audit. It shows up when you're onboarding a new engineer and realise the data flow takes two hours to explain.
This post covers six antipatterns that appear consistently in production codebases at scale — what they look like, why they happen, and exactly how to fix them.
1. Prop Drilling
The symptom is familiar: a prop that passes through three or four components that don't actually use it, just to reach the one that does.
// ❌ Antipattern — UserAvatar doesn't need theme, but every layer has to carry it
function Page({ theme }: { theme: Theme }) {
return <Sidebar theme={theme} />;
}
function Sidebar({ theme }: { theme: Theme }) {
return <Nav theme={theme} />;
}
function Nav({ theme }: { theme: Theme }) {
return <UserAvatar theme={theme} />;
}
function UserAvatar({ theme }: { theme: Theme }) {
return <img style={{ border: `2px solid ${theme.primary}` }} />;
}
The real problem isn't verbosity — it's coupling. Sidebar and Nav now have a dependency on Theme they don't own. Rename a prop, and you're touching four files.
// ✅ Solution — context, consumed directly where it's needed
const ThemeContext = createContext<Theme>(defaultTheme);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme] = useState<Theme>(defaultTheme);
return <ThemeContext value={theme}>{children}</ThemeContext>;
}
// UserAvatar reaches in directly — Sidebar and Nav are untouched
function UserAvatar() {
const theme = use(ThemeContext);
return <img style={{ border: `2px solid ${theme.primary}` }} />;
}
Use React Context for cross-cutting concerns: auth state, theme, locale, feature flags. For complex domain state with actions and selectors, reach for Zustand — it scales better than context as your state graph grows.
2. Storing Derived State in useState
This one causes bugs that are genuinely hard to track down. When you store something in state that can be calculated from other state, you now own a synchronisation problem.
// ❌ Antipattern — fullName is always one render behind, useEffect is a patch
function NameForm() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
return (
<>
<input value={firstName} onChange={e => setFirstName(e.target.value)} />
<input value={lastName} onChange={e => setLastName(e.target.value)} />
<p>{fullName}</p> {/* one render stale on every keystroke */}
</>
);
}
This is a guaranteed stale render on every change. fullName shows the previous value until the effect fires.
// ✅ Solution — derive it during render. Zero state, zero sync, always current.
function NameForm() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const fullName = `${firstName} ${lastName}`; // computed inline, always fresh
return (
<>
<input value={firstName} onChange={e => setFirstName(e.target.value)} />
<input value={lastName} onChange={e => setLastName(e.target.value)} />
<p>{fullName}</p>
</>
);
}
The rule: if a value can be computed from props or existing state, it is not state. Computed values belong in the render body. If the computation is expensive, wrap it in useMemo — but only if profiling shows the cost is real.
3. useEffect for Data Fetching
Raw useEffect data fetching is one of the most common sources of race conditions in React applications. Every time userId changes and the user clicks fast, you get two overlapping requests and no guarantee of which one resolves last.
// ❌ Antipattern — no cancellation, no deduplication, no caching
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => {
setUser(data); // could be a stale response if userId changed
setLoading(false);
});
}, [userId]);
if (loading) return <Spinner />;
return <Profile user={user} />;
}
In Next.js App Router, the server component pattern eliminates this entirely:
// ✅ Option A — React Server Component (Next.js App Router)
// app/users/[id]/page.tsx
async function UserPage({ params }: { params: { id: string } }) {
const user = await fetchUser(params.id); // server-side, cached by Next.js
return <UserProfile user={user} />;
}
For genuinely client-side reactive data, TanStack Query handles the hard parts — deduplication, background refetching, stale-while-revalidate, error and loading states:
// ✅ Option B — TanStack Query for client-side reactive data
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 60_000, // treat as fresh for 60s
});
if (isLoading) return <Spinner />;
if (error) return <ErrorState />;
return <Profile user={user} />;
}
The signal to reach for TanStack Query: any time you write const [loading, setLoading] = useState(true).
4. Array Index as key Prop
This is the most widespread antipattern on this list, and the bugs it produces are notoriously confusing — animations that stutter, inputs that retain stale values, state that "jumps" to the wrong item.
// ❌ Antipattern — index keys break React's reconciliation when order changes
function TaskList({ tasks }: { tasks: Task[] }) {
return (
<ul>
{tasks.map((task, index) => (
<TaskItem key={index} task={task} />
))}
</ul>
);
}
When a task is removed or reordered, React compares the new list to the old one by key. With index keys, Task B and Task C shift positions — React thinks the component at position 1 is the same component it was before, so it reuses the DOM node and its associated state. The result is state from the deleted item appearing on a different item.
// ✅ Solution — stable, unique ID from the data itself
function TaskList({ tasks }: { tasks: Task[] }) {
return (
<ul>
{tasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</ul>
);
}
If your data doesn't have an ID (rare for real data, common for mock data), generate a stable one when the item enters your system — not on every render. crypto.randomUUID() at creation time, not inside the map.
5. Unscoped Component State (Lifting State Too High)
The opposite of prop drilling is state that lives too high in the tree. When global state changes, every subscriber re-renders — including subtrees that don't care.
// ❌ Antipattern — searchQuery is global, every Panel re-renders on every keystroke
function Dashboard() {
const [searchQuery, setSearchQuery] = useState('');
return (
<>
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<UserPanel /> {/* re-renders on every keystroke */}
<MetricsPanel /> {/* re-renders on every keystroke */}
<ActivityFeed /> {/* re-renders on every keystroke */}
</>
);
}
State should live as close to its consumers as possible. searchQuery is only needed by SearchInput and the component that renders filtered results — it should never be at Dashboard level.
// ✅ Solution — co-locate state with the subtree that owns it
function SearchSection() {
const [searchQuery, setSearchQuery] = useState('');
return (
<section>
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<SearchResults query={searchQuery} />
</section>
);
}
// Dashboard is now stable — UserPanel, MetricsPanel, ActivityFeed never re-render
// from search input changes
function Dashboard() {
return (
<>
<SearchSection />
<UserPanel />
<MetricsPanel />
<ActivityFeed />
</>
);
}
Before reaching for React.memo or useMemo, ask: is the state just too high? Co-location solves the problem at the source; memoization papers over it.
6. Missing Error Boundaries
In enterprise apps, components fail. An API returns an unexpected shape, a third-party script throws, a date library chokes on a null. Without error boundaries, a single component error takes down the entire React tree.
// ❌ No error boundary — one bad API response renders the whole app blank
function App() {
return (
<Dashboard>
<UserProfile userId={userId} /> {/* throws if API returns 500 */}
<MetricsPanel />
<ActivityFeed />
</Dashboard>
);
}
Error boundaries are still class components in React (as of React 19), but a lightweight wrapper makes them composable:
// ✅ Reusable ErrorBoundary component
import { Component, type ReactNode } from 'react';
interface Props {
fallback: ReactNode;
children: ReactNode;
}
interface State {
hasError: boolean;
}
class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(): State {
return { hasError: true };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
// send to your error tracking (Sentry, Datadog, etc.)
console.error('Boundary caught:', error, info.componentStack);
}
render() {
if (this.state.hasError) return this.props.fallback;
return this.props.children;
}
}
// ✅ Wrap independent subtrees — failures are isolated
function App() {
return (
<Dashboard>
<ErrorBoundary fallback={<ProfileError />}>
<UserProfile userId={userId} />
</ErrorBoundary>
<ErrorBoundary fallback={<MetricsUnavailable />}>
<MetricsPanel />
</ErrorBoundary>
<ErrorBoundary fallback={null}>
<ActivityFeed />
</ErrorBoundary>
</Dashboard>
);
}
Wrap each independent section of your UI. When UserProfile throws, the rest of the dashboard keeps working.
Key Takeaways
- Derived state is not state. If you can compute it from props or existing state during render, do that. Reach for
useEffectto sync it only when you can't. - Co-locate before you memoize. Lifting state too high causes unnecessary renders. Move the state down first.
React.memoanduseMemoare optimisations, not architecture. useEffectfor data fetching is a trap. Use React Server Components for server data and TanStack Query for client-side reactive data. Both handle the hard parts for you.- Stable keys are non-negotiable. If your list can reorder or filter, index keys will cause bugs. Assign IDs when items are created.
- Error boundaries are infrastructure, not optional. Add them at the section level before you go to production. A single thrown error should never blank the entire app.
This week: pick one of these and grep for it in your codebase. Prop drilling is usually the easiest to spot — search for any prop name that appears across more than two component signatures without being consumed in the first one.
Sources & References
- React Core Team. "React Documentation: You Might Not Need an Effect". 2024.
- React Core Team. "React Documentation: Keeping Components Pure". 2024.
- Tkdodo. "Practical React Query". 2023.
- Mark Erikson. "A (Mostly) Complete Guide to React Rendering Behavior". 2020.
- Dan Abramov. "Writing Resilient Components". 2019.
Suggested Reading
Architectural Note:This platform serves as a live research laboratory exploring the future of Agentic Web Engineering. While the technical architecture, topic curation, and professional history are directed and verified by Maas Mirzaa, the technical research, drafting, and code execution for this post were augmented by Gemini (Google DeepMind). This synthesis demonstrates a high-velocity workflow where human architectural vision is multiplied by AI-powered execution.