Back

Blog

A guide to refactoring, optimizing, and detecting component leaks in React

Web Development

May 31, 2023

Hello there! I am a fellow developer who has spent a fair share of time working with React. Over the years, I've picked up a few tricks of the trade, and today I'm going to share them with you. We'll dive into the delightful world of React components, discussing how to refactor, optimize, and find those sneaky memory leaks that might be hindering your application's performance.

Let's kick things off by discussing what exactly a React component is. In the most basic sense, a component is a reusable chunk of code that outputs a piece of the UI. Here's an example of a simple functional component:

function Greeting(props) {
  return <h1>Hello, {props.name}!</h1>;
}

Components can be as simple as this Greeting component, or they can be more complex, encompassing state and lifecycle methods when we're dealing with class components. Understanding your components is the first step in creating a performant React application.

"How do I know when a component needs refactoring?"

Now that we have our components, it's time to consider their size and complexity. You might be wondering: "How do I know when a component needs refactoring?" Well, it's part art, part science. Code smells like repeated logic, large file size, or difficulty in understanding what the component does are all signs that refactoring could be beneficial.

// This component might benefit from refactoring
function LargeComponent({ prop1, prop2, prop3, ...rest }) {
  // Lots of logic here...
  
  return (
    // A large and complex render method...
  );
}

How do we break down large components into smaller ones?

Smaller components are easier to understand, reuse, and test. It's like dealing with a friendly group of small animals rather than one giant, unwieldy beast. But how do we break down large components into smaller ones? One approach is to identify parts of the render method that could logically exist as separate components:

// After refactoring
function SmallerComponent1({ prop1 }) {
  // Some logic here...
  
  return (
    // Some render output...
  );
}

function SmallerComponent2({ prop2 }) {
  // Some logic here...
  
  return (
    // Some render output...
  );
}

function ParentComponent({ prop1, prop2 }) {
  return (
    <div>
      <SmallerComponent1 prop1={prop1} />
      <SmallerComponent2 prop2={prop2} />
    </div>
  );
}

React is wonderfully performant out of the box, but there are times when we need to give it a little nudge. Performance issues can crop up in React, especially when dealing with large lists or complex state changes. React provides us with several optimization techniques like 'React.memo', 'shouldComponentUpdate', and 'PureComponent'.

import React from 'react';

class OptimizedComponent extends React.PureComponent {
  render() {
    // Your render method here...
  }
}

In this example, 'PureComponent' performs a shallow comparison of props and state, helping us avoid unnecessary renders.

Identifying and Resolving Memory Leaks

I've seen it all too often in React - memory leaks creeping into applications like unwelcome house guests. They are subtle, often going unnoticed until your app starts to stumble and slow, or worse, crashes. More often than not, the culprit is an unmounted component trying to update its state after an asynchronous operation.

Consider this simplified scenario:

componentDidMount() {
  this.interval = setInterval(() => {
    // Some business logic...
  }, 1000);
}

See the problem? The interval is set up and keeps running, even after the component has been unmounted. It's like leaving the faucet running in an empty house - a classic case of a memory leak. We're holding on to resources we don't need anymore.

React, being the ever-reliable toolset it is, provides us with ways to prevent such mishaps. Here are a couple of my favorite techniques, applicable to both function and class-based components:

// Function-based component using useRef
const _isMounted = useRef(true);

useEffect(() => {
  return () => {
    _isMounted.current = false;
  }
}, []);

function performAction(e) {
  e.preventDefault();
  setLoading(true);
  Inertia.post(window.route('login.attempt'), values)
      .then(() => {
          if (_isMounted.current) {
              setLoading(false);
          } else {
              _isMounted = null;
          }
      });
}
// Class-based component with lifecycle methods
unsubscribe = null;

componentDidMount() {
  this.unsubscribe = setTimeout(() => {
    // Some action here...
  }, 1000);
}

componentWillUnmount() {
  clearTimeout(this.unsubscribe);
  this.unsubscribe = null;
}

In both examples, the key is to tidy up after ourselves. It's like doing the dishes after a meal - leaving it for later only makes it worse. In the function-based component, we're using 'useRef' to keep an eye on the component's mounted status. In the class-based component, we're using the 'componentWillUnmount' lifecycle method to clear our timeout when the component says goodbye. This is good hygiene for preventing memory leaks​​.

Adopt these practices and you'll keep your React applications running smoothly, free from the hidden baggage of memory leaks.

Now that we've got the theory down, let's walk through a real-world example. We'll take a large, poorly-optimized component and work through refactoring, optimizing, and plugging any memory leaks. For the sake of this example, let's assume we have a component that fetches a list of users and renders them.

class UserList extends React.Component {
  state = { users: [] };

  componentDidMount() {
    fetch('/api/users')
      .then(response => response.json())
      .then(users => this.setState({ users }));
  }

  render() {
    return (
      <div>
        {this.state.users.map(user => (
          <div key={user.id}>
            <h2>{user.name}</h2>
            <p>{user.email}</p>
          </div>
        ))}
      </div>
    );
  }
}

There are several things we could improve in this component:

  • Refactoring: The component is responsible for both fetching data and rendering the users. We could split this into two components – one for fetching data ('UserFetcher') and another for rendering the users ('UserList').

  • Optimization: The component fetches the users every time it's mounted, even if the data hasn't changed. We could utilize React's 'memo' function to avoid unnecessary re-renders.

  • Memory Leaks: If the component unmounts before the fetch completes, it will try to call 'setState' on an unmounted component, resulting in a memory leak. We can use a flag to check if the component is still mounted before calling 'setState'.

// UserFetcher.js
import React from 'react';

class UserFetcher extends React.PureComponent {
  state = { users: [] };
  _isMounted = false;

  componentDidMount() {
    this._isMounted = true;
    fetch('/api/users')
      .then(response => response.json())
      .then(users => {
        if (this._isMounted) {
          this.setState({ users });
        }
      });
  }

  componentWillUnmount() {
    this._isMounted = false;
  }

  render() {
    return this.props.children(this.state.users);
  }
}

// UserList.js
import React from 'react';

const UserList = React.memo(function UserList({ users }) {
  return (
    <div>
      {users.map(user => (
        <div key={user.id}>
          <h2>{user.name}</h2>
          <p>{user.email}</p>
        </div>
      ))}
    </div>
  );
});

export default function App() {
  return (
    <UserFetcher>
      {users => <UserList users={users} />}
    </UserFetcher>
  );
}

Refactoring, optimizing, and leak detection are crucial aspects of building robust and efficient React applications. It's a process, not a one-time thing. Always remember to keep your components small, utilize React's built-in optimization features, and ensure you clean up after yourself. 

Mark Zaicev

Lead Developer

Other articles

By continuing to use this website you agree to our Cookie Policy