React Best Practices: Build Scalable Apps Faster

text
[2024-05-22T03:14:02.891Z] SEVERE: [MainThread] Uncaught RangeError: Maximum update depth exceeded.
at Object.updateContainer (react-dom.development.js:25331:7)
at Object.scheduleUpdateOnFiber (react-dom.development.js:21835:3)
at dispatchSetState (react-dom.development.js:15853:7)
at onAuthChange (AuthContext.tsx:42:5)
[2024-05-22T03:14:03.112Z] FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed – JavaScript heap out of memory
[2024-05-22T03:14:03.445Z] Process exited with code 137 (OOM)

My eyes are vibrating. Not from the four double-shots of espresso I’ve downed since Tuesday, but from the blue light of a 32-inch monitor that has been screaming "OOM" at me for three days. The office smells like ozone and despair. Somewhere in the distance, a janitor is buffing the floors, oblivious to the fact that our entire checkout pipeline just melted into a puddle of unhandled promises and infinite re-renders.

This wasn't a "bug." It was a massacre. A $50 million refactor, led by a "clever" junior developer who thought he knew **react best** practices because he watched a 15-minute YouTube video on "Clean Architecture." He decided to "decouple" our state. He decided to "abstract" our hooks. He decided to turn our codebase into a recursive nightmare that even the V8 engine couldn't optimize its way out of.

I’ve spent the last 72 hours gutting his "elegant" abstractions and replacing them with code that actually works. If you’re looking for a "vibrant" discussion on the "tapestry" of modern UI, leave. This is a post-mortem. This is how you actually write React when your company’s survival depends on it.

## The 3 AM Incident Report: The Infinite Re-render Loop

The PagerDuty alert hit at 3:14 AM. By 3:20 AM, our SREs were reporting that the frontend was consuming 4GB of RAM per tab before crashing. The culprit? A "clever" implementation of a global `AuthContext` that triggered a state update every time a user moved their mouse, because "we might need to track engagement metrics in real-time."

The junior—let's call him Kevin—had implemented a "reactive" observer pattern inside a `useEffect` that lacked a proper dependency array. He thought he was being "efficient" by not "hardcoding" dependencies.

