10 React Best Practices for Writing Cleaner Code in 2024

React is Not a Framework, It’s a Debt Generator (And How to Stop the Bleeding)

Three years ago, I was paged at 2:14 AM on a Tuesday. Our main logistics dashboard, the one the warehouse teams use to track every single shipment across the Atlantic, had turned into a space heater. The browser was consuming 6.2GB of RAM. The CPU was pegged at 100%. The culprit? A “senior” frontend lead had decided that the react best way to handle real-time updates was to shove a WebSocket stream directly into a global Redux store without any throttling or memoization. Every time a package moved an inch in a warehouse in Rotterdam, every single component on the dashboard re-rendered. The DOM couldn’t keep up. The browser’s main thread was locked in a death spiral of reconciliation. We had to kill the service worker and force a cache-bust just to get the warehouse back online. It wasn’t a backend failure. It wasn’t a database deadlock. It was just bad React.

Most React tutorials are written by people who build Todo apps. They don’t build systems that stay alive for five years under heavy load. They talk about “clean code” while ignoring the fact that their useEffect hooks are essentially infinite loops waiting to happen. If you want to build something that doesn’t require an SRE to wake up in the middle of the night, you need to stop following the “standard” advice and start looking at the actual cost of your abstractions.

The Documentation is Lying to You

React’s official documentation is great for getting started, but it’s dangerously optimistic. It presents hooks as this magical way to handle state, but it rarely discusses the “Zombie Child” problem or the performance tax of the Context API. Most developers treat useContext as a replacement for Redux. It isn’t. Context is a dependency injection tool, not a state management engine. When you update a value in a Context provider, every single consumer re-renders. Period. There is no built-in selector logic to bail out of updates. If you put your entire app state in one giant AppContext, you’ve just built a slower version of 1990s-era global variables.

Pro-tip: If you find yourself nesting more than three Context providers, you’ve officially entered “Provider Hell.” Your component tree is now a brittle mess that is impossible to unit test without mocking half the universe.

The industry is obsessed with “clean” looking code, but in React, “clean” often means “hidden side effects.” I’ve seen developers replace a 10-line fetch call with a 500-line custom hook abstraction that makes it impossible to track where the data is actually coming from. We need to get back to basics: predictable data flow and explicit dependencies.

State Management: Stop the Global Bloat

The react best practice for state is simple: keep it as local as humanly possible. If a piece of state is only used by two components, don’t put it in a store. Pass the props. Yes, prop drilling is annoying, but it’s explicit. You can look at a component and see exactly what it needs to function. When you hide everything behind a useSelector or a useContext, you’re making the component a black box that’s tied to a specific environment.

  • Local State: Use useState for UI toggles, form inputs, and anything that doesn’t need to survive a page refresh.
  • Server State: Stop using useEffect to fetch data. Use TanStack Query (formerly React Query). It handles caching, de-duplication, and loading states out of the box.
  • Global State: If you actually have global data (like user auth or theme), use Zustand. It’s 1KB, has no boilerplate, and doesn’t suffer from the Context re-render trap.
  • URL State: The URL is the most underutilized state manager. Use search params for filters, pagination, and tabs. It makes the “Back” button actually work.
// Bad: Putting everything in a global store
const { filter } = useSelector(state => state.dashboard);

// Good: Using the URL as the source of truth
const [searchParams, setSearchParams] = useSearchParams();
const filter = searchParams.get('status') || 'all';

const updateFilter = (newStatus) => {
  setSearchParams({ status: newStatus });
};

I once saw a team spend two weeks debugging why a search filter wasn’t clearing when the user clicked the logo to go home. It was because the filter was stuck in a Redux store that persisted across routes. If they had used the URL, the problem wouldn’t have existed. The state would have vanished the moment the route changed. That’s the difference between “clever” code and resilient code.

The useEffect Footgun

If I could delete one thing from the React API, it would be the useEffect hook. It is the source of 90% of the bugs I see in production. Developers use it as a catch-all for “do something when this changes,” but they forget that React is a declarative UI library, not an imperative event emitter. When you use useEffect to sync two pieces of state, you’re creating a “split brain” problem where your UI can get out of sync with your data.

Consider this nightmare I found in a checkout flow on api.stripe.com integration code:

useEffect(() => {
  if (cartItems.length > 0) {
    setTotal(cartItems.reduce((acc, item) => acc + item.price, 0));
  }
}, [cartItems]);

This is redundant. You don’t need an effect to calculate the total. You can just calculate it during the render. By using an effect, you’re forcing React to render once with the old total, then immediately render again with the new total. On a slow mobile device, the user will see the price flicker. Worse, if you have other effects listening to total, you’ve just started a chain reaction of re-renders that can easily lead to an OOM-kill on lower-end Android phones.

