Mastering React Development: Best Practices and Tips

It’s 3:14 AM. The PagerDuty alert has been screaming for twenty minutes, and I’ve just found the useMemo that killed our conversion rate.

My eyes feel like they’ve been rubbed with sandpaper. The “War Room” smells of stale espresso and the collective failure of a team that thinks “react development” is just about stacking Lego bricks until something looks like a UI. It isn’t. It’s about managing state transitions in a way that doesn’t make the V8 engine want to commit ritual suicide.

We’re running Node v20.11.0 on the backend and React v18.3.1 on the frontend. Or we were, until the heap limit hit the ceiling and the pods started crashing in a recursive loop of misery.

[INCIDENT-882] The Infinite Re-render Loop in the SSR Pipeline

The first sign of trouble wasn’t a bug report. It was the Prometheus dashboard for the edge nodes looking like a sheer cliff face. CPU usage spiked to 100% across the entire cluster in three minutes.

<--- Last few GCs --->
[42:0x7f8c1c000000]   104205 ms: Mark-sweep 2034.2 (2080.1) -> 2033.1 (2082.6) MB, 1240.2 / 0.0 ms  (average mu = 0.142, current mu = 0.002) allocation failure; scavenge might not succeed
[42:0x7f8c1c000000]   105612 ms: Mark-sweep 2035.4 (2082.6) -> 2034.8 (2085.1) MB, 1401.8 / 0.0 ms  (average mu = 0.081, current mu = 0.001) allocation failure; scavenge might not succeed

<--- JS stacktrace --->
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
 1: 0x10b1a2c64 node::Abort() (.cold.1) [/usr/local/bin/node]
 2: 0x10a2345d0 node::Abort() [/usr/local/bin/node]
 3: 0x10a234748 node::OnFatalError(char const*, char const*) [/usr/local/bin/node]
 4: 0x10a3c9e10 v8::Utils::ApiCheck(bool, char const*, char const*) [/usr/local/bin/node]
 5: 0x10ab7a32c v8::internal::Heap::FatalProcessOutOfMemory(char const*) [/usr/local/bin/node]

The junior developer—let’s call him “The Architect of Chaos”—decided that the best way to handle a global filter state was to put a massive object inside a useMemo hook, but he forgot how reference equality works in JavaScript. He passed a new object literal as a dependency to another hook, which triggered a state update, which triggered the useMemo, which triggered the state update.

On the client, this just makes the fan spin. On the server, during the initial render of our Next.js pages, it creates a blocking execution context that never yields. The garbage collector (GC) tries to keep up, but it’s fighting a losing battle against a closure that refuses to die.

[TECH-DEBT-004] The Fallacy of “Simple” React Development

People think “react development” is easy because you can write HTML-ish code in JavaScript. That is a lie. React is a complex scheduling engine that happens to produce DOM nodes. When you ignore the underlying mechanics of the Fiber architecture, you aren’t writing code; you’re setting traps for your future self.

The junior’s code looked like this:

// The crime scene
const FilterProvider = ({ children }) => {
  const [filters, setFilters] = useState({ category: 'all', tags: [] });

  // This is where the nightmare begins
  const memoizedValue = useMemo(() => {
    return {
      ...filters,
      timestamp: Date.now() // Why? "For debugging," he said.
    };
  }, [filters]); 

  return (
    <FilterContext.Provider value={memoizedValue}>
      {children}
    </FilterContext.Provider>
  );
};

By adding Date.now() inside the useMemo, he ensured that every single time React checked if the value had changed, it would technically be “new” if the re-render took more than a millisecond. But wait, it gets worse. Downstream, a useEffect was listening to memoizedValue.

useEffect(() => {
  // Fetching data based on filters
  fetchData(memoizedValue);
}, [memoizedValue]); // memoizedValue is new every time because of Date.now()

This isn’t just a bug. This is a denial-of-service attack from inside the house. In React 18.3.1, the reconciliation algorithm is aggressive. It wants to finish the work loop as fast as possible. When you feed it an infinite loop of state updates, it will gladly consume every cycle your CPU offers until the OS kills the process.

