The app loaded in 180 milliseconds on Friday. By Monday morning, the same page took eleven seconds.
Nothing had changed in production. No new deployments. No infrastructure issues. No traffic spikes. Just a single developer, me, adding what seemed like an innocuous React hook to track user interactions.
One useEffect. Seventeen lines of code. And suddenly our entire application was unusable.
This is the story of how I learned that React's mental model and actual performance characteristics are two completely different things. And how the abstraction that makes React easy to use is the same abstraction that makes it easy to accidentally destroy performance at scale.
The Innocent Addition
We were building an analytics feature. Simple requirement: track when users viewed certain components. Marketing wanted to know which features got the most attention. Product wanted engagement metrics. Engineering wanted to ship and move on.
I added a custom hook called useViewTracking. Clean, reusable, following all the React best practices I'd learned:
function useViewTracking(componentId) {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
setIsVisible(entry.isIntersecting);
});
const element = document.getElementById(componentId);
if (element) observer.observe(element);
return () => observer.disconnect();
}, [componentId]);
useEffect(() => {
if (isVisible) {
trackEvent('component_viewed', { componentId });
}
}, [isVisible, componentId]);
}
Looks reasonable, right? I thought so too. The code reviews passed. Tests passed. It worked perfectly in development.
Then I dropped this hook into our dashboard component. The dashboard rendered a list of cards—anywhere from 50 to 200 items depending on the user. Each card called useViewTracking to log when it entered the viewport.
The first user to hit this new version of the dashboard waited eleven seconds for the page to render. Then filed a bug report. Then another user. Then another.
What I Missed
React hooks are elegant. They let you compose behavior, share logic, and write functional components that feel clean and declarative. But that elegance hides computational cost that isn't obvious until it explodes in production.
Every time my hook ran, it created a new IntersectionObserver. In a list of 200 cards, that meant 200 separate observers watching 200 separate DOM elements. Each observer triggered state updates. Each state update triggered re-renders. Each re-render created new observers.
I had created an exponential performance cascade hidden inside what looked like simple, idiomatic React code.
The mental model React teaches you is: "Components are functions. State updates trigger renders. Effects run after renders. Trust the framework."
What it doesn't teach you: "Every hook call has cost. Every state update ripples through the tree. Every effect creates overhead. And when you multiply this by hundreds of components, the framework can't save you."
The Rendering Trap
React's reconciliation algorithm is optimized for frequent, small updates. It's designed around the idea that most changes affect small parts of the tree. When you violate this assumption—when you create patterns that cause widespread re-renders—performance collapses in ways that aren't immediately obvious.
My hook violated this in three ways:
First, every visibility change triggered a state update, which triggered a re-render, which ran all effects again, which created new observers. The cleanup ran, but not fast enough to prevent the cascade.
Second, the hook was called in child components, but those state updates bubbled context through the entire parent tree. React had to diff the entire dashboard component and all its children every time a single card became visible.
Third, I was creating 200 IntersectionObservers when one would suffice. Each observer maintained its own callback, its own state, its own connection to the browser's layout engine.
The browser's developer tools showed the problem clearly once I knew what to look for: thousands of layout recalculations per second, massive memory allocation for observer callbacks, and React spending more time reconciling than rendering.
What Actually Works
The fix wasn't better React code. It was questioning whether React's patterns were the right tool for this problem.
Instead of a hook per component, I created a single global IntersectionObserver managed outside React's lifecycle:
class ViewTracker {
constructor() {
this.observer = new IntersectionObserver(this.handleIntersection);
this.tracked = new Map();
}
track(element, componentId) {
this.tracked.set(element, componentId);
this.observer.observe(element);
}
untrack(element) {
this.tracked.delete(element);
this.observer.unobserve(element);
}
handleIntersection = (entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const id = this.tracked.get(entry.target);
trackEvent('component_viewed', { componentId: id });
}
});
};
}
const tracker = new ViewTracker();
Then I used a simple ref-based hook that didn't trigger renders:
function useViewTracking(componentId) {
const ref = useRef(null);
useEffect(() => {
const element = ref.current;
if (element) {
tracker.track(element, componentId);
return () => tracker.untrack(element);
}
}, [componentId]);
return ref;
}
Load time dropped from eleven seconds to 190 milliseconds. Memory usage decreased by 80%. The user experience went from broken to invisible.
The code was less "React-idiomatic" but infinitely more performant. Sometimes the framework's best practices aren't best for your use case.
The Pattern That Keeps Breaking Apps
This isn't unique to my analytics hook. I see the same pattern everywhere:
State updates in loops. Developers map over arrays and trigger state updates for each item. React has to reconcile every update, even if they could be batched.
Effects without dependencies. Hooks that run on every render because developers didn't understand the dependency array, creating infinite update cycles.
Context overuse. Wrapping entire apps in context providers, then wondering why unrelated components re-render when context values change.
Memo misuse. Aggressively memoizing everything thinking it helps, not realizing that memo itself has cost and often prevents optimizations React could make naturally.
Custom hooks that hide complexity. Beautiful, reusable hooks that abstract away performance problems until you use them at scale.
The React documentation teaches patterns that work for small apps. It doesn't prepare you for what happens when those patterns hit production scale.
What the Framework Hides
Modern frameworks sell you on developer experience. Write declarative code. Let the framework handle the hard parts. Trust the abstractions.
But abstractions leak. And in React, they leak performance.
When you write useState, you're not just declaring a variable. You're registering that component with React's state management system, creating subscription relationships, and setting up re-render triggers.
When you write useEffect, you're not just running side effects. You're creating lifecycle hooks that React must track, schedule, and execute in specific order relative to rendering.
When you create custom hooks, you're not just extracting logic. You're composing these state registrations and effect subscriptions in ways that multiply their cost.
The framework makes it easy to write code that works. It doesn't make it easy to write code that performs.
The Tools That Actually Help
When performance collapses, you need visibility into what React is actually doing. The browser's performance profiler shows symptoms. React DevTools shows the cause.
I use tools that help me understand code behavior at a system level, not just at a component level. When debugging performance, I need to see the entire render tree, track state updates across components, and understand how my hooks compose.
For complex logic, I'll sometimes use AI assistants to analyze patterns in my code that might cause performance issues. Not to write the code for me, but to spot patterns I've stopped noticing because they've become habitual.
The Gemini 2.5 Flash model is particularly good at identifying anti-patterns in React code when you give it context about your component structure and ask it to spot potential performance bottlenecks.
But the most valuable tool is changing how you think about React. Stop trusting the framework blindly. Start questioning whether its patterns serve your use case.
The Real Lessons
Lesson one: Hooks have cost. Every useState and useEffect adds overhead. When you multiply that across hundreds of components, the cost compounds. Design accordingly.
Lesson two: React's reconciliation is optimized for specific patterns. Frequent small updates in isolated components work well. Widespread state changes that ripple through large trees don't. Know which pattern your code creates.
Lesson three: Idiomatic code isn't always performant code. The React way isn't always the right way. Sometimes you need to break the abstraction and use refs, vanilla JavaScript, or patterns the documentation doesn't recommend.
Lesson four: Developer experience and user experience aren't the same. Code that's pleasant to write can create terrible user experiences. Optimize for the user, not for the developer.
Lesson five: Performance problems hide in abstraction. Custom hooks, higher-order components, and context providers all abstract away complexity. That complexity doesn't disappear—it just becomes invisible until it breaks things.
What You Should Check Today
Open your codebase. Look for these patterns:
Hooks in loops or maps. Every array item that calls a hook creates separate state management overhead. Consider whether that's necessary.
State updates that could be batched. React 18 helps with automatic batching, but you can still create patterns that bypass it. Consolidate related state updates.
Effects without cleanup. If your effect creates subscriptions, observers, or timers, it needs cleanup. Missing cleanup creates memory leaks that compound over renders.
Context that changes frequently. Context is convenient but expensive. Every context update re-renders every consumer. Consider whether state lifting or props would be more efficient.
Custom hooks you haven't profiled. Your beautiful reusable hooks might be performance disasters waiting to happen. Profile them under realistic load before using them widely.
Use platforms like Crompt AI to compare different approaches to the same problem. Sometimes the best way to spot performance issues is to see multiple solutions side-by-side and understand their tradeoffs.
The Uncomfortable Reality
React makes it easy to build UIs. It doesn't make it easy to build performant UIs.
The mental models the framework teaches—components as functions, render as pure computation, state as automatic updates—these work until they don't. And when they don't, you need to understand what's actually happening under the abstraction.
Every hook call has cost. Every state update triggers work. Every effect creates overhead. At small scale, this doesn't matter. At production scale, it's the difference between apps that feel instant and apps that feel broken.
The developers who build fast React apps aren't the ones who know the most hooks. They're the ones who know when not to use them.
Your framework will lie to you. It will make bad patterns look reasonable. It will hide performance problems until they explode in production.
The question is whether you'll catch them before your users file bug reports.
-ROHIT
Top comments (0)