The Rule: If you can calculate something during render, do it. If you need to handle an event (like a click or a form submission), put that logic in the event handler, not an effect. Effects should only be used to synchronize your component with external systems—like a WebSocket, a canvas element, or a legacy jQuery plugin that your boss won’t let you delete.

Performance: The Myth of useMemo

I see people wrapping every single function in useCallback and every object in useMemo. They think they’re being “performant.” They aren’t. They’re just adding overhead to the initial render. React is incredibly fast at comparing virtual DOM trees. The overhead of creating a memoized function and checking its dependency array is often higher than just re-creating the function.

Don’t optimize until you have a trace. Open the Chrome DevTools, go to the “Performance” tab, and look for the long yellow bars. If a component is taking 100ms to render, then sure, memoize the expensive calculation. But if it’s taking 2ms, leave it alone. You’re making the code harder to read for zero gain.

Note to self: Stop trying to fix re-renders by adding React.memo to every component. Fix the re-renders by moving the state lower down the tree so the parent doesn’t update in the first place.

Folder Structure: Features Over Types

If your project looks like this, you’ve already lost:

src/
  components/
  hooks/
  utils/
  pages/

This is “Type-based” organization, and it’s a nightmare for maintainability. When I’m working on the “Billing” feature, I don’t want to jump between four different top-level folders to find the logic. I want everything related to billing in one place. The react best way to organize a large-scale app is “Feature-based” architecture.

src/
  features/
    billing/
      components/
      hooks/
      api/
      types.ts
      index.ts
    auth/
    shipping/
  shared/
    components/
    ui/

This structure allows you to treat features as mini-applications. You can even enforce boundaries using ESLint rules to prevent features/auth from importing things from features/billing. This prevents the “Spaghetti Dependency” mess where changing a button in the login flow somehow breaks the checkout page. I’ve seen YAML-hell in K8s configs that was easier to parse than a poorly structured React src folder.

The “Z-Index Hell” and Portals

Every SRE has a story about a “Critical Alert” modal that was hidden behind a sidebar because of a z-index conflict. CSS in React is still a mess, whether you use Tailwind, Styled Components, or CSS Modules. The problem isn’t the styling; it’s the DOM hierarchy. If you have a modal nested deep inside a div with overflow: hidden, your modal is going to get clipped. No amount of z-index: 999999 will save you.

Use createPortal. Always. Modals, tooltips, and dropdowns should be rendered at the root of the document, outside of the main app hierarchy. This avoids stacking context issues and makes your UI significantly more predictable.

// The right way to do a Modal
import { createPortal } from 'react-dom';

const Modal = ({ children }) => {
  return createPortal(
    <div className="modal-overlay">
      {children}
    </div>,
    document.getElementById('modal-root')
  );
};

Testing: Stop Testing Implementation Details

I’ve seen test suites with 95% coverage that still allow critical bugs into production. Why? Because the tests are checking how the code works, not what it does. If your test checks that a specific state variable changes from false to true, your test is useless. The user doesn’t care about your state variables. They care if the button works.

  • Don’t: Use Enzyme to shallow-render components and inspect their internal state.
  • Do: Use React Testing Library to find elements by their role (e.g., getByRole('button', { name: /submit/i })) and simulate user events.
  • Don’t: Unit test every single 5-line utility function.
  • Do: Write integration tests for the “Happy Path” of your features. Can the user log in? Can they add an item to the cart? Can they pay?
  • Pro-tip: Use Playwright for E2E tests. It’s faster than Cypress, has better debugging tools, and doesn’t flake out as much when running in a CI/CD pipeline on GitHub Actions.

The Hydration Mismatch Nightmare

If you’re using Next.js or any SSR (Server-Side Rendering) setup, you’ve likely seen the dreaded “Hydration failed because the initial UI does not match what was rendered on the server” error. This usually happens because you’re using something like window.innerWidth or new Date() inside your render function. The server renders one thing (where window doesn’t exist), and the client renders another.

The fix isn’t to just suppress the warning. The fix is to ensure that the first render on the client matches the server exactly. Use useEffect to trigger any client-only logic after the initial mount.

const [isClient, setIsClient] = useState(false);

useEffect(() => {
  setIsClient(true);
}, []);

if (!isClient) return <LoadingSkeleton />;

return <ClientOnlyComponent />;

This pattern is ugly, but it’s the only way to prevent the “Flicker of Death” where the page layout jumps around as the client-side JS kicks in. As an SRE, I look at hydration errors as a sign of a brittle frontend. If the app can’t even agree on what time it is, how can I trust it to handle a complex checkout state?