[PERF-99] Memory Leaks and the V8 Garbage Collector’s Suicide Note

Let’s talk about memory. In modern “react development”, we treat RAM like it’s an infinite resource. It isn’t. Every time a component renders, a new set of closures is created. If those closures are captured by a long-running effect or a stale reference in a memoized object, the V8 engine cannot reclaim that memory.

I ran a heap snapshot during the outage. The results were disgusting.

Heap Snapshot Profile:
(results from Chrome DevTools / Node --inspect)

Distance | Objects | Shallow Size | Retained Size
---------|---------|--------------|--------------
0        | (root)  | 1.2 MB       | 2.04 GB
1        | Context | 450 KB       | 1.88 GB  <-- This is the FilterContext
2        | Array   | 800 MB       | 1.80 GB  <-- Closures holding onto old state
3        | Object  | 1.1 GB       | 1.1 GB   <-- Stale props from 10,000 renders

The Retained Size of the FilterContext was nearly 2GB. Why? Because the junior was also storing the entire product catalog in the state, and every “memoized” version of that state was being held in memory by the React Fiber tree because the effects hadn’t finished cleaning up.

React 18’s Concurrent Mode makes this even more dangerous. With features like useTransition and useDeferredValue, React can start rendering an update, realize it’s taking too long, pause it, and start a new render. If your hooks aren’t pure—if they have side effects or unstable dependencies—you end up with multiple versions of the “truth” floating around in memory. The garbage collector sees these as active references because they are part of a suspended fiber tree. You aren’t just leaking memory; you’re hoarding it.

[REFACTOR-LOG] Stripping the Abstractions: A Return to Sanity

I spent four hours stripping out the “clever” logic. We don’t need a useMemo for a simple object. We don’t need a useEffect to sync state that could be calculated during render.

Here is the “less-bad” version of the code. It’s not “vibrant.” It’s not “shaping the future.” It just works without crashing the server.

// Step 1: Remove the unstable dependency
const FilterProvider = ({ children }) => {
  const [filters, setFilters] = useState({ category: 'all', tags: [] });

  // If you need a timestamp, it shouldn't be part of the memoized state 
  // that triggers effects. Keep it local or use a Ref.
  const lastUpdated = useRef(Date.now());

  // Step 2: Use a stable object. 
  // Only memoize if the computation is actually expensive. 
  // Hint: Spreading an object is NOT expensive.
  const contextValue = useMemo(() => ({
    filters,
    updateFilters: (newFilters) => {
      lastUpdated.current = Date.now();
      setFilters(newFilters);
    }
  }), [filters]);

  return (
    <FilterContext.Provider value={contextValue}>
      {children}
    </FilterContext.Provider>
  );
};

But we had to go deeper. The real problem was the useEffect in the consumer components. In React 18, you should avoid useEffect for data fetching whenever possible. Use a library that handles caching properly, or use useSyncExternalStore if you’re dealing with a third-party state manager.

The junior had also decided to use a heavy component library that injected styles at runtime. Every time his infinite loop triggered a re-render, the library was re-calculating the entire CSS-in-JS tree and injecting <style> tags into the document head.

# Webpack Bundle Analyzer Output (Simplified)
Asset                                Size          Chunks
main.js                              1.2 MB        [emitted]  <-- This is too big
vendor.js                            4.5 MB        [emitted]  <-- 70% is just the UI library
styles.js                            800 KB        [emitted]

4.5 MB of vendor code. For a landing page. We are literally sending the equivalent of the original Doom game in JavaScript just to show a dropdown menu. This is the state of “react development” in 2024. We’ve traded performance for developer convenience, and we’re not even getting the convenience.

[ARCH-REVIEW] Concurrent Mode is Not a Silver Bullet

The junior tried to fix the lag by wrapping everything in startTransition. He thought it was a “make it fast” button.

// Junior's "fix"
const handleFilterChange = (e) => {
  startTransition(() => {
    setFilters({ ...filters, category: e.target.value });
  });
};

