20 React Antipatterns That Quietly Break Your App
A practical guide to common React mistakes around state, effects, memoization, refs, reducers, conditional rendering, and component structure — with better code examples.
React is predictable when you work with its model instead of against it: data flows down, rendering is derived from props and state, and side effects are handled explicitly.
Most React bugs do not start as dramatic architectural failures. They start as tiny shortcuts: storing derived values in state, using effects for synchronous work, hiding stale props behind a key, wrapping every function in useCallback, or ignoring dependency warnings because “it works right now.”
This article is a practical checklist of common React antipatterns. The goal is not to turn every rule into dogma. In real projects, context matters: component size, rendering cost, SSR, Strict Mode, team conventions, and measured performance all affect the right decision. Use these examples as review material, not as universal law.
1. Storing synchronous derived data in state
If a value can be calculated from props or existing state during render, it usually should not be stored in separate state.
Bad
import { useEffect, useState } from 'react';
type UserNameProps = {
firstName: string;
lastName: string;
};
export function UserName({ firstName, lastName }: UserNameProps) {
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
return <span>{fullName}</span>;
}This forces React to render once with the old value, run the effect, update state, and render again.
Better
type UserNameProps = {
firstName: string;
lastName: string;
};
export function UserName({ firstName, lastName }: UserNameProps) {
const fullName = `${firstName} ${lastName}`;
return <span>{fullName}</span>;
}Use state for data the component owns and needs to remember. Use render-time calculation for values already available from props or state.
2. Creating unstable objects inside render without a reason
A new object or array literal inside a component gets a new reference on every render. That is not always a bug, but it matters when the value is passed to a memoized child or used as a dependency.
Bad
import { memo } from 'react';
const UserCard = memo(function UserCard({ options }: { options: { compact: boolean } }) {
return <div>{options.compact ? 'Compact' : 'Full'}</div>;
});
export function Sidebar() {
const options = { compact: true };
return <UserCard options={options} />;
}UserCard is memoized, but options is a fresh object every time, so memoization cannot help.
Better for constants
import { memo } from 'react';
const SIDEBAR_OPTIONS = { compact: true } as const;
const UserCard = memo(function UserCard({ options }: { options: { compact: boolean } }) {
return <div>{options.compact ? 'Compact' : 'Full'}</div>;
});
export function Sidebar() {
return <UserCard options={SIDEBAR_OPTIONS} />;
}Better for values that depend on props
import { memo, useMemo } from 'react';
const UserCard = memo(function UserCard({ options }: { options: { compact: boolean } }) {
return <div>{options.compact ? 'Compact' : 'Full'}</div>;
});
export function Sidebar({ compact }: { compact: boolean }) {
const options = useMemo(() => ({ compact }), [compact]);
return <UserCard options={options} />;
}Do not use useState just to stabilize a constant object. Use a module-level constant, or useMemo when the object is derived from changing inputs.
3. Defining static CSS-in-JS styles inside a component
Some CSS-in-JS libraries generate classes at runtime. If static styles are recreated on every render, you may add unnecessary work.
Bad
import { css } from '@emotion/css';
export function Alert() {
const className = css({
padding: 12,
background: '#fee2e2',
color: '#991b1b',
});
return <div className={className}>Something went wrong.</div>;
}Better
import { css } from '@emotion/css';
const alertClassName = css({
padding: 12,
background: '#fee2e2',
color: '#991b1b',
});
export function Alert() {
return <div className={alertClassName}>Something went wrong.</div>;
}If styles depend on props, use the styling API recommended by your library. Do not blindly move everything outside the component if doing so makes dynamic styling harder or less clear.
4. Using useCallback everywhere
useCallback is useful when function identity matters. It is not a requirement for every event handler.
Usually fine
import { useState } from 'react';
export function ToggleButton() {
const [enabled, setEnabled] = useState(false);
return (
<button onClick={() => setEnabled((current) => !current)}>
{enabled ? 'Enabled' : 'Disabled'}
</button>
);
}This is simple, local, and readable. No memoized child depends on the handler, so useCallback would only add noise.
Useful when passed to a memoized child
import { memo, useCallback, useState } from 'react';
const SaveButton = memo(function SaveButton({ onSave }: { onSave: () => void }) {
return <button onClick={onSave}>Save</button>;
});
export function Editor() {
const [draft, setDraft] = useState('');
const handleSave = useCallback(() => {
console.log('Saving draft:', draft);
}, [draft]);
return (
<>
<textarea value={draft} onChange={(event) => setDraft(event.target.value)} />
<SaveButton onSave={handleSave} />
</>
);
}Use useCallback when a stable reference is part of the design: memoized children, effect dependencies, subscription cleanup, or expensive re-render paths confirmed by profiling.
5. Depending on unstable functions in useMemo
If a function is recreated every render and then used as a dependency, anything depending on it will also update every render.
Bad
import { useMemo } from 'react';
export function ProductList({ products, query }: { products: string[]; query: string }) {
const matchesQuery = (product: string) => product.toLowerCase().includes(query.toLowerCase());
const filteredProducts = useMemo(() => {
return products.filter(matchesQuery);
}, [products, matchesQuery]);
return <ul>{filteredProducts.map((product) => <li key={product}>{product}</li>)}</ul>;
}matchesQuery changes every render, so the memoized value is recalculated every render.
Better: move the function inside useMemo
import { useMemo } from 'react';
export function ProductList({ products, query }: { products: string[]; query: string }) {
const filteredProducts = useMemo(() => {
const normalizedQuery = query.toLowerCase();
return products.filter((product) => product.toLowerCase().includes(normalizedQuery));
}, [products, query]);
return <ul>{filteredProducts.map((product) => <li key={product}>{product}</li>)}</ul>;
}Often the best fix is not useCallback; it is simplifying the dependency graph.
6. Triggering effects because a handler changes identity
Effects should describe synchronization with something outside React. If an effect depends on a locally declared function, check whether the function really needs to exist outside the effect.
Bad
import { useEffect } from 'react';
export function Analytics({ userId }: { userId: string }) {
const buildPayload = () => ({ userId, source: 'dashboard' });
useEffect(() => {
sendAnalytics(buildPayload());
}, [buildPayload]);
return null;
}The effect runs after every render because buildPayload is a new function every time.
Better
import { useEffect } from 'react';
export function Analytics({ userId }: { userId: string }) {
useEffect(() => {
sendAnalytics({ userId, source: 'dashboard' });
}, [userId]);
return null;
}Define helper functions inside the effect when they are only used there. Reach for useCallback when the function is shared elsewhere and its identity matters.
7. Forgetting the dependency array in useEffect
An effect without a dependency array runs after every render.
Bad
import { useEffect, useState } from 'react';
export function Profile({ userId }: { userId: string }) {
const [profile, setProfile] = useState<ProfileData | null>(null);
useEffect(() => {
fetchProfile(userId).then(setProfile);
});
return <pre>{JSON.stringify(profile, null, 2)}</pre>;
}Every state update triggers another render, which triggers another request.
Better
import { useEffect, useState } from 'react';
export function Profile({ userId }: { userId: string }) {
const [profile, setProfile] = useState<ProfileData | null>(null);
useEffect(() => {
let cancelled = false;
async function loadProfile() {
const nextProfile = await fetchProfile(userId);
if (!cancelled) {
setProfile(nextProfile);
}
}
loadProfile();
return () => {
cancelled = true;
};
}, [userId]);
return <pre>{JSON.stringify(profile, null, 2)}</pre>;
}In React Strict Mode, effects may run more than once in development to reveal unsafe side effects. Design effects to be idempotent and clean up after themselves.
8. Omitting real dependencies from hooks
Ignoring dependencies usually creates stale closures: the effect or callback keeps reading an old value.
Bad
import { useEffect } from 'react';
export function DocumentTitle({ title }: { title: string }) {
useEffect(() => {
document.title = title;
}, []);
return null;
}The title is only set on mount. Later prop changes are ignored.
Better
import { useEffect } from 'react';
export function DocumentTitle({ title }: { title: string }) {
useEffect(() => {
document.title = title;
}, [title]);
return null;
}Use the React Hooks ESLint rules. If the linter complains, treat it as a design signal: maybe the effect is doing too much, maybe a helper belongs inside the effect, or maybe the value should be derived during render instead.
9. Initializing global libraries inside a component effect
If initialization does not depend on React state, props, or the DOM, it usually does not belong inside a component.
Bad
import { useEffect } from 'react';
import { initErrorReporter } from './errorReporter';
export function AppShell() {
useEffect(() => {
initErrorReporter();
}, []);
return <main />;
}If AppShell is mounted more than once, initialization can repeat.
Better
import { initErrorReporter } from './errorReporter';
initErrorReporter();
export function AppShell() {
return <main />;
}For browser-only code in SSR apps, guard the initialization:
import { initErrorReporter } from './errorReporter';
if (typeof window !== 'undefined') {
initErrorReporter();
}Use useEffect when initialization needs component lifecycle, DOM access, props, or cleanup.
10. Wrapping imported functions in useCallback for no reason
Imported functions already have stable module-level identity. Wrapping them often adds noise.
Bad
import { useCallback } from 'react';
import { logout } from './auth';
export function LogoutButton() {
const handleLogout = useCallback(() => {
logout();
}, []);
return <button onClick={handleLogout}>Log out</button>;
}Better
import { logout } from './auth';
export function LogoutButton() {
return <button onClick={logout}>Log out</button>;
}Use a local callback when you need to combine actions or pass local data:
import { useCallback } from 'react';
import { logout, trackEvent } from './services';
export function LogoutButton({ redirectTo }: { redirectTo: string }) {
const handleLogout = useCallback(() => {
trackEvent('logout_clicked');
logout({ redirectTo });
}, [redirectTo]);
return <button onClick={handleLogout}>Log out</button>;
}11. Using useMemo with an empty dependency array for simple constants
useMemo(() => value, []) is often a sign that the value should be outside the component.
Bad
import { useMemo } from 'react';
export function PricingBadge() {
const label = useMemo(() => 'Free plan', []);
return <span>{label}</span>;
}Better
const FREE_PLAN_LABEL = 'Free plan';
export function PricingBadge() {
return <span>{FREE_PLAN_LABEL}</span>;
}Use useMemo for expensive calculations or stable references that depend on changing inputs. Do not use it to make simple values look optimized.
12. Declaring components inside other components
A nested component declaration creates a new component type on every render. That can reset state and defeat memoization.
Bad
import { useState } from 'react';
export function Dashboard() {
const [count, setCount] = useState(0);
function CounterLabel() {
return <span>Count: {count}</span>;
}
return (
<button onClick={() => setCount((value) => value + 1)}>
<CounterLabel />
</button>
);
}Better
type CounterLabelProps = {
count: number;
};
function CounterLabel({ count }: CounterLabelProps) {
return <span>Count: {count}</span>;
}
export function Dashboard() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount((value) => value + 1)}>
<CounterLabel count={count} />
</button>
);
}Small inline render helpers are sometimes fine, but reusable components should usually live at module scope or in their own file.
13. Calling hooks conditionally
Hooks must be called in the same order on every render.
Bad
import { useState } from 'react';
export function UserPanel({ user }: { user?: User }) {
if (user) {
const [expanded, setExpanded] = useState(false);
return <button onClick={() => setExpanded((value) => !value)}>{expanded ? 'Hide' : 'Show'}</button>;
}
return <p>No user selected.</p>;
}Better
import { useState } from 'react';
export function UserPanel({ user }: { user?: User }) {
const [expanded, setExpanded] = useState(false);
if (!user) {
return <p>No user selected.</p>;
}
return <button onClick={() => setExpanded((value) => !value)}>{expanded ? 'Hide' : 'Show'}</button>;
}Put conditions inside hooks, not around hook calls.
import { useEffect } from 'react';
function useOnlineStatus(enabled: boolean) {
useEffect(() => {
if (!enabled) return;
const unsubscribe = subscribeToOnlineStatus();
return unsubscribe;
}, [enabled]);
}14. Returning before hooks are called
An early return before a hook is also a conditional hook call.
Bad
import { useState } from 'react';
export function SettingsPanel({ enabled }: { enabled: boolean }) {
if (!enabled) {
return null;
}
const [tab, setTab] = useState<'profile' | 'security'>('profile');
return <button onClick={() => setTab('security')}>{tab}</button>;
}Better
import { useState } from 'react';
export function SettingsPanel({ enabled }: { enabled: boolean }) {
const [tab, setTab] = useState<'profile' | 'security'>('profile');
if (!enabled) {
return null;
}
return <button onClick={() => setTab('security')}>{tab}</button>;
}All hooks should be called before conditional returns.
15. Moving all visibility logic to the parent by default
Conditional rendering in the parent unmounts the child. Sometimes that is exactly what you want. Sometimes it accidentally resets local state, effects, and DOM.
Parent unmounts the child
import { useState } from 'react';
function DraftForm() {
const [title, setTitle] = useState('');
return <input value={title} onChange={(event) => setTitle(event.target.value)} />;
}
export function Page() {
const [visible, setVisible] = useState(false);
return (
<>
<button onClick={() => setVisible((value) => !value)}>Toggle</button>
{visible ? <DraftForm /> : null}
</>
);
}Every time DraftForm disappears, its local title state is lost.
Keep mounted when state should survive
import { useState } from 'react';
function DraftForm({ visible }: { visible: boolean }) {
const [title, setTitle] = useState('');
if (!visible) {
return null;
}
return <input value={title} onChange={(event) => setTitle(event.target.value)} />;
}
export function Page() {
const [visible, setVisible] = useState(false);
return (
<>
<button onClick={() => setVisible((value) => !value)}>Toggle</button>
<DraftForm visible={visible} />
</>
);
}Unmount in the parent when you intentionally want cleanup and reset. Keep the child mounted when temporary hiding should preserve state.
16. Replacing every group of useState calls with useReducer
useReducer is excellent for related state transitions. It is not automatically better than useState.
Hard to scale
import { useState } from 'react';
export function SignupForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
function handleSubmit() {
if (password.length < 8) {
setError('Password must be at least 8 characters.');
return;
}
setError(null);
submitSignup({ email, password });
}
return (
<form onSubmit={(event) => { event.preventDefault(); handleSubmit(); }}>
<input value={email} onChange={(event) => setEmail(event.target.value)} />
<input value={password} onChange={(event) => setPassword(event.target.value)} type="password" />
{error ? <p>{error}</p> : null}
<button type="submit">Create account</button>
</form>
);
}This is acceptable for small forms. But once transitions become connected, a reducer can make the rules clearer.
Better for related transitions
import { useReducer } from 'react';
type FormState = {
email: string;
password: string;
error: string | null;
};
type FormAction =
| { type: 'emailChanged'; email: string }
| { type: 'passwordChanged'; password: string }
| { type: 'submitted' }
| { type: 'reset' };
function createInitialFormState(): FormState {
return {
email: '',
password: '',
error: null,
};
}
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'emailChanged':
return { ...state, email: action.email };
case 'passwordChanged':
return { ...state, password: action.password };
case 'submitted':
return {
...state,
error: state.password.length < 8 ? 'Password must be at least 8 characters.' : null,
};
case 'reset':
return createInitialFormState();
default:
return state;
}
}
export function SignupForm() {
const [state, dispatch] = useReducer(formReducer, undefined, createInitialFormState);
function handleSubmit() {
dispatch({ type: 'submitted' });
if (state.password.length >= 8) {
submitSignup({ email: state.email, password: state.password });
}
}
return (
<form onSubmit={(event) => { event.preventDefault(); handleSubmit(); }}>
<input value={state.email} onChange={(event) => dispatch({ type: 'emailChanged', email: event.target.value })} />
<input value={state.password} onChange={(event) => dispatch({ type: 'passwordChanged', password: event.target.value })} type="password" />
{state.error ? <p>{state.error}</p> : null}
<button type="submit">Create account</button>
</form>
);
}Use useReducer when state transitions are related, depend on previous state, or are easier to test as a pure function.
17. Sharing mutable initial state objects
A module-level object is not dangerous by itself, but it becomes risky if it is reused and mutated.
Risky
type FormState = {
text: string;
touched: boolean;
};
const initialFormState: FormState = {
text: '',
touched: false,
};
function reducer(state: FormState, action: { type: 'reset' }): FormState {
if (action.type === 'reset') {
return initialFormState;
}
return state;
}If some code mutates the returned object, future resets may not be clean.
Better
type FormState = {
text: string;
touched: boolean;
};
function createInitialFormState(): FormState {
return {
text: '',
touched: false,
};
}
function reducer(state: FormState, action: { type: 'reset' }): FormState {
if (action.type === 'reset') {
return createInitialFormState();
}
return state;
}A factory function gives each reset a fresh object. For immutable constants, Object.freeze or TypeScript as const can also be useful, but do not mutate React state either way.
18. Using useState for values that should not re-render the component
If changing a value should not affect JSX, useRef is often a better fit.
Bad
import { useEffect, useState } from 'react';
export function SearchBox({ query }: { query: string }) {
const [previousQuery, setPreviousQuery] = useState(query);
useEffect(() => {
if (previousQuery !== query) {
console.log('Query changed:', query);
setPreviousQuery(query);
}
}, [previousQuery, query]);
return <input defaultValue={query} />;
}The previous value is used for bookkeeping, not rendering, but updating it causes a render.
Better
import { useEffect, useRef } from 'react';
export function SearchBox({ query }: { query: string }) {
const previousQueryRef = useRef(query);
useEffect(() => {
if (previousQueryRef.current !== query) {
console.log('Query changed:', query);
previousQueryRef.current = query;
}
}, [query]);
return <input defaultValue={query} />;
}Use refs for instance-like mutable values: timers, DOM nodes, previous values, request IDs, and flags that do not belong in the rendered output.
19. Copying props or context into state as an initial value
useState(props.value) only uses the prop on the first render. It does not keep the state synchronized.
Bad
import { useState } from 'react';
type Article = {
title: string;
body: string;
};
export function ArticleStats({ article }: { article: Article }) {
const [length] = useState(article.title.length + article.body.length);
return <p>Total length: {length}</p>;
}If article changes, length stays stuck on the old article.
Better
type Article = {
title: string;
body: string;
};
export function ArticleStats({ article }: { article: Article }) {
const length = article.title.length + article.body.length;
return <p>Total length: {length}</p>;
}If the user needs to edit a local draft based on props, make the reset behavior explicit:
import { useEffect, useState } from 'react';
type Article = {
id: string;
title: string;
};
export function ArticleTitleEditor({ article }: { article: Article }) {
const [draftTitle, setDraftTitle] = useState(article.title);
useEffect(() => {
setDraftTitle(article.title);
}, [article.id, article.title]);
return <input value={draftTitle} onChange={(event) => setDraftTitle(event.target.value)} />;
}Copy props into state only when local editing or intentional divergence is part of the feature.
20. Forcing remounts with key to hide state bugs
Changing a component’s key tells React to throw away the old instance and create a new one. That is useful for intentional resets, but harmful when used to paper over stale state.
Bad
import { useState } from 'react';
export function ArticlePage({ articles }: { articles: Article[] }) {
const [currentArticle, setCurrentArticle] = useState<Article | null>(null);
return (
<main>
<ArticleNavigation articles={articles} onSelect={setCurrentArticle} />
{currentArticle ? <ArticleContent key={currentArticle.id} article={currentArticle} /> : null}
</main>
);
}This remounts ArticleContent every time the article changes. Any local state, focus, animation, or effect inside it is reset.
Better
import { useMemo, useState } from 'react';
export function ArticlePage({ articles }: { articles: Article[] }) {
const [currentArticleId, setCurrentArticleId] = useState<string | null>(null);
const currentArticle = useMemo(() => {
return articles.find((article) => article.id === currentArticleId) ?? null;
}, [articles, currentArticleId]);
return (
<main>
<ArticleNavigation articles={articles} onSelect={(article) => setCurrentArticleId(article.id)} />
{currentArticle ? <ArticleContent article={currentArticle} /> : <p>Select an article.</p>}
</main>
);
}Store the stable identifier, derive the selected object from current data, and let the child update from props naturally.
Use key for real identity changes and intentional resets, not as a workaround for incorrect state design.
Final checklist
Before adding state, ask: can this be derived during render?
Before adding an effect, ask: am I synchronizing with something outside React, or just calculating data?
Before adding useMemo or useCallback, ask: does identity or expensive computation actually matter here?
Before suppressing a dependency warning, ask: is this effect trying to do too much?
Before using key to force a remount, ask: do I really want to destroy the component state?
React code stays healthy when each value has a clear owner, each effect has a clear external reason to exist, and optimizations are applied where they solve a real problem instead of making the component look more advanced.


