React Best Practices - Part 1

React Best Practices
React Best Practices

React is a powerful JavaScript library for building user interfaces, widely appreciated for its flexibility and performance. However, creating scalable, efficient, and maintainable React applications requires following certain best practices. Adopting these practices not only improves the performance of your app but also ensures cleaner and more maintainable codebases, making collaboration easier in teams.

O(n) also know as 'Big O notation' optimization in React

One key area of focus when building React applications is performance optimization, particularly ensuring the efficiency of rendering and re-rendering components. One common approach to this is optimizing for O(n), where "n" represents the number of elements rendered on the screen or the operations that the component needs to handle.

1. Minimize the Number of Elements or Components

The more elements or components rendered in your React app, the greater the impact on performance. A common mistake is nesting components or rendering unnecessary elements, which can lead to inefficient rendering processes.


// Instead of:
return (
  <div>
    <div>
      <h1>Title</h1>
    </div>
  </div>
);

// Opt for this:
return <h1>Title</h1>;

In this example, by reducing unnecessary div elements, you decrease the number of nodes React needs to manage, improving rendering performance.

2. Break Down Complex Components | Use Keys for Lists | Avoid Unnecessary Re-renders

Large, complex components can become slow and difficult to manage. Breaking them down into smaller, focused components improves both maintainability and performance by minimizing the amount of state and logic each component handles.

Bad Example:

import React, { memo } from 'react';

// Example usage
const App = () => {
  const items = [
    { id: 1, name: 'Item 1', category: 'Category 1', size: 42, tags: 'tag1, tag2' },
    { id: 2, name: 'Item 2', category: 'Category 2', size: 22, tags: 'tag1, tag2' },
    { id: 3, name: 'Item 3', category: 'Category 3', size: 12, tags: 'tag1, tag2' },
  ];

  return (
    <div>
      <h1>Item List</h1>
      <div>
        {items.map((item) => (
          return (
            <div>
              <div>{item.name}</div><br />
              <div>{item.category}</div><br />
              <div>{item.size}</div><br />
              <div>{item.tags}</div><br />
            </div>
          );
        ))}
      </div>
    </div>
  );
};

export default App;

Here's an example that demonstrates how to optimize a React component when displaying a list of items using .map(), invoking another component for each item, and using React.memo() to minimize re-renders. This approach illustrates how you can reduce complexity by ensuring that components are only re-rendered when necessary, showcasing O(n) optimization by avoiding unnecessary operations.

Using key={item.id} ensures React can track each element efficiently during updates.

Good Example:

import React, { memo } from 'react';

// Child Component that displays individual items
const Item = memo(({ item }: { item: { id: number; name: string; category: string, size: number, tags: string } }) => {
  console.log('Rendering item:', item.name); // Logs to demonstrate re-renders
  return (
    <>
      <div>{item.name}</div><br />
      <div>{item.category}</div><br />
      <div>{item.size}</div><br />
      <div>{item.tags}</div><br />
    </>
  );
});

// Example usage
const App = () => {
  const items = [
    { id: 1, name: 'Item 1', category: 'Category 1', size: 42, tags: 'tag1, tag2' },
    { id: 2, name: 'Item 2', category: 'Category 2', size: 22, tags: 'tag1, tag2' },
    { id: 3, name: 'Item 3', category: 'Category 3', size: 12, tags: 'tag1, tag2' },
  ];

  return (
    <>
      <h1>Item List</h1>
      <div>
        {items.map((item) => (
          <Item key={item.id} item={item} />
        ))}
      </div>
    </>
  );
};

export default App;

By breaking complex components into smaller ones, you improve code readability and make your app easier to optimize and debug.

We're using React fragments (<></>) instead of wrapping elements in additional <div></div> tags. This approach helps reduce unnecessary nodes in the DOM, improving performance and ensuring cleaner HTML output without introducing extra elements that don't contribute to the layout.

In React 19 and above, the framework automatically optimizes and caches component re-renders, reducing the need for manual caching strategies likeReact.memo(). However, in earlier versions of React (below 19), developers need to explicitly use React.memo()or other caching techniques to prevent unnecessary re-renders and improve performance.

Lazy Loading Components

Lazy loading is a technique used to improve the performance of React applications by deferring the loading of components until they are needed. This can significantly reduce the initial load time of your application, as only the critical components are rendered on the first load, and less important components are fetched and rendered when they are actually required by the user.

Lazy loading is particularly useful in scenarios where large components, like dashboards or pages, are not immediately visible but need to be loaded when a user interacts with certain parts of the application.

In React, lazy loading can be implemented using React.lazy() and Suspense. This approach allows you to break down your application into smaller bundles, ensuring that components are only loaded when necessary.

Suppose you have a large component, such as a dashboard, that you don’t need to load until the user navigates to the dashboard route.

Without Lazy Loading

import { createBrowserRouter } from "react-router-dom";
import AccountList from '../pages/AccountList/page';
import AccountMut from '../pages/AccountMut/page';
import NotFoundPage from '../pages/NotFoundPage';