**The Failure:**
```typescript
// Kevin's "Clever" Auth Provider
export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null);
  const [metrics, setMetrics] = useState({});

  // This "react best" practice according to Kevin
  useEffect(() => {
    const unsubscribe = api.onAuthStateChanged((u) => {
      setUser(u);
      // Logic that triggers a re-render on EVERY auth change
      // which in turn re-runs this effect because the 
      // anonymous function is recreated every render.
    });
    return unsubscribe;
  }); // Look Ma, no dependency array!

  return (
    <AuthContext.Provider value={{ user, metrics, setMetrics }}>
      {children}
    </AuthContext.Provider>
  );
};

Because the useEffect lacked a dependency array, it ran on every render. Because it called setUser, it triggered a re-render. Because it triggered a re-render, the useEffect ran again. Maximum update depth exceeded. The browser didn’t just hang; it choked.

The “react best” Fix:
We had to strip the “engagement tracking” out of the Auth provider entirely. State that changes frequently (like mouse position or metrics) should never live in the same context as state that is globally critical (like authentication).

// The Hardened Fix (React 18.3.1)
import { memo, useMemo, useCallback, useEffect, useState } from 'react';

export const AuthProvider = memo(({ children }: { children: React.ReactNode }) => {
  const [user, setUser] = useState<User | null>(null);
  const [status, setStatus] = useState<'loading' | 'auth' | 'unauth'>('loading');

  const handleAuthChange = useCallback((u: User | null) => {
    setUser(u);
    setStatus(u ? 'auth' : 'unauth');
  }, []);

  useEffect(() => {
    let isMounted = true;
    const unsubscribe = api.onAuthStateChanged((u) => {
      if (isMounted) {
        handleAuthChange(u);
      }
    });

    return () => {
      isMounted = false;
      unsubscribe();
    };
  }, [handleAuthChange]);

  const value = useMemo(() => ({ user, status }), [user, status]);

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

We added isMounted flags to prevent state updates on unmounted components—a classic memory leak source. We used useMemo for the context value to prevent every consumer from re-rendering unless the user or status actually changed. This is survival, not “clean code.”

The “Prop Drilling” Graveyard: Why Our State Management Killed the LCP

Kevin hated Redux. He said it was “boilerplate-heavy.” So he decided to use the Context API for everything. By the time I looked at the component tree, we had 24 nested Providers. Every time a user typed a single character in a search bar, the entire application tree—from the navigation bar to the footer—was being diffed by React.

Our Largest Contentful Paint (LCP) went from 1.2s to 6.5s. On a mobile device over 4G, the app was unusable. The “react best” approach isn’t to use Context for everything; it’s to use the right tool for the frequency of the update.

The Failure:
Kevin had a GlobalStateContext that held:
1. User profile data (Static-ish)
2. Shopping cart items (Frequent)
3. Search input text (High frequency)
4. Theme settings (Static)
5. Real-time websocket notifications (Extreme frequency)

Every time a notification came in via the websocket, the SearchInput component would re-render. Why? Because it was consuming the GlobalStateContext, and React Context does not have a built-in way to “select” only a portion of the state. If the value object changes, every consumer re-renders. Period.

The “react best” Fix:
We moved high-frequency state to Zustand. It’s small, it’s fast, and it allows for selector-based subscriptions that don’t trigger the “Context Hell” re-render loop.

// zustand/store.ts
import { create } from 'zustand';

interface AppState {
  searchQuery: string;
  setSearchQuery: (query: string) => void;
  notifications: Notification[];
  addNotification: (n: Notification) => void;
}

export const useStore = create<AppState>((set) => ({
  searchQuery: '',
  notifications: [],
  setSearchQuery: (query) => set({ searchQuery: query }),
  addNotification: (n) => set((state) => ({ 
    notifications: [...state.notifications, n] 
  })),
}));

// In the component:
const SearchInput = () => {
  // ONLY re-renders when searchQuery changes.
  const searchQuery = useStore((state) => state.searchQuery);
  const setSearchQuery = useStore((state) => state.setSearchQuery);

  return (
    <input 
      value={searchQuery} 
      onChange={(e) => setSearchQuery(e.target.value)} 
      placeholder="Search..."
    />
  );
};

Stop using Context for high-frequency updates. It’s not what it was designed for. If you’re building a $50M refactor, don’t use a hammer to perform heart surgery.

useEffect is Not a Lifecycle Hook: Tearing Down the Misuse of Synchronization

If I see one more useEffect used to derive state, I’m going to throw my MacBook into the Hudson River. Kevin had this habit of “syncing” state variables. He’d have firstName and lastName in state, and then a useEffect to update fullName.

The Failure:

const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');

useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

This is a disaster. It triggers an extra render cycle.
1. User types in firstName.
2. Component renders with new firstName.
3. useEffect runs.
4. setFullName is called.
5. Component renders again with new fullName.

In a complex form with 30 fields, this “pattern” creates a laggy, stuttering UI that makes the user feel like they’re typing through molasses.

The “react best” Fix:
Calculate values during the render phase. If the calculation is expensive, use useMemo. If it’s just string concatenation, just do it.

const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');

// No extra render. No useEffect. No bullshit.
const fullName = `${firstName} ${lastName}`; 

// If it were actually expensive:
const expensiveValue = useMemo(() => {
  return performHeavyCalculation(firstName, lastName);
}, [firstName, lastName]);

useEffect is for synchronizing with external systems (APIs, WebSockets, manual DOM manipulation). It is not a place to hide your business logic. We spent 12 hours just removing redundant useEffect calls that were causing “flickering” in the UI where old data would show for one frame before the effect kicked in.

The Memoization Myth: When useMemo Actually Made Our App Slower

Kevin went on a “performance optimization” spree. He wrapped every single component in memo() and every function in useCallback(). He thought he was being a “react best” practitioner. He was actually just adding overhead to the garbage collector.

Memoization isn’t free. You’re trading memory and CPU cycles (to compare dependency arrays) for the avoidance of a render. If the render is cheap (like a button or a small text component), the comparison is often more expensive than the render itself.

The Failure:

const MyButton = memo(({ onClick, label }) => {
  console.log("Button render");
  return <button onClick={onClick}>{label}</button>;
});

const Parent = () => {
  const handleClick = useCallback(() => {
    console.log("Clicked");
  }, []); // Memoizing a function that does nothing

  return <MyButton onClick={handleClick} label="Click Me" />;
};

In this case, MyButton is so simple that the React Fiber reconciler would have handled it in microseconds. By adding memo and useCallback, we forced React to:
1. Store the function in memory across renders.
2. Run a shallow comparison on the props object every single time Parent renders.
3. Check the dependency array of useCallback.

When Kevin did this to 400 components in our dashboard, the “dependency check” overhead became a measurable bottleneck in our flame graphs.

The “react best” Fix:
We removed memo from 90% of the components. We only kept it for:
1. Components that render massive lists (100+ items).
2. Components that perform heavy SVG or Canvas rendering.
3. Components that are forced to re-render by a parent they have no relation to.

Rule of thumb: Don’t optimize until you’ve measured. Use the React DevTools Profiler. If a component renders in under 1ms, leave it the hell alone.

Fetching Follies and the “react best” Way to Handle Server State

The “clever” refactor included a custom-built data fetching hook. Kevin didn’t want to “bloat” our bundle with TanStack Query (React Query). So he wrote his own. It didn’t handle cache invalidation. It didn’t handle race conditions. It didn’t handle request cancellation.

The Failure:

// Kevin's "Lightweight" Fetcher
const useData = (url) => {
  const [data, setData] = useState(null);
  useEffect(() => {
    fetch(url).then(res => res.json()).then(setData);
  }, [url]);
  return data;
};

Here’s what happened: A user clicked “Profile,” then immediately clicked “Settings.” Two fetch requests were fired. The “Settings” request finished first. Then the “Profile” request finished and called setData. The user was looking at the Settings page but seeing Profile data. Race condition.

The “react best” Fix:
We deleted 2,000 lines of Kevin’s “custom hooks” and installed @tanstack/react-query. It’s not “bloat”; it’s the infrastructure required to run a professional application.

import { useQuery } from '@tanstack/react-query';

const fetchUser = async (id: string) => {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) throw new Error('Network response was not ok');
  return response.json();
};

export const UserProfile = ({ userId }: { userId: string }) => {
  const { data, error, isLoading } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    staleTime: 1000 * 60 * 5, // 5 minutes
    retry: 3,
  });

  if (isLoading) return <LoadingSpinner />;
  if (error) return <ErrorMessage error={error} />;

  return <div>{data.name}</div>;
};

React Query handles the race conditions. It handles the loading states. It handles the caching. It handles the “I closed the tab so stop the request” logic via AbortController. This is how you prevent $50M outages. You don’t reinvent the wheel when the wheel is already optimized for Formula 1.

The Final Deployment Script: The Hardened Configuration

After 72 hours, we finally pushed the fix. We didn’t just fix the code; we fixed the environment. We locked down the versions. No more ^ or ~ in the package.json that allows minor versions to break our build in CI.

The Hardened package.json:

{
  "name": "enterprise-checkout-system",
  "version": "2.4.1",
  "private": true,
  "dependencies": {
    "react": "18.3.1",
    "react-dom": "18.3.1",
    "@tanstack/react-query": "5.28.0",
    "zustand": "4.5.2",
    "zod": "3.22.4",
    "lucide-react": "0.363.0",
    "clsx": "2.1.0",
    "tailwind-merge": "2.2.2"
  },
  "devDependencies": {
    "@types/react": "18.2.66",
    "@types/react-dom": "18.2.22",
    "@vitejs/plugin-react": "4.2.1",
    "typescript": "5.4.2",
    "vite": "5.4.0",
    "eslint": "8.57.0"
  },
  "engines": {
    "node": ">=20.11.0"
  }
}

We also implemented a strict vite.config.ts that forces the browser to clear old assets and ensures that our chunks are optimized for HTTP/2.

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  build: {
    target: 'esnext',
    minify: 'terser',
    cssCodeSplit: true,
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ['react', 'react-dom', 'zustand'],
          utils: ['zod', 'clsx', 'tailwind-merge'],
        },
      },
    },
  },
  server: {
    headers: {
      'Cache-Control': 'no-store',
    },
  },
});

The Aftermath:
The CPU usage on our production pods dropped by 70%. The memory leak is gone. The “Maximum update depth exceeded” error is a ghost of the past. Kevin is currently “re-evaluating his career goals” in a meeting with HR, and I am going to sleep for the next 48 hours.

But let’s be real. In six months, some new framework will come out. Some new “influencer” will post a thread about how “React is dead” and we should all move to a signal-based, server-component-only, zero-JS-on-the-client monstrosity. And some other junior will read it, get “clever,” and I’ll be right back here, drinking stale coffee at 3 AM, fixing another $50M mistake.

React best practices aren’t about following the latest trend. They are about understanding the underlying engine. They are about knowing that every line of code you write has a cost—in memory, in CPU, and in human sanity.

If you want to write “clean code,” go write a blog post. If you want to write production code, keep it simple, keep it measurable, and for the love of God, put a dependency array on your effects.

Sign-off,
Senior Lead Engineer (Current Caffeine Level: Lethal)

Leave a Comment