React is fast until it suddenly isn't
You've probably spent an afternoon tracking down why a modal resets its input fields every time you hover over a parent component. That's not a bug. That's React doing exactly what you asked it to do, and your component tree was structured in a way that made it re-mount whenever the parent considered re-rendering. A Troubleshooting Guide For React starts with understanding that most symptoms point back to three things: unstable keys, misunderstood dependencies, and state in the wrong place. When React walks the virtual DOM, it uses keys to decide whether to reuse an existing component or tear it down and create a new one. If your key changes between renders, React destroys the component tree rooted at that element and remounts it. That means all local state disappears. Input fields reset. Scroll position is lost. Timers are recreated. This is usually what people call a "bug" but is actually just React being honest about what you told it. I ran into this building a dashboard widget list where items could be reordered by dragging. Someone had used array index as the key on draggable list items. Every drag operation re-sorted the array, which re-assigned indices, which made React think the components had changed. Input fields inside those widgets would snap back to empty on every drag event. The fix was straightforward: generate a persistent UUID when each widget is created and use that as the key. It eliminated the issue entirely because the keys stayed stable regardless of reorder operations.
useEffect dependencies are not optional
The dependency array on useEffect is not a suggestion. Omitting a value that your effect reads will cause the effect to run with stale data. React gives you a warning about this in development mode. The development lint rule is more useful than most people realize because it catches the exact bugs that slip into production. The counter-intuitive part is that adding too many dependencies is also a problem. When your effect depends on an object or function defined in the component body, that object is recreated on every render, which makes the effect run constantly. The solution is not to remove the dependency. It is to stabilize it. Wrap the object in useMemo. Wrap the function in useCallback. Move the function outside the component if it doesn't need to close over anything. I spent two days debugging an API call that fired four times on every keystroke in a search input. The effect depended on a filter object built inline in the component. That object was a new reference every render. Moving the filter construction into the effect itself and using the functional updater form for state reduced the calls from four to one per search action.
Stale state happens when you reach outside the updater
This is the mistake that trips up most people who just learned hooks. Calling setState inside a closure that captured an old value produces stale state. Event handlers, setTimeout callbacks, and Promise chains all create closures. React batches synchronous state updates, which means multiple setState calls in a row see the same previous state unless you use the functional form. Instead of this: setText(text + char), use this: setText(prev => prev + char). The functional form guarantees you are working with the current state, not whatever state existed when the closure was created. The same principle applies to useRef when you need the latest value inside a timer or event listener. useRef holds a mutable value that doesn't trigger re-renders, which is exactly what you want for sharing state across asynchronous boundaries without stale closures.
Get the Full Details

useMemo is not a performance optimization by default
Most developers add useMemo to avoid unnecessary computations. That is valid, but the more important reason to use it is to stabilize object and function references for child components that do shallow comparison. React.memo checks referential equality, not structural equality. Two objects that look identical but are different instances will cause a memoized child to re-render anyway. If you have a list component wrapped in React.memo and the parent passes a new object on every render, React.memo does nothing. The component still re-renders. Stabilizing that prop with useMemo fixes the symptom. But the deeper fix is often to move the object creation into the child or to pass the primitive values separately instead of wrapping them in an object. The tradeoff is that useMemo itself has a cost. It stores the cached value and runs the computation on every render to check if dependencies changed. For trivial computations, the memo costs more than the computation. A good rule of thumb is to only memoize when the computation or the referential stability genuinely matters for something downstream.
Memory leaks in long-running applications
useEffect cleanup functions are not optional housekeeping. They prevent memory leaks when subscriptions, event listeners, or async operations outlive the component. I worked on a React application that tracked WebSocket connections inside effects without cleanup. Each time the route changed and the component unmounted, the WebSocket stayed open. The server kept sending messages. The callback was trying to update state on an unmounted component. React would throw a warning about setting state on an unmounted component, which is harmless but indicates a leak. The real cost showed up as growing memory usage and increasing network traffic from duplicate subscriptions. The fix is a cleanup function that unsubscribes or cancels the operation. AbortController is now the standard pattern for fetch cancellation. For custom subscriptions, store the subscription handle and unsubscribe in the cleanup. If you need to guard against unmounted state updates, keep a boolean ref that the effect checks before calling setState.
React.StrictMode is not just for development
StrictMode double-invokes effects and constructors in development. This is not a bug. It is designed to surface effects that are not properly cleaned up and to warn about legacy lifecycle patterns. Some people disable it because it makes debugging harder or slows down the dev server. That is the wrong decision. The double invocation catches issues that would otherwise remain hidden until production. If you encounter tests that fail under StrictMode, investigate the underlying problem instead of removing StrictMode. The test failure is usually telling you that your component has side effects that are not properly isolated or cleaned up.

Advanced patterns that save hours of debugging
When to use useReducer instead of useState
useState is fine for simple counters and toggles. Once your state logic involves multiple related values or complex transitions, useReducer reduces the chance of inconsistent state. It also centralizes the transition logic so you can reason about it separately from the component tree. The dispatch function is stable across renders, which eliminates one class of dependency-related bugs entirely. The limitation is that useReducer adds boilerplate. You need actions, a reducer function, and initial state. For small components, that is overkill. For anything with interdependent state fields or conditional update logic, it pays off quickly.
Batching updates and when they break
React 18 automatically batches state updates inside promises, timeouts, and native event handlers. Before React 18, batching only happened inside React event handlers. This changed caused some applications to behave differently after upgrading because updates that were previously separate were now batched together. If you need to force an immediate synchronous update, flushSync from react-dom/flushSync will work, but it should be rare. Batching is almost always the right behavior. The practical implication is that if you upgrade React and notice different timing in your tests, check whether an expectation relied on unbatched updates. Adjusting the test to wait for the batched result is usually the correct fix.
Bundle size and code splitting
React.lazy with Suspense is the standard way to split bundles. The overhead is negligible if done correctly. The common mistake is lazy-loading too aggressively or not handling loading states properly, which produces flicker or broken UI. Another mistake is lazy-loading a component that is rendered on the initial page because the browser already has to wait for the chunk anyway. The sweet spot is routes, heavy dialogs, and features that are not immediately needed. Above all, check your bundle before and after lazy loading. The React DevTools Profiler package can show you which components are expensive to render and which parts of the tree are re-rendering unnecessarily. It is more useful than most developers realize because it surfaces actual render counts instead of guesses.
