This article is published in English.
Stop Syncing State With useEffect: A Safer React Pattern
Learn why using useEffect to sync derived state causes race conditions and extra renders, and how to replace it with render-time derivation and the key prop.
How treating useEffect as a mechanism for keeping state in sync triggers render loops, race conditions, and phantom UI glitches.
A support ticket lands on your desk marked urgent. A customer says that while switching between accounts on a team dashboard, the activity log sometimes shows events belonging to the account they viewed thirty seconds earlier.
You dig into the codebase. There's nothing exotic here — no WebSocket layer, no worker threads, just a fairly typical master-detail React view.
Testing it locally, you click through accounts one after another. The first ninety-nine switches work fine. Then, on the hundredth attempt with network throttling enabled, something breaks visually: for a brief moment, the billing tier of the previously selected user shows up inside the newly selected user's profile card before correcting itself.
The root of that flicker is a pattern that looks completely harmless:
useEffect(() => {
if (selectedUserId) {
fetchUserData(selectedUserId).then((data) => {
setUserProfile(data);
});
}
}, [selectedUserId]);
This one habit — reaching for useEffect to keep internal component state aligned with props or other state — causes more subtle bugs, visual jank, and structural headaches in modern frontend code than almost any other single pattern.
1. The Lifecycle Fallacy: Why Developers Default to useEffect
When Hooks arrived in React 16.8, engineers with a background in class components often treated useEffect as a drop-in replacement for componentDidMount, componentDidUpdate, and componentWillUnmount combined into one API.
That assumption led to real confusion down the line.
Class components encouraged an imperative style: when a prop changed, you'd manually trigger this.setState() inside componentDidUpdate to recompute anything derived from it.
Moving to function components, many developers carried that same imperative habit forward, essentially assuming that whenever some prop changed, it was their job to explicitly push a matching update into a piece of local state.
The trouble is that React is fundamentally declarative and state-driven. Writing a useEffect whose sole purpose is updating another local state variable effectively forces React to run through two full render passes instead of one.
Here's the sequence that unfolds:
- React renders the component using the new props alongside the still-outdated state.
- That render is committed to the DOM, and the browser paints it.
- The effect fires and calls
setState(). - React queues up a second render reflecting the updated state.
Take this example:
function UserBillingSummary({
plan,
addonCount
}: {
plan: string;
addonCount: number;
}) {
const [totalCost, setTotalCost] = useState(0);
useEffect(() => {
const base = plan === 'enterprise' ? 499 : 99;
setTotalCost(base + addonCount * 25);
}, [plan, addonCount]); return <div>Total: ${totalCost} / month</div>;
}
Here, there's no real need for a separate state variable at all — totalCost can be derived entirely from plan and addonCount.
In other words, the component is performing unnecessary extra work just to reach a value that was already computable during the initial render.
During that in-between frame, users may briefly see inconsistent numbers on screen. And if any layout logic depends on that computed value, the browser might also have to redo layout and paint work it shouldn't have needed to repeat.
2. The Domino Effect: Cascading Dependency Chains
This double-render cost gets considerably worse once multiple effects start depending on each other's output.
Picture a multi-step filtering panel inside an analytics dashboard:
function AnalyticsFilters({
organizationId
}: {
organizationId: string;
}) {
const [teams, setTeams] = useState<Team[]>([]);
const [selectedTeamId, setSelectedTeamId] = useState<string>('');
const [projects, setProjects] = useState<Project[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string>('');
useEffect(() => {
fetchTeams(organizationId).then((res) => {
setTeams(res);
setSelectedTeamId(res[0]?.id || '');
});
}, [organizationId]); useEffect(() => {
if (selectedTeamId) {
fetchProjects(selectedTeamId).then((res) => {
setProjects(res);
setSelectedProjectId(res[0]?.id || '');
});
}
}, [selectedTeamId]); useEffect(() => {
if (selectedProjectId) {
logAnalyticsFilterChange(selectedProjectId);
}
}, [selectedProjectId]); return (
<div className="filter-bar">
{/* Filter UI */}
</div>
);
}
Watch what happens the moment organizationId changes:
- React re-renders using the new
organizationId. - The first effect fetches the list of teams, then updates both
teamsandselectedTeamId. - React renders again.
- A second effect notices the updated
selectedTeamIdand fetches related projects. - React renders yet again.
- A third effect picks up the new
selectedProjectIdand logs the change.
What started as one prop update has now cascaded into a chain of state changes and effect executions.
As an application grows, chains like this become genuinely hard to trace. If network responses arrive out of order, or if one of them returns empty due to a permissions issue or some other edge case, the interface can settle into an inconsistent state without any obvious error being thrown.
The real issue isn't just the extra render count — it's that the component has quietly turned into a miniature asynchronous state machine that nobody deliberately designed as one.
3. The Ghost of Async Race Conditions
Unmanaged asynchronous calls inside useEffect are another frequent source of phantom data appearing in single-page apps.
Picture a support agent rapidly clicking through different ticket rows in a table:
function TicketDetailView({
ticketId
}: {
ticketId: string;
}) {
const [ticket, setTicket] = useState<TicketData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true); api.getTicket(ticketId).then((data) => {
setTicket(data);
setLoading(false);
});
}, [ticketId]); if (loading) return <div>Loading ticket...</div>; return <TicketDetails ticket={ticket} />;
}
Here's the sequence of events that breaks this component:
- The agent clicks Ticket #101. Request A fires.
- Before A resolves, the agent clicks Ticket #102. Request B fires.
- Request B resolves first, so the UI now shows Ticket #102.
- Request A finally resolves and calls
setTicket(ticket101). - The sidebar still highlights Ticket #102, but the detail pane is now showing Ticket #101 instead.
React isn't at fault here. The real flaw is that the component lets a stale, outdated request overwrite state after the user has already navigated elsewhere.
If you're fetching inside an effect without any helper library, you need to guard against this with manual cleanup:
useEffect(() => {
let isCurrent = true;
setLoading(true); api.getTicket(ticketId)
.then((data) => {
if (isCurrent) {
setTicket(data);
setLoading(false);
}
})
.catch((err) => {
if (isCurrent) {
handleTicketError(err);
}
}); return () => {
isCurrent = false;
};
}, [ticketId]);
A better option, when your API client supports it, is AbortController. Rather than simply discarding a late response, you actually cancel the in-flight request before it can resolve.
4. The Clean Alternative: Derived State During Render
Often the simplest fix for synchronization bugs is to avoid synchronizing state in the first place.
In many cases where developers instinctively reach for useState paired with useEffect, the value they're trying to store already exists somewhere in the current props or parent state.
Rather than duplicating that value into local state, you can just compute it directly while rendering.
Here's the problematic pattern:
function OrderSummary({
items,
discountCode
}: OrderSummaryProps) {
const [discountPercent, setDiscountPercent] = useState(0);
const [subtotal, setSubtotal] = useState(0);
const [finalTotal, setFinalTotal] = useState(0);
useEffect(() => {
const rawSum = items.reduce(
(acc, item) => acc + item.price * item.quantity,
0
); setSubtotal(rawSum);
}, [items]); useEffect(() => {
const discount = calculateDiscount(discountCode);
setDiscountPercent(discount);
}, [discountCode]); useEffect(() => {
setFinalTotal(
subtotal - subtotal * (discountPercent / 100)
);
}, [subtotal, discountPercent]); return (
<SummaryView
subtotal={subtotal}
total={finalTotal}
/>
);
}
Now compare that with a version built on derived state:
function OrderSummary({
items,
discountCode
}: OrderSummaryProps) {
const subtotal = items.reduce(
(acc, item) => acc + item.price * item.quantity,
0
);
const discountPercent = calculateDiscount(discountCode); const finalTotal =
subtotal - subtotal * (discountPercent / 100); return (
<SummaryView
subtotal={subtotal}
total={finalTotal}
/>
);
}
The contrast matters. There's no duplicate state, no effect responsible for keeping values in sync, and no window in which finalTotal could drift out of alignment with subtotal and discountPercent.
The props themselves stay the single source of truth throughout.
When a computation is genuinely costly, useMemo lets you cache the result across renders:
const filteredTransactions = useMemo(() => {
return rawTransactions.filter((tx) => {
return (
tx.amount >= minThreshold &&
tx.category === activeCategory
);
});
}, [rawTransactions, minThreshold, activeCategory]);
The key point is that useMemo exists purely to memoize a calculation — it isn't meant to keep two separate pieces of state in sync with each other.
5. Resetting State Declaratively With the key Prop
A related trap shows up when an editable form needs to reset its fields whenever the entity being edited changes.
The typical instinct looks like this:
function EditUserModal({
user
}: {
user: UserData;
}) {
const [name, setName] = useState(user.name);
const [role, setRole] = useState(user.role);
useEffect(() => {
setName(user.name);
setRole(user.role);
}, [user.id]); return (
<form>
<input
value={name}
onChange={(e) => setName(e.target.value)}
/> <select
value={role}
onChange={(e) => setRole(e.target.value)}
/>
</form>
);
}
This approach can produce a visible flash where the previous record's data lingers on screen for one render before the effect fires and updates the inputs.
Worse, it can silently overwrite whatever the user was typing if fresh data arrives mid-edit.
React already has a built-in, declarative answer for this: the key prop.
In the parent:
function UserAdminPage() {
const [selectedUser, setSelectedUser] =
useState<UserData | null>(null);
return (
<div>
<UserList onSelectUser={setSelectedUser} /> {selectedUser && (
<EditUserForm
key={selectedUser.id}
initialUser={selectedUser}
/>
)}
</div>
);
}
The child component's local state then stays simple, with nothing to reconcile:
function EditUserForm({
initialUser
}: {
initialUser: UserData;
}) {
const [name, setName] = useState(initialUser.name);
const [role, setRole] = useState(initialUser.role);
return (
<form>
<input
value={name}
onChange={(e) => setName(e.target.value)}
/> <select
value={role}
onChange={(e) => setRole(e.target.value)}
/>
</form>
);
}
When the key switches from user-1 to user-2, React doesn't try to update the existing component — it discards it and mounts a brand-new instance, with state initialized fresh from the new user data.
No synchronization effect is needed at all.
6. Where Code Actually Belongs: Event Handlers vs. Effects
A helpful mental model is this: effects exist to keep your component synchronized with something outside React, while event handlers exist to respond to something a user did.
Imagine you need to fire an analytics event and pop a confirmation toast whenever someone clicks "Submit Order."
One way to write this:
function CheckoutButton({
orderId
}: {
orderId: string;
}) {
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (submitted) {
analytics.track('order_submitted', {
orderId
}); showToast('Order placed successfully!');
}
}, [submitted, orderId]); return (
<button onClick={() => setSubmitted(true)}>
Place Order
</button>
);
}
The problem is that this splits the trigger from the action — the click sets a flag, and an effect reacts to that flag later.
A cleaner approach keeps everything inside the handler itself:
function CheckoutButton({
orderId
}: {
orderId: string;
}) {
const handlePlaceOrder = async () => {
await submitOrderApi(orderId);
analytics.track('order_submitted', {
orderId
}); showToast('Order placed successfully!');
}; return (
<button onClick={handlePlaceOrder}>
Place Order
</button>
);
}
Now the causality is explicit: the user clicks, the order gets submitted, and the follow-up actions run immediately as part of that same event. There's no intermediate state change for a separate effect to detect and react to.
7. When Is useEffect Actually Justified?
None of this means useEffect itself is flawed.
The trouble starts when it gets treated as a catch-all tool for shuttling data between different pieces of React state.
Its actual job is to keep your component in sync with something that lives outside of React's own rendering model.
That "something outside React" typically falls into categories like:
- Native browser APIs, such as
window.addEventListener,IntersectionObserver, ormatchMedia - Imperative third-party libraries, like Mapbox, Chart.js, or video player SDKs
- Real-time connections such as WebSockets or Server-Sent Events
- Direct DOM manipulation, such as updating
document.title
Tracking the browser's viewport width on resize is a good example of a legitimate effect:
function useWindowWidth() {
const [width, setWidth] = useState(
() => window.innerWidth
);
useEffect(() => {
const handleResize = () => {
setWidth(window.innerWidth);
}; window.addEventListener(
'resize',
handleResize
); return () => {
window.removeEventListener(
'resize',
handleResize
);
};
}, []); return width;
}
In this case, the effect is doing something rendering alone cannot accomplish: it sets up a subscription to a browser event and tears it down on cleanup.
That's precisely the kind of job useEffect was designed for.
The Architectural Rules I Now Follow
When a React-based dashboard or app starts feeling sluggish, erratic, or riddled with timing bugs that are hard to reproduce, the first place worth checking is how useEffect is being used throughout the codebase.
Four guiding principles tend to catch most of the problems.
1. Compute values during render.
Anything derivable from props or existing state should be calculated directly in the render body. Reach for useMemo only when that calculation is actually expensive.
2. Keep user-triggered logic inside event handlers. When something happens because of a click, keystroke, selection, or submission, that logic belongs right next to the event that caused it, not scattered into a separate effect.
3. Reset state cleanly with the key prop.
When switching between entities should produce a completely fresh component instance, let React remount the component rather than manually syncing every individual field.
4. Reserve useEffect for genuine external synchronization.
Browser events, subscriptions, WebSocket connections, and imperative library integrations are the proper domain for effects.
The point isn't to purge useEffect from your codebase entirely.
The point is to stop leaning on it as an informal pipeline for moving values between different bits of React state.
Once derived values are treated as derived values, user actions are handled as events, and only true external systems are wired up through effects, React components become dramatically easier to reason about.
And a large share of the mysterious bugs that only surface after dozens of clicks, under a slow network connection, or exclusively in production become far easier to prevent in the first place.