export const router = createBrowserRouter([
  {
    path: '/',
    element: <AccountList />,
    errorElement: <NotFoundPage />
  },
  {
    path: '/mutation/:id',
    element: <AccountMut />,
    errorElement: <NotFoundPage />
  },
]);

In this case, the Dashboard component is loaded upfront, regardless of whether the user visits the dashboard section of the app.

With Lazy Loading

import { Suspense, lazy } from "react";
import { createBrowserRouter } from "react-router-dom";

const AccountList = lazy(() => import('../pages/AccountList/page'));
const AccountMut = lazy(() => import('../pages/AccountMut/page'));
const NotFoundPage = lazy(() => import('../pages/NotFoundPage'));


export const router = createBrowserRouter([
  {
    path: '/',
    element: (
      <Suspense fallback={<div>Loading...</div>}>
        <AccountList />
      </Suspense>
    ),
    errorElement: (
      <Suspense fallback={<div>Loading Error Page...</div>}>
        <NotFoundPage />
      </Suspense>
    )
  },
  {
    path: '/mutation/:id',
    element: (
      <Suspense fallback={<div>Loading...</div>}>
        <AccountMut />
      </Suspense>
    ),
    errorElement: (
      <Suspense fallback={<div>Loading Error Page...</div>}>
        <NotFoundPage />
      </Suspense>
    )
  },
]);

In this lazy-loaded version, the Dashboard component is only fetched and rendered when needed. The Suspense component provides a fallback (such as a loading spinner or message) while the component is being loaded asynchronously.

Benefits of Lazy Loading:

  • Faster Initial Load: By not loading every component upfront, the size of the initial bundle is smaller, leading to faster page loads.
  • Better User Experience: Users get faster access to the parts of the app they need immediately. Non-critical components are loaded in the background when the user interacts with certain features.
  • Code Splitting: Lazy loading naturally introduces code splitting, a technique where your code is divided into chunks that can be loaded separately. This improves maintainability and performance.

In summary, lazy loading is an excellent way to optimize React apps by ensuring components are loaded only when necessary, thereby improving performance and user experience.

Avoid Inline Functions

In React, using inline functions directly in the JSX can seem convenient, but it comes with a significant performance cost. Every time a component re-renders, all inline functions are recreated, leading to unnecessary re-renders of child components and increased memory usage. To prevent this, it's best to define functions outside of the render method and use hooks like useCallback to memoize them.

When you pass an inline function as a prop to a child component, React cannot determine if the function has changed unless you explicitly control the component's rendering. Each time the parent component renders, the child component also receives a newly created function, potentially causing unnecessary re-renders.

With Inline Functions

const Parent = () => {
  return (
    <div>
      <Child onClick={() => console.log('Button clicked')} />
    </div>
  );
};

const Child = ({ onClick }) => (
  <button onClick={onClick}>Click Me</button>
);

In this example, every time the Parent component re-renders, a new function (() => console.log('Button clicked')) is created and passed to the Child component. This causes Child to re-render, even though its behavior hasn't changed.

Without Inline Functions (Optimized):

const Parent = () => {
  const handleClick = useCallback(() => {
    console.log('Button clicked');
  }, []);

  return (
    <div>
      <Child onClick={handleClick} />
    </div>
  );
};

const Child = ({ onClick }) => (
  <button onClick={onClick}>Click Me</button>
);

Here, the handleClick function is defined outside of the JSX and memoized with useCallback. This ensures that the function is not recreated on every render, preventing unnecessary re-renders of the Child component.

Benefits of Avoiding Inline Functions

  • Improved Performance: By defining functions outside of the JSX and memoizing them, you prevent React from creating new function references on every render. This reduces the number of re-renders and improves performance.
  • More Readable Code: Moving functions out of the JSX makes the component cleaner and easier to read. The logic is separated from the UI, following the principle of separation of concerns.
  • Reduced Memory Usage: Every time a function is created, it consumes memory. By reusing the same function reference, you reduce the overall memory footprint of your application.

In conclusion, avoiding inline functions is a simple yet effective optimization that can significantly enhance the performance of your React app, especially when dealing with frequent re-renders.

Event Delegation

Event delegation is a technique that allows us to handle events at a higher level in the DOM hierarchy rather than attaching individual event listeners to each element. In React, this is made efficient through its synthetic event system, where events are handled at the document level. This leads to performance benefits, especially when dealing with a large number of dynamic elements or frequent DOM updates.

In traditional DOM manipulation, every element can have its own event listener. However, adding a large number of event listeners can negatively impact performance. Event delegation addresses this issue by setting up a single event listener on a parent element and handling events for all of its child elements.

React’s synthetic event system abstracts away many of the complexities of event handling and optimizes performance by delegating events to the root of the document, where they are captured and processed efficiently.

Benefits of Event Delegation:

  • Fewer Event Listeners: Instead of attaching an event listener to each element, you can add one listener to a parent element. This reduces the number of listeners in your app and makes it more performant.
  • Dynamic Elements: Event delegation works well with dynamically added or removed elements, as the event listener is attached to a parent element that remains constant in the DOM, so you don’t need to manually reattach event listeners.