What he didn’t realize is that startTransition tells React that the update is low priority. That’s fine for the UI, but if you have a useEffect that depends on that state, the effect still has to run. If the update is interrupted by a higher-priority task (like a user typing), React might throw away the work and start over. If your effect has side effects that aren’t idempotent—like hitting an API endpoint without an AbortController—you’ve just created a race condition that will haunt your logs for months.

In React 18.3.1, you have to be surgical. You have to understand the difference between a “sync” update, a “transition” update, and the “deferred” values. You have to understand that useLayoutEffect will block the paint, which is exactly what you don’t want when you’re already struggling with a massive DOM tree.

We had to implement a strict linting rule to prevent the use of useEffect without a senior’s approval. That’s where we are now. We’re babysitting the code because the abstractions have become so leaky that the water is up to our necks.

[POST-MORTEM] Why Your Component Library is a Trojan Horse

We finally tracked down the final nail in the coffin: a “vibrant” (I hate that word) UI library that used a custom hook for media queries. This hook added a resize event listener to the window on every mount.

Because of the infinite re-render loop, the component was mounting and unmounting 500 times a second. The cleanup function in the useEffect was being called, but the event listener removal was failing because the junior had passed an anonymous function to window.removeEventListener.

// The leak
useEffect(() => {
  const handler = () => console.log('resized');
  window.addEventListener('resize', handler);
  return () => window.removeEventListener('resize', () => console.log('resized')); 
  // This doesn't work. It's a different function reference.
}, []);

The result? Thousands of orphaned event listeners clogging the main thread. The browser’s event loop was so busy trying to process these listeners that it couldn’t even handle the click event to close the tab. The only way out was kill -9.

$ ps aux | grep node
user    12345  140.2  15.4  4567890  2048000 ??  R    3:15AM   15:24.45 node server.js
$ kill -9 12345

This is the reality of “react development” when you don’t respect the platform. You’re not just writing a web app; you’re managing a distributed system where the nodes are the users’ browsers and your own SSR servers. If you don’t understand how the reconciliation algorithm works—how it uses a bitmask to track updates, how it prioritizes lanes, how it handles the “expiration time” of a render—then you are a liability.

The refactor took 48 hours. We replaced the complex state with a simple URL-based state management system. Why? Because the URL is the ultimate source of truth. It doesn’t need a useMemo. It doesn’t trigger a re-render loop if you handle it correctly. It’s stable, it’s serializable, and it’s been around since the 90s.

We also ditched the heavy component library for raw CSS modules. The bundle size dropped from 4.5 MB to 120 KB. The “Time to Interactive” (TTI) went from 8 seconds to 0.8 seconds. The conversion rate didn’t just recover; it doubled.

The junior asked me if we could “delve” into the new architecture tomorrow. I told him to go home. I told him to read the React source code—specifically the ReactFiberWorkLoop.js file—and not to come back until he can explain the difference between DiscreteEventPriority and ContinuousEventPriority.

I’m going to sleep now. My terminal is finally quiet. No more PagerDuty alerts. No more memory leaks. Just the cold, hard reality of a codebase that has been purged of its “vibrant” complexity.

Modern web development is a house of cards built on a foundation of shifting sand. If you want to survive, stop looking for “seamless” solutions and start looking at the heap dumps. The truth is in the memory allocation, not the marketing fluff.

If I see one more useEffect that doesn’t have a cleanup function, I’m quitting and becoming a carpenter. At least wood follows the laws of physics. JavaScript just follows the whims of whoever decided that a 2MB runtime was a good idea for a “lightweight” framework.

React v18.3.1 is a powerful tool, but in the hands of someone who doesn’t understand the cost of a closure, it’s a chainsaw without a guard. We’ve spent two days putting the guard back on.

Don’t talk to me about “shaping the future.” I’m just trying to make sure the “present” doesn’t crash before I finish my coffee.

The incident is closed. The manifesto is written. The refactor is merged. Now, get out of my War Room.

Related Articles

Explore more insights and best practices:

Leave a Comment