Data Fetching: The Stripe Example

Let’s look at a real-world scenario. You need to fetch a list of customers from api.stripe.com/v1/customers and display them in a table. The naive way is to use useState and useEffect. The react best way is to use a dedicated data-fetching library. Here is why:

  1. Race Conditions: If a user clicks “Refresh” twice quickly, two API calls are fired. If the first one finishes after the second one, your UI will show stale data. TanStack Query handles this by canceling the previous request.
  2. Caching: Why fetch the same data every time the user navigates back to the page? Cache it for 5 minutes.
  3. Error Boundaries: What happens if the Stripe API returns a 503? Your whole app shouldn’t crash. You should catch that error at the component level.
// src/features/customers/api/getCustomers.ts
import { useQuery } from '@tanstack/react-query';

export const useCustomers = () => {
  return useQuery({
    queryKey: ['customers'],
    queryFn: async () => {
      const response = await fetch('https://api.stripe.com/v1/customers', {
        headers: { 'Authorization': `Bearer ${process.env.STRIPE_KEY}` }
      });
      if (!response.ok) throw new Error('Network response was not ok');
      return response.json();
    },
    staleTime: 1000 * 60 * 5, // 5 minutes
  });
};

By abstracting the API call into a custom hook using a real library, you’ve solved caching, error handling, and loading states in about 15 lines of code. This is how you build systems that don’t break. You lean on battle-tested libraries instead of trying to reinvent the wheel with useEffect and fetch.

The “God Component” Anti-Pattern

We’ve all seen it. The Dashboard.tsx file that is 2,500 lines long, takes 40 props, and has 15 different useState hooks. It’s a “God Component.” It does everything, and therefore, it is impossible to change without breaking everything else. If you find yourself passing a prop down through five layers of components just to reach a button, you need to rethink your architecture.

Instead of passing props, use Component Composition. Instead of a Layout component that takes user, settings, notifications, and content as props, make a Layout that takes children.

// Bad: Configuration-heavy
<PageHeader 
  title="Settings" 
  showBackButton={true} 
  onBackClick={handleBack} 
  user={user} 
/>

// Good: Composition-heavy
<PageHeader>
  <BackButton onClick={handleBack} />
  <Title>Settings</Title>
  <UserAvatar user={user} />
</PageHeader>

Composition makes your components more flexible. If you suddenly need to add a “Search” bar to the header, you don’t have to modify the PageHeader component and add a showSearchBar prop. You just drop the SearchBar component inside the PageHeader tags. This is the Open-Closed Principle in action: components should be open for extension but closed for modification.

Observability: The SRE’s View of React

As an SRE, I don’t care how pretty your code is. I care if I can debug it when it breaks. Most React apps are black boxes. When a user says “the checkout button didn’t work,” we have no idea why. We need observability in the frontend just as much as we need it in the backend.

  • Error Boundaries: Wrap every major feature in an Error Boundary. If the “Recommendations” widget crashes, it shouldn’t take down the entire “Product Detail” page. Show a fallback UI and log the error to Sentry.
  • Custom Logging: Don’t just console.error. Use a structured logger that includes the component name, the user ID, and the state of the app when the error occurred.
  • Performance Monitoring: Use the web-vitals library to track LCP (Largest Contentful Paint) and CLS (Cumulative Layout Shift). Send this data to your monitoring tool (Datadog, New Relic, etc.).

Pro-tip: Set up a “Dead Man’s Switch” for your frontend. If the main bundle fails to load or the root component fails to mount, have a tiny, inline script in index.html that sends a ping to your alerting system. Sometimes the app is so broken it can’t even report its own errors.

The Final Word on “React Best”

The react best practices aren’t about using the latest experimental features or the trendiest styling library. They are about managing complexity. React makes it very easy to build something quickly, but it makes it very hard to build something that lasts. Every line of code you write is a liability. Every hook you add is a potential bug. Every library you install is a security vulnerability and a bundle-size tax.

Stop trying to be clever. Stop trying to make your code “elegant.” Build boring, predictable, and explicit components. Use local state. Use URL params. Use TanStack Query. Wrap things in Error Boundaries. And for the love of all that is holy, stop using useEffect for things that aren’t side effects. Your SREs will thank you, and your users—who are probably on a 3G connection with a $100 Android phone—will actually be able to use your app.

If you can’t explain how a component works to a junior dev in 30 seconds, it’s too complex. Delete it and start over.

Related Articles

Explore more insights and best practices:

Leave a Comment