Without Event Delegation (Inefficient):

const List = ({ items }) => {
  return (
    <ul>
      {items.map((item, index) => (
        <li key={index} onClick={() => console.log(`Clicked ${item}`)}>
          {item}
        </li>
      ))}
    </ul>
  );
};

In this example, each <li> element has its own onClick event listener. If the list is large or dynamic, adding and removing event listeners like this can degrade performance.

With Event Delegation (Optimized):

const List = ({ items }) => {
  const handleClick = (event) => {
    if (event.target.tagName === 'LI') {
      console.log(`Clicked ${event.target.textContent}`);
    }
  };

  return (
    <ul onClick={handleClick}>
      {items.map((item, index) => (
        <li key={index}>{item}</li>
      ))}
    </ul>
  );
};

Here, instead of adding an onClick listener to each <li>, we attach a single event listener to the parent <ul>. The handleClick function checks if the event target is an LI element and logs the clicked item. This reduces the number of listeners and makes it easier to manage dynamically added or removed list items.

Benefits of Event Delegation in React:

  • Performance Boost: Fewer event listeners mean less memory usage and fewer event handlers to process, which results in improved performance.
  • Works with Dynamic Content: React's virtual DOM allows for elements to be added or removed frequently. Event delegation ensures that you don't need to manually handle re-attachment of listeners to newly created elements.
  • Cleaner Code: By using event delegation, you can keep your components more maintainable by reducing repetitive event listener logic.

In summary, event delegation is a powerful technique in React, made even more efficient by React's synthetic event system. It helps optimize performance by reducing the number of event listeners and making it easier to handle dynamic elements in the DOM.

MVVM: Model-View-ViewModel Pattern

The Model-View-ViewModel (MVVM) architectural pattern is widely used in modern software development, particularly in UI-heavy applications like those built with React. This pattern promotes separation of concerns, improving maintainability and testability by organizing code into three distinct layers: Model, View, and ViewModel.

  • Model: This layer represents the core data and business logic of the application. It contains the state, data-fetching logic, and any interactions with external services (e.g., APIs or databases).
  • View: The view is responsible for rendering the UI. It directly reflects the state managed by the ViewModel but does not contain business logic. In React, this is usually your component tree.
  • ViewModel: The ViewModel acts as an intermediary between the Model and the View. It prepares and transforms data from the Model into a format that the View can easily use. It also handles any logic related to user interactions and updates the Model when necessary.
Model (business logic and data):

export const useGetWallet = () => {

  const { data: wallet, error, isLoading } = useQuery({
    queryKey: ['wallet'],
    queryFn: () => fetch(process.env.REACT_APP_WALLETS_URL!).then(res => res.json()),
    refetchOnWindowFocus: false,
    retry: 5
  })

  return { wallet, error, isLoading }
}

The Model represents the data and business logic. It fetches the list of accounts from an API.

ViewModel (intermediary logic):

import { useGetWallet } from '../../hooks/walletHooks'
import ErrorBoundary from '../../helper/ErrorBoundary';
import Skeleton from '../Skeleton';
import { AccountLink } from '../AccountLink';

import './index.css';

const DisplayHolderNames = () => {
  const { wallet, error, isLoading } = useGetWallet();

  ...
}
  
export default DisplayHolderNames

The useGetWallet hook acts as the ViewModel. It manages the state of wallet and handles fetching data from the Model. This data is transformed into a format that can easily be used by the View. It also exposes loading state to the View.

View (UI representation):


  ...
  return (
    <div className='holderNames'>
      <h1>Display holder names:</h1>
      {isLoading && <Skeleton count={10} />}
      {error && <div>Error Occurred: {error.message}</div>}
      <ul>
      {wallet && wallet.map((account: Wallet, index: number) => (
          <ErrorBoundary key={index}>
            <li><AccountLink account={account} /></li>
          </ErrorBoundary>
        )
      )}
      </ul>
      
    </div>
  )

In the List component (View), the useGetWallet hook is used to retrieve the data and render the list of accounts. The component is focused solely on the UI, without any business logic.

Benefits of MVVM in React:

  • Separation of Concerns: The clear separation between the Model, View, and ViewModel makes the code more modular, easier to maintain, and testable.
  • Reusability: ViewModels can be reused across different views, making it easier to centralize and manage business logic.
  • Testability: Since the ViewModel handles the interaction logic, it can be tested independently from the View (UI), improving the overall testability of your application.

In conclusion, the MVVM pattern is an effective architectural approach for organizing React applications, particularly when they become large and complex. It ensures clean separation of data, logic, and presentation, making your application more scalable and maintainable.

Conclusion

In summary, adopting best practices in React development is key to building efficient, scalable, and maintainable applications. Techniques like optimizing for O(n) performance, lazy loading components, avoiding inline functions, and employing event delegation significantly improve both user experience and code quality. Architectural patterns like MVVM further enhance code modularity, making applications easier to test and maintain. By following these best practices, developers can ensure that their React applications remain robust, adaptable, and prepared for future growth.