You just shipped a new feature. The code looks clean. The tests pass. Then your support channel blows up: “The dashboard freezes for 10 seconds when switching tabs.” Not on prod, not in staging — only after three hours of idle tabbing in Chrome. You open DevTools and see memory climbing like a staircase. No leaks reported. No obvious loops. Just… slow decay.
I spent two days chasing that bug across a logistics SaaS startup’s admin dashboard — 35k monthly active users, no infra team, no observability budget, just React Router v6, Zustand v4, TanStack Query v4, and a growing pile of “framework” abstractions we’d built to “standardize” things. We weren’t hitting React limits. We weren’t leaking DOM nodes. We were, however, violating one unspoken rule that no tutorial mentions, no linter enforces, and no RFC documents: React components are not services — they’re contracts with the browser’s rendering lifecycle. Break that contract, and you don’t get errors. You get slow decay, stale state, and silent data corruption.
This isn’t about bad libraries. It’s about how we orchestrate them — layering indirection on top of indirection until the framework stops being a tool and starts being a maze we navigate blindfolded.
Let me show you exactly what broke — and how we fixed it, not with architecture diagrams or RFCs, but with four concrete, testable, human-written rules. Each came from a real project. Each cost us real time. Each is now in our onboarding checklist.
---
The Problem Wasn’t React — It Was Our “Framework”
We didn’t start with a framework. We started with a working page: /shipments/:id. It fetched data. It rendered. It had a “Mark as delivered” button. Simple.
Then came /shipments?status=delivered&date=last-week. So we added query param parsing. Then /shipments/:id/notes, so we nested routes. Then /shipments/:id/history, which needed a separate API call — but we wanted shared loading states, so we built a useShipmentContext hook. Then we needed permissions, so we wrapped routes in guards. Then analytics, so we added useTrackPageView. Then caching logic, so we wrote useCachedQuery. Then error boundaries, then skeleton loaders, then optimistic updates…
Six months later, every route had:
- A global store slice (Zustand)
- A context provider (React.createContext)
- A query key factory (TanStack Query)
- A route guard HOC
- A side-effect tracker
- A memoized props object
None of these were wrong in isolation. But together? They created hidden coupling, timing races, and resource leaks — all invisible until users clicked around for ten minutes.
Here’s what actually happened in production:
- A technician opened
/shipments/123, then navigated to/shipments/456, then back to/shipments/123. - The global store still held
shipmentId = 456. - The query key was
['shipment', 123], but the store readshipmentId = 456, so a second query fired for['shipment', 456]— even though the URL said123. - That second query resolved after the first, overwriting the correct data with stale data.
- Meanwhile, the WebSocket connection (used for live status) never closed on unmount — so after five route switches, five connections stayed open. Chrome capped at six. The sixth request hung forever.
- The “Mark as delivered” button called
navigate('/success'), thenlogEvent(). If the user hit Back beforelogEvent()resolved, the success screen vanished — and the event never fired. The order was delivered, but the UI said nothing happened.
No stack trace. No console warning. Just confused users and a dashboard that felt “heavy.”
We blamed Chrome. Then TanStack Query. Then React.memo. Then Vite. Then ourselves.
The real culprit? We’d stopped treating components as declarative descriptions of UI state — and started treating them as entry points into an imperative runtime we’d built around React.
So we stepped back. We deleted half our “framework” folder. We audited every useEffect, every store subscription, every navigation call. And we landed on four rules — each born from a specific failure, each enforceable in code review, each backed by a working fix.
---
Rule 1: Route-Bound State Must Die — Use Route Params as Source of Truth, Not Stores
Why This Hurts
Route params (:id, ?search) are immutable within a single render. Stores (Zustand, Redux, Context) are mutable at any time. When you mix them — reading id from URL and patientId from store in the same component — you create a race condition with no winner. The store wins silently, and your UI desyncs from the URL.
I messed this up the first time on a dental practice management tool. We stored patientId in Zustand on mount, then used it to fetch appointments. But if a user clicked the browser Back button, the URL changed to /patients/789, but the store still held patientId = 123. The appointment list kept showing old data. Worse: clicking “New Appointment” opened a form pre-filled with patient 123 — even though the URL clearly showed patient 789.
We thought it was a Zustand persistence bug. It wasn’t. It was us violating the contract: the URL owns truth for route-scoped data. Full stop.
The Fix: Derive Everything From Params + Explicit Dependencies
// ❌ Before: storing route state in global store
const PatientPage = () => {
const { id } = useParams();
const patientId = useStore(state => state.patientId); // stale after back/forward
useEffect(() => {
loadPatient(id); // but what if id changes mid-load?
}, [id]);
return <PatientDetail id={patientId} />; // mismatched!
};
That useEffect dependency array [id] looks right — but it’s lying. Because loadPatient(id) reads id, sure — but PatientDetail reads patientId from store, which may be stale. There’s no guarantee those two values match. And there’s no cleanup if id changes while loadPatient is still pending.
// ✅ After: derive everything from URL + explicit dependencies
const PatientPage = () => {
const { id } = useParams();
const query = useQuery({
queryKey: ['patient', id],
queryFn: () => fetchPatient(id),
enabled: !!id, // critical: disable if param missing
});
if (!id) return <Navigate to="/patients" replace />;
if (query.isLoading) return <Spinner />;
if (query.error) return <ErrorFallback />;
return <PatientDetail patient={query.data} />;
};
Notice three things:
- No store read.
idcomes only fromuseParams(). enabled: !!idprevents TanStack Query from firing a request whenidis undefined (e.g., during initial render or invalid route). Without this, you getfetchPatient(undefined)— which either fails, or worse, returns cached garbage.- No manual
useEffectfor fetching. TanStack Query handles loading/error/initial fetch — and crucially, it invalidates and refetches automatically whenidchanges. No race. No stale data. No cleanup needed.
This works because TanStack Query’s queryKey includes id. When id changes, the key changes → old query is marked stale → new query starts → old query is garbage collected. It’s declarative, predictable, and requires zero custom logic.
Practical Tip: Ask “Which One Owns Truth?”
Before reading from any store inside a route component, ask out loud: “Which source owns the truth for this piece of data — the URL, or the store?”
- If the answer is URL, delete the store read. Use
useParams()oruseSearchParams()exclusively. - If the answer is store, then the data should not be route-scoped. Move it to a higher-level layout or persist it outside routing (e.g., in localStorage for preferences).
- If you can’t answer — that’s a design smell. Refactor until the ownership is unambiguous.
Real Tradeoff
You lose the ability to “preload” data before navigation (e.g., fetch patient 123 while user is still on /patients list). That’s real — and sometimes worth it. But in 90% of cases, the complexity cost of preloading (cache invalidation, race conditions, double-fetching) outweighs the ~200ms perceived speed gain. Users tolerate a spinner. They don’t tolerate wrong data.
We measured: moving from store-based patient ID to param-based cut inconsistent UI states by 100%. Zero bugs related to back/forward navigation after rollout.
---
Rule 2: Side Effects Belong Inside Components — Never in Framework Wrappers
Why This Hurts
We built a “data provider” wrapper for a freelance e-commerce dashboard. Its job: auto-fetch cart, user, and notifications on every route change. “Consistency,” we said. “One place to manage data.”
It worked — until mobile Safari throttled background tabs. Then it didn’t.
On iOS, the cart sidebar wasn’t visible on /products, but our provider fetched cart data anyway — triggering a network request, parsing JSON, updating Zustand, and forcing a re-render of the entire app shell. Same for notifications. Same for user profile. Seven requests on every route change — even when the user scrolled past the cart icon without clicking.
TTI (Time to Interactive) spiked by 40%. Lighthouse scores dropped. Support tickets asked, “Why does the product page feel sluggish?”
The problem wasn’t the requests. It was where we placed them. We’d moved side effects — expensive, conditional, user-intent-driven operations — out of components and into a global wrapper. That wrapper had no knowledge of visibility, intent, or urgency. It just ran.
The Fix: Colocate, Condition, and Lazy-Load
// ❌ Before: framework-level auto-fetch
const DataProvider = ({ children }) => {
useQuery(['cart'], fetchCart); // runs on every route
useQuery(['user'], fetchUser);
useQuery(['notifications'], fetchNotifications);
return children;
};
This looks tidy. But it’s a lie. useQuery doesn’t “fetch data.” It registers interest in data. And registering interest everywhere means fetching everywhere — whether needed or not.
// ✅ After: colocated, conditional, and lazy
const CartSidebar = () => {
const [isOpen, setIsOpen] = useState(false);
const query = useQuery({
queryKey: ['cart'],
queryFn: fetchCart,
enabled: isOpen, // only fetch when needed
});
useEffect(() => {
if (isOpen && !query.isFetched) {
query.refetch(); // explicit trigger, not magic
}
}, [isOpen, query.isFetched, query.refetch]);
return (
<div className="sidebar">
<button onClick={() => setIsOpen(!isOpen)}>
Cart ({query.data?.items.length || 0})
</button>
{isOpen && (
<div>{query.data?.items.map(item => <CartItem key={item.id} item={item} />)}</div>
)}
</div>
);
};
Three key shifts:
- Side effect lives where the user interacts with it. The cart sidebar owns cart data — not a root provider.
enabled: isOpenmakes fetching intent-driven. No request fires until the user signals interest.useEffectwithrefetch()gives us control. If the user opens the cart and we haven’t fetched yet (e.g., first open), we trigger it explicitly — rather than relying on TanStack Query’s default stale-while-revalidate behavior, which might show stale data.
Bonus: notice query.isFetched. That’s not a hack — it’s TanStack Query’s documented flag meaning “we’ve successfully fetched at least once.” It’s how you know whether to show a spinner or cached data.
Practical Tip: Run npm ls @tanstack/react-query
If you see more than one version, you’re almost certainly double-wrapping queries.
Example output:
├─┬ @tanstack/react-query@4.36.1
└─┬ my-custom-framework@2.1.0
└── @tanstack/react-query@4.29.0
That means my-custom-framework ships its own copy of React Query — and its QueryClientProvider creates a separate query cache. Your app now has two independent caches. One fetches /api/cart, the other fetches /api/cart, but they don’t share state. You get duplicate requests, inconsistent loading states, and impossible-to-debug staleness.
Fix: Delete the wrapper. Use QueryClientProvider once, at the very root of your app. No per-page providers. No “data context” HOCs. Just one cache, one source of truth.
Real Tradeoff
You trade “consistency” (all data always present) for predictability (data only loads when needed). That means:
- First click on cart takes longer (network round-trip).
- But subsequent clicks are instant (cached).
- No hidden requests slowing down unrelated pages.
- No memory bloat from unused data.
We cut median TTI on product listing pages by 40% — and reduced average memory usage per tab by ~30MB. Not flashy. But real.
---
Rule 3: Navigation Must Be Side-Effect Free — Treat navigate() Like fetch()
Why This Hurts
A field-service app let technicians log work orders offline-first. After submission, it called:
await submitOrder(formData);
navigate('/success');
logAnalytics('order_submitted');
Looks fine. Until you test it.
If the user taps “Back” immediately after tapping “Submit”, the /success page mounts — then unmounts — before logAnalytics() resolves. The analytics event never fires. The order is saved (server confirmed), but the user sees no confirmation — just a flash of /success, then back to the form. They tap Submit again. Duplicate order.
Worse: the /success page had useEffect(() => { trackPageView('success') }, []). That effect ran, but since the component unmounted instantly, the tracking call was cancelled mid-flight. We had zero visibility into failed submissions — just angry technicians saying “it didn’t work.”
We thought it was a PWA caching bug. It was us treating navigation as synchronous.
The Fix: Await Side Effects Before Navigation
// ❌ Before: navigate first, then side effect
const handleSubmit = async () => {
await submitOrder(formData);
navigate('/success'); // navigation commits immediately
logAnalytics('order_submitted'); // may never run
};
Navigation is not like console.log(). It’s a commitment — the browser tears down the current component tree and mounts the next one. Any code after navigate() is best-effort. If the component unmounts, that code dies.
// ✅ After: await side effects before navigation
const handleSubmit = async () => {
try {
await submitOrder(formData);
await logAnalytics('order_submitted'); // wait for this
navigate('/success', { replace: true }); // then commit
} catch (err) {
showError(err.message);
}
};
Two critical changes:
await logAnalytics()ensures the analytics call completes before navigation. If it fails, we catch it and show an error — instead of silently losing the event.{ replace: true }prevents the user from getting stuck on/successwith no way back (since Back would just loop between/successand the form). It replaces the current history entry — so Back goes to wherever they were before the form.
But wait — what if logAnalytics() takes 2 seconds? Won’t the UI hang?
Yes. Which is why you also need optimistic UI:
const handleSubmit = async () => {
try {
// Optimistic update: assume success
setSubmitting(true);
showSuccessToast('Order submitted!');
await submitOrder(formData);
await logAnalytics('order_submitted');
navigate('/success', { replace: true });
} catch (err) {
setSubmitting(false);
showError(err.message);
}
};
Now the user gets instant feedback — and the analytics call happens in the background, without blocking navigation.
Practical Tip: Never Call navigate() Inside useEffect That Depends on Async Data
This pattern is everywhere — and dangerously broken:
// ❌ Don't do this
const OrderPage = () => {
const query = useQuery({ queryKey: ['order', id], queryFn: fetchOrder });
useEffect(() => {
if (query.data?.status === 'completed') {
navigate('/success'); // race: what if query refetches?
}
}, [query.data?.status, navigate]);
return <OrderForm />;
};
Here, query.data?.status can change during a refetch — e.g., from 'pending' → 'completed' → 'pending' (if network flaps). You’ll navigate away, then get pulled back when the new 'pending' data arrives. Or worse: the effect runs twice, navigating twice.
Instead, handle navigation in the event handler — where you control timing:
// ✅ Do this
const OrderPage = () => {
const query = useQuery({ queryKey: ['order', id], queryFn: fetchOrder });
const navigate = useNavigate();
const handleComplete = async () => {
try {
await markAsCompleted(id);
await logAnalytics('order_completed');
navigate('/success', { replace: true });
} catch (err) {
showError(err.message);
}
};
return (
<div>
<h1>Order #{id}</h1>
{query.data?.status === 'completed' ? (
<p>✅ Completed</p>
) : (
<button onClick={handleComplete}>Mark Complete</button>
)}
</div>
);
};
Navigation belongs in user-triggered handlers — not in effects reacting to data changes.
Real Tradeoff
You trade “automatic redirects” for reliability. That means:
- You must write more explicit handler logic.
- You can’t rely on “magic” redirects in layouts or providers.
- But you guarantee side effects complete before leaving the page.
We reduced “order not confirmed” support tickets by 95%. Technicians trusted the app again.
---
Rule 4: Cleanup Isn’t Optional — It’s Your Contract With the Browser
Why This Hurts
A real-time inventory tracker used WebSockets to push stock updates. We reused the same connection across route changes — “efficient,” we thought. “Don’t recreate sockets needlessly.”
But we never closed it.
After five route switches, five WebSocket connections stayed open. Chrome enforces a hard limit of six concurrent connections per origin. The sixth request — a critical low-stock alert — hung forever. Users saw “Loading…” and assumed the system was down.
No error. No warning. Just silence.
We blamed the backend. Then the network. Then our WebSocket library.
The fix took 37 seconds: add a useEffect cleanup function.
The Fix: Explicit, Minimal, Testable Cleanup
// ❌ Before: no cleanup
const InventoryMonitor = () => {
useEffect(() => {
const ws = new WebSocket('wss://api.example.com/inventory');
ws.onmessage = (e) => updateInventory(JSON.parse(e.data));
}, []);
return <InventoryList />;
};
This looks fine. But ws.onmessage = ... is just shorthand for ws.addEventListener('message', ...). And addEventListener without removeEventListener leaks the listener — and keeps the WebSocket alive.
// ✅ After: explicit, minimal, testable
const InventoryMonitor = () => {
const [inventory, setInventory] = useState([]);
useEffect(() => {
const ws = new WebSocket('wss://api.example.com/inventory');
const handleMessage = (e: MessageEvent) => {
setInventory(prev => {
const data = JSON.parse(e.data);
return [...prev.filter(i => i.id !== data.id), data];
});
};
ws.addEventListener('message', handleMessage);
return () => {
ws.removeEventListener('message', handleMessage);
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close(); // critical: close before GC
}
};
}, []);
return <InventoryList items={inventory} />;
};
Key points:
removeEventListenermust match the handler. Using an inline arrow function (ws.addEventListener('message', (e) => {...})) means you can’t remove it — because every arrow function is a new reference. Always declare the handler separately.- Close the socket before removing the listener. Otherwise, you might miss the final
closeevent. - Check
readyState. Don’t callws.close()on a closed or closing socket — it throws. - No abstraction needed. This is 12 lines. It’s readable. It’s testable. It’s reliable.
Practical Tip: If Your useEffect Opens a Resource, Its Cleanup Must Close It
This applies to all resources:
| Resource | How to Open | How to Clean Up |
|----------|-------------|-----------------|
| WebSocket | new WebSocket(url) | ws.close() + ws.removeEventListener() |
| addEventListener | el.addEventListener('click', handler) | el.removeEventListener('click', handler) |
| setTimeout / setInterval | setTimeout(...) / setInterval(...) | clearTimeout(id) / clearInterval(id) |
| requestAnimationFrame | requestAnimationFrame(cb) | cancelAnimationFrame(id) |
| IntersectionObserver | new IntersectionObserver(...) | observer.disconnect() |
| AbortController | const ac = new AbortController() | ac.abort() |
If your useEffect does any of the left-column actions, the cleanup function must do the corresponding right-column action. No exceptions. No “it’ll be garbage collected.” Browsers don’t guarantee timing — especially under memory pressure.
Real Tradeoff
You trade “slightly less code” for resource safety. That means:
- Every WebSocket, timer, or observer adds 4–6 lines of cleanup.
- You must remember to do it — every time.
- But you prevent hard-to-diagnose memory leaks, connection exhaustion, and ghost listeners.
We went from “inventory updates stop after 5 minutes” to “stable for 8+ hour shifts.” No more “refresh the page” instructions.
---
Common Pitfalls — And Why They Feel Right (Until They Aren’t)
Pitfall 1: Using useMemo to “Optimize” Props Passed to Children — When the Child Doesn’t Need It
#### Why This Hurts
A reporting dashboard wrapped every chart component in useMemo:
// ❌ Before: over-memoizing
const Dashboard = () => {
const [filters, setFilters] = useState({ dateRange: 'week' });
const chartProps = useMemo(() => ({
filters,
onExport: handleExport,
}), [filters]); // hides changes to onExport ref!
return <BarChart {...chartProps} />;
};
handleExport is a function — and functions are recreated on every render unless wrapped in useCallback. So chartProps always changes — because onExport changes — but useMemo hides that change from the dependency array. The BarChart receives new props every render, but useMemo lies and says “no change,” so React skips the re-render — even though the props are different.
Result: BarChart stops responding to onExport changes. Clicking “Export” does nothing.
We thought it was a React.memo bug. It wasn’t. It was us breaking the rules of useMemo: you can only memoize if all dependencies are included in the array. Omitting onExport meant the memo was invalid.
#### The Fix: Skip Memo Unless You’ve Measured a Problem
// ✅ After: skip memo unless child uses React.memo and props are heavy
const Dashboard = () => {
const [filters, setFilters] = useState({ dateRange: 'week' });
const onExport = useCallback(() => { / ... / }, []);
return <BarChart filters={filters} onExport={onExport} />;
};
Now BarChart receives fresh props every render — but that’s fine. If BarChart is pure and fast, React will bail out of reconciliation anyway. If it’s slow, then wrap it in React.memo — and make sure its props are stable.
#### Practical Tip: Use React DevTools to Verify
- Install React DevTools.
- Toggle “Highlight updates.”
- Interact with your UI.
- Watch which components flash green.
- If a component flashes green but its visual output doesn’t change, it’s harmless — don’t memoize.
- If it flashes green and you see jank or lag, profile it.
- Only memoize if profiling shows
BarChartitself is expensive — not its parent.
We removed 12 useMemo calls and fixed 3 broken export handlers. Zero performance regression.
---
Pitfall 2: Assuming useEffect Runs Once — When It Runs on Every Render (Unless You Control Dependencies)
#### Why This Hurts
A settings page loaded user preferences on mount:
// ❌ Before: assuming "run once"
const SettingsPage = () => {
const [prefs, setPrefs] = useState(null);
useEffect(() => {
fetchPreferences().then(setPrefs);
}); // no dependency array → runs every render
return <SettingsForm prefs={prefs} />;
};
No dependency array means “run after every render.” So on every keystroke in SettingsForm, fetchPreferences() fired again. Network tab lit up like Christmas. Users complained the page “froze” while typing.
We thought it was a hydration bug. It was us forgetting the rule: no dependency array = run every render.
#### The Fix: Be Explicit About When You Want It to Run
// ✅ After: run only on mount
const SettingsPage = () => {
const [prefs, setPrefs] = useState(null);
useEffect(() => {
fetchPreferences().then(setPrefs);
}, []); // empty array = run once on mount
return <SettingsForm prefs={prefs} />;
};
But wait — what if preferences change elsewhere (e.g., another tab)? Then prefs becomes stale.
So the real fix is to treat preferences like any other server data:
// ✅ Better: use TanStack Query for consistency
const SettingsPage = () => {
const query = useQuery({
queryKey: ['preferences'],
queryFn: fetchPreferences,
});
if (query.isLoading) return <Spinner />;
if (query.error) return <ErrorFallback />;
return <SettingsForm prefs={query.data} />;
};
Now it’s reactive, cache-aware, and respects network conditions.
#### Practical Tip: Enable ESLint’s react-hooks/exhaustive-deps
It will yell at you for missing dependencies — and it’s almost always right. If it complains, either:
- Add the missing dep to the array, or
- Wrap the value in
useCallback/useMemo, or - Prove why it’s safe to omit (rare).
We caught 87% of accidental re-runs with this rule alone.
---
Pitfall 3: Forgetting That setState Is Asynchronous — And That useState Initializers Run Every Render
#### Why This Hurts
A search component initialized state from props:
// ❌ Before: initializer runs every render
const SearchBox = ({ initialQuery }) => {
const [query, setQuery] = useState(initialQuery); // runs on every render
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
};
useState(initialQuery) looks like “set once.” But it’s not. The initializer function runs every time SearchBox renders — and if initialQuery changes (e.g., from a parent re-render), query resets to the new value — wiping out user input.
We thought it was a controlled component bug. It was us misusing useState.
#### The Fix: Use useRef for Initial Values — Or Accept Controlled Behavior
// ✅ After: useRef for true "initial" value
const SearchBox = ({ initialQuery }) => {
const [query, setQuery] = useState('');
const initialRef = useRef(initialQuery);
useEffect(() => {
setQuery(initialRef.current);
}, []); // run once
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
};
Or better — embrace controlled components:
// ✅ Even better: fully controlled
const SearchBox = ({ query, onQueryChange }) => {
return <input value={query} onChange={(e) => onQueryChange(e.target.value)} />;
};
Now the parent owns the state — and decides when to reset it.
#### Practical Tip: Prefer useRef Over “Caching” Initial Props
useRef is the standard, lightweight way to capture a value once and hold it across renders. Don’t try to “memoize” props — use useRef or lift state up.
---
What We Gained (And What We Gave Up)
We didn’t ship a new framework. We deleted one.
What we gained:
- Predictable data flow. Route params → query keys → UI. No store intermediaries.
- Zero navigation-related race conditions. Side effects complete before routing.
- No resource leaks. Every
useEffectthat opens something closes it. - Smaller bundles. Removed 3 custom hooks, 2 HOCs, and a “data context” provider.
- Faster onboarding. New devs read
PatientPage.tsxand understand the whole flow in 60 seconds. - Fewer bugs. “Stale data on back/forward” dropped from weekly to zero.
What we gave up:
- The illusion of “consistency.” Not all data loads on every page. Some pages feel “lighter” — and that’s good.
- The comfort of abstraction. We write more
useEffectcleanup blocks. More explicitnavigate()calls. Moreenabledflags. - The fantasy of “set it and forget it.” React doesn’t auto-clean up WebSockets. You do.
The biggest shift wasn’t technical — it was philosophical. We stopped asking, “How do we make React do what we want?” and started asking, “What does React want from us?”
It wants:
- Clear ownership of truth (URL > store for route data).
- Side effects colocated with their triggers (cart fetch lives in cart sidebar).
- Navigation treated as a transaction (side effects first, then commit).
- Cleanup as non-negotiable (if you open it, you close it).
That’s not opinionated. It’s how the browser works.
---
One useEffect Rule — And Why It Changed Everything
The title says “one useEffect rule.” Here it is:
Every useEffect must answer three questions — and the answers must be visible in the code:
1. What does it do? (The effect body)
2. When does it run? (The dependency array)
3. What does it clean up? (The return function)
If any answer is missing, hidden, or implied — it’s broken.
That rule caught everything:
- The stale patient ID? Missing cleanup — the effect ran on mount, but didn’t handle param changes.
- The hanging WebSocket? Missing cleanup — no return function.
- The duplicate analytics? Wrong timing — navigation ran before the effect completed.
- The over-fetched cart? Wrong dependencies —
enableddepended on visibility, not a static boolean.
We added this to our PR checklist:
✅ Effect body is clear and focused (one responsibility)
✅ Dependency array includes all values used inside the effect
✅ Return function exists and cleans up exactly what the effect started
✅ If the effect fetches data, it uses enabled or if guards to avoid unnecessary runs
No linter can enforce all of that. But a human can — in 30 seconds.
---
Final Thought: Frameworks Are for Users, Not Developers
We built a “framework” to save time. It cost us weeks.
The irony? The most time-saving thing we did wasn’t writing code — it was deleting it.
We stopped trying to build a system that prevented mistakes — and started building one that made mistakes obvious.
- Route params as source of truth? If the URL says
/patients/123but the UI shows patient 456, it’s glaringly wrong. - Colocated side effects? If the cart sidebar fetches cart data, and the cart sidebar is closed, no request fires — and you see it in the network tab.
- Navigation last? If analytics fail, the error throws in the handler — not silently in a cleanup function.
- Explicit cleanup? If a WebSocket stays open, you see it in Chrome’s “Connections” tab — not buried in memory snapshots.
That’s the real win: debuggability by design.
You don’t need a framework to make React “enterprise-ready.” You need discipline. You need to respect the contracts it gives you. You need to admit when you’ve broken them — and fix them with simple, human-readable code.
Start there. Everything else follows.
---
(Word count: 4,820)