React & Javascript Optimization Techniques - Part I

React & Javascript Optimization Techniques - Part I

When we start our journey as programmers, our primary concern is often making code run with zero errors. Initially, we may not prioritize code optimization. However, optimizing code is a crucial aspect that fosters growth, leading one toward becoming a senior or a lead developer.

Moreover, research by Portent suggests that a website loading in one second boasts a conversion rate five times higher than a site taking 10 seconds to load.

In this article, we will introduce some of the most common techniques for optimizing code, applicable to any application. We will use sample code written in React and JavaScript. The upcoming sections will cover the following techniques:

Debouncing, Throttling

Memoization

Bundle Size optimization

Keeping component state local where necessary

Avoid memory leaks in React

DOM size optimization

Applying web workers in React

Asset optimization

Rendering patterns

Debouncing, Throttling

Why do we need Debouncing and Throttling?

Consider these scenarios:

  1. Processing the user's search query: Every time the user modifies the text, an event handler is invoked, initiating a request to the server that returns the results to the user. If the server takes an extended period to respond and numerous requests are made whenever the user alters the search text, it can significantly harm performance.

  2. Responding to specific user actions: Actions like resizing the browser, mouse movement, and page scrolling require adjustments to the site's content. Without techniques to control the number of calls, these events can trigger numerous calls to handler functions, adversely affecting performance.

What is Debouncing and Throttling?

Debouncing and throttling are a programming technique used to optimize the processing of functions that consume a lot of execution time. These techniques involve preventing those functions from executing repeatedly without control, which helps improve the performance of our applications.

Throttling: skips function calls with a certain frequency
Debounce: delays a function call until a certain amount of time passed since the last call.

That is quite hard to understand, isn't it? Let me explain it to you:
For instance, when you invoke a function handler on user scrolling, you can't precisely predict how many times the function will be called—perhaps 20, 30, or even 100 times.

With Throttling applied, the function is triggered only after a specific time interval (e.g., 200ms). The sequence unfolds as follows: Function call => Wait 200ms => Function call => Wait 200ms => ...

On the other hand, after applying Debouncing, the function is triggered only after a designated time has passed since the last call (e.g., 200ms). The sequence unfolds as follows: Wait 200ms (Step 1) => If another function is called before completing the 200ms wait, reset the timer and continue waiting for another 200ms (Step 2) => Loop through Step 2 until completing the 200ms wait => Function call => Wait 200ms => Repeat Step 2 => Function call => ..."

How do we implement Debouncing and Throttling?
Debouncing:

const debounce = (callback, time = 200) => {
    let timer;
    return () => {
        clearTimeOut(timer);
        timer = setTimeout(callback, time);
    };
};

Throttling:

const throttle = (callback, time = 200) {
    let pause = false;
    return () => {
        if(pause) return;
        pause = true;
        callback();
        setTimeout(() => {
            pause = false;
        }, time);
    }
}

If we don't wanna implement it manually, we could import it from lodash library:

import debounce from "lodash/debounce";
import throttle from "lodash/throttle";

How to use Debouncing and Throttling in real cases?

// Use lodash's debounce to delay the search function by 500 milliseconds
const debounceSearch = _.debounce((event) => {
  const query = event.target.value;
  searchUsers(query);
}, 500);

// Add an event listener to the search input
searchInput.addEventListener('input', debounceSearch);
// Use lodash's throttle to trigger the action when the user scrolls, limited to once every 500 milliseconds
const throttleScroll = _.throttle(() => {
  // Check if the user has reached the bottom of the content
  if (contentElement.scrollTop + contentElement.clientHeight >= contentElement.scrollHeight) {
    loadMoreContent();
  }
}, 500);

// Add an event listener to the content element for the scroll event
contentElement.addEventListener('scroll', throttleScroll);

Memoization

Why is memoization necessary for improving performance and speed?

In the context of a sizable React component with numerous states, certain functions can be resource-intensive. When any state updates, React re-renders the entire component, even if the updated state is unrelated to these resource-heavy functions. Consequently, we end up recalculating these expensive functions, negatively impacting performance. Memoization techniques come to our rescue, providing a solution to mitigate these performance issues. Yayyy!

What is memoization?

Memoization is a technique that involves storing the result of a function in a memory space that allows for later retrieval. This is done to avoid having to recalculate the result every time the function is called with the same parameters. This technique only should be applied for functions that consume a lot of resources. It isn't necessary and causes a bad effect on performance if you apply this technique everywhere in a project.

This technique can be used in React as well, we can make use of the following memorization features in React:

React.memo

useMemo

useCallback

Implementing React.memo()

React.memo() is a higher-order component used to wrap the pure component to prevent re-rendering if the props are not changed.

import React, { useState } from "react";

// ...

const ChildComponent = React.memo(function ChildComponent({ count }) {
  console.log("child component is rendering");
  return (
    <div>
      <h2>This is a child component.</h2>
      <h4>Count: {count}</h4>
    </div>
  );
});

If the count prop remains unchanged, React will skip rendering the ChildComponent and reuse the previous result, even if the parent component of ChildComponent re-renders.

React.memo() functions effectively when we pass down primitive values, such as numbers or strings. On the other hand, for non-primitive values like objects, including arrays and functions, we should use useCallback and useMemo hooks to return a memoized value between renders.

It's important to note that React.memo will not prevent component renders caused by updates in state or context React.Context, as it only considers props to avoid unnecessary re-renders.

Implementing React.useMemo hook

useMemo is a React Hook that lets you cache the result of a costly function between component renders and only re-execute the function if its dependencies change.

This hook should only be used within components or other hooks, not within loops to conditionals.

Here is an example of how to use useMemo in a component:

import React, { useState, useMemo } from "react";

const expensiveFunction = (count) => {
  // artificial delay (expensive computation)
  for (let i = 0; i < 1000000000; i++) {}
  return count * 3;
};

export default function Component({ count }) {
  // ...
  const myCount = React.useMemo(() => {
  return expensiveFunction(count);
}, [count]);
  return (
    <div>
      <h3>Count x 3: {myCount}</h3>
    </div>
  );
}

In this example, we use useMemo to avoid the costly execution of expensiveFunction on every component render, which can slow down the application. In this way, the hook will return the last cached value and only update that value if the dependencies change.

It is recommended that the function used withuseMemobe pure.

Please note that useMemo is not a general javascript function for memorization, it's only a built-in hook in React that is used for memorization.

Implementing useCallback hook

useCallback hook lets you cache a function, preventing unnecessary re-creations of the function definition

It's very important to note that useMemo executes functions and saves the value in the cache. In contrast, useCallback does not execute functions, it only saves and updates their definition to be executed later by us.

As I mentioned above this hook can be useful for cache functions that are passed to child components as props.

Here is an example of how to use useCallback in a component:

const Dashboard = ({ month, income, theme }) => {
  const [data, setData] = useState([]);

  useEffect(() => {
    // Fetch data
    // ...
  }, []);

  const onFilterChange = useCallback((filter) => {
    // Handle expensive filter change logic
    // ...
    setData(result);
  }, [month, income]);

  return (
    <div className={`dashboard ${theme}`}>
      <Chart data={data} onFilterChange={onFilterChange} />
      {/* Other components */}
    </div>
  );
};

Bundle Size Optimization

Most of the websites are now server-side and include javascript code more than HTML, and CSS. The bundle is sent to clients then the clients will parse complied and execute. Imagine that if you send to the browser 100kb of the app bundle and 50kb, Which one will make the browser load faster?

Optimizing bundle size will make the browser load quickly. Here are some strategies to optimize the React app bundle effectively.

Code splitting

You might wonder why we need a code-splitting technique to optimize the bundle size. Let me explain to you. Firstly, we should know what is called Bundling. Bundling is the process of following imported files and merging them into a single file "a bundle" .This bundle can then be included on a webpage to load the entire webpage at once. Most React apps will be using tools like Webpack, Rollup, and Browserify to bundle their applications. Bundling is great, but as your app grows, your bundle will grow too. Especially, if you are including large third-party libraries. You have to keep your eyes on the size of the bundle file and if your bundle file becomes too big then your apps will take a long time to load. To avoid winding up with a large bundle, it's good to get ahead of the problem and start "splitting" your bundle. Code splitting is a technique that allows you to split the bundle into small chunks.

You might worried that the Webpack splitting configuration is complicated and you don't know how to do it. But when Webpack comes across this syntax, it automatically starts code-splitting your app. If you are using the Create react app tool, This is already configured for you and you can start using it immediately. it's also supported out of the box in Next.js. If you wanna set up Webpack by yourself, read this guideline.

React provides an easy way to implement code splitting by using lazy imports and Suspense.

import { lazy, Suspense } from 'react';
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";

const HomeComponent = lazy(() => import('./HomeComponent'));
const AboutComponent = lazy(() => import('./AboutComponent'));

const App = () => {
  return (
    <Router>
      <Suspense fallback={<div>Loading...</div>}>
        <Routes>
          <Route path="/" exact element={HomeComponent} />
          <Route path="/about" element={AboutComponent} />
        </Routes>
      </Suspense>
    </Router>
  );
};

export default App;

By doing this way, we won't let import the entire bundle at once, It can slow down application load time. The idea behind React.lazy this is to reduce the bundle file by importing only what we need at the moment. This can be especially helpful for large or complex applications with many components.

It is recommended that components be imported by React.lazy should encapsulated within another React component called Suspense. This component allows us to display fallback while waiting for the lazy components to load, we could use a message or loading animation to let the user know that something is being loaded.

It's important to note that React.lazy currently only supports default exports. if the module you want to import is named exports, you can create an intermediate module that reexports it as the default. This way ensures that tree sharking keeps working and doesn't pull unused code to bundle files.

// ManyComponents.js
export const MyComponent = /* ... */;
export const MyUnusedComponent = /* ... */;
// MyComponent.js
export { MyComponent as default } from "./ManyComponents.js";
// MyApp.js
import React, { lazy } from 'react';
const MyComponent = lazy(() => import("./MyComponent.js"));

Error Boundaries

Error Boundaries are React components that catch Javascript errors(for example, due to network errors) anywhere in their child component tree, log those errors, and display the fallback instead of the app crashing.

It's important to note that Error boundaries do not catch errors for event handlers, asynchronous code, or errors that occur in the boundary itself.

class MyErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // Update state so the next render will show the fallback UI.
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // You can also log the error to an error reporting service
    logErrorToMyService(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // You can render any custom fallback UI
      return <h1>Something went wrong.</h1>;
    }

    return this.props.children; 
  }
}
import React, { Suspense } from 'react';
import MyErrorBoundary from './MyErrorBoundary';

const OtherComponent = React.lazy(() => import('./OtherComponent'));
const AnotherComponent = React.lazy(() => import('./AnotherComponent'));

const MyComponent = () => (
  <div>
    <MyErrorBoundary>
      <Suspense fallback={<div>Loading...</div>}>
        <section>
          <OtherComponent />
          <AnotherComponent />
        </section>
      </Suspense>
    </MyErrorBoundary>
  </div>
);

Dependency audit and clean-up

Regularly auditing your project dependencies is essential. Use commands like npm audit or yarn audit to identify vulnerable packages or outdated dependencies. You can also use tools like depcheck to find unused or redundant packages, which you can remove to minimize your application's size.

# Check for vulnerabilities in your project's dependencies
npm audit

# Check for unused dependencies
npx depcheck

# Remove an unused package
npm uninstall package-name

Tree sharking

Tree sharking is a bundle optimization to reduce the bundle size by eliminating unused code. Let's assume that your application imports a lot of libraries that contain several modules. But you don't use all the modules, without tree sharking, much of the bundled is unused.

With ES6 modules, modern bundlers can analyze your code and remove unused exports, reducing the overall bundle size. To benefit from tree sharking, use ES6 import/export syntax and avoid importing entire libraries if you only need specific functionalities.

// Avoid CommonJS syntax ❌
const React = require('react'); // Not tree-shakable
const Component = React.memo(() => { ... })

// Use ES6 import syntax ✅
import { memo } from 'react'; // Tree-shakable
const Component = memo(() => { ... })

All code is bundled, regardless of whether or not it's used

Only used code is bundled

Remove dead code

Removing dead code is indeed an art form. At times, it can be challenging to distinguish between live and dead code. There are tools available, such as unimported, that can assist with this task, but I often find it challenging to fully utilize them. The best approach is to remain vigilant and identify dead code continuously while coding.

One method I frequently employ is commenting out potentially dead code and observing if any issues arise. If everything functions as expected, I proceed to delete the code. You'd be surprised by how much dead code you can eliminate by periodically employing this practice.

Dedupe Dependencies

One trick that I recently learned was to run npm dedupe in my project after making any changes to the dependencies. This is a built-in command from npm that looks through your project’s dependency tree as defined in the package-lock.json file and looks for opportunities to remove duplicate packages.

For example, if package A has a dependency of package B, but package B is also a dependency of your project (and thus at the top level of your node_modules), running npm dedupe will remove package B as a dependency of package A in your package-lock.json since package A will already have access to package B as it looks up the tree. This probably won’t have dramatic effects on your bundle size. But it definitely will have some effect. Plus, why not use a built-in tool from npm to simplify and clean up your package-lock.json?

Keeping component state local where necessary

We've learned that a component rerenders every time its state changes. Consider a scenario where you have a parent component with several child components. If the state in the parent component changes, even if it's only relevant to one child component, placing it outside the parent component and passing it down as props will cause all child components to rerender unnecessarily, adversely affecting app performance.

To ensure that a component rerenders only when necessary, we can extract the part of the code that relies on the component state, making it local to that specific part of the code.

Suppose we have a parent component ParentComponent with two child components ChildComponentA and ChildComponentB. The parent component holds a state count that is only relevant to ChildComponentA. However, if we define the count state in the parent component and pass it down as a prop to both child components, any change in count will cause both child components to rerender, even if ChildComponentB doesn't depend on count.

import React, { useState } from 'react';

const ParentComponent = () => {
  const [count, setCount] = useState(0);

  const incrementCount = () => {
    setCount(count + 1);
  };

  return (
    <div>
      <ChildComponentA count={count} />
      <ChildComponentB />
      <button onClick={incrementCount}>Increment Count</button>
    </div>
  );
};

const ChildComponentA = ({ count }) => {
  console.log('ChildComponentA rendered');
  return <div>Count from ChildComponentA: {count}</div>;
};

const ChildComponentB = () => {
  console.log('ChildComponentB rendered');
  return <div>ChildComponentB</div>;
};

export default ParentComponent;

To optimize performance and prevent unnecessary rerenders of ChildComponentB, we can lift the count state up to ChildComponentA, making it local to that part of the code:

import React, { useState } from 'react';

const ParentComponent = () => {
  return (
    <div>
      <ChildComponentA />
      <ChildComponentB />
    </div>
  );
};

const ChildComponentA = () => {
  const [count, setCount] = useState(0);

  const incrementCount = () => {
    setCount(count + 1);
  };

  return (
    <div>
      <div>Count from ChildComponentA: {count}</div>
      <button onClick={incrementCount}>Increment Count</button>
    </div>
  );
};

const ChildComponentB = () => {
  console.log('ChildComponentB rendered');
  return <div>ChildComponentB</div>;
};

export default ParentComponent;

However, there are situations where we cannot avoid having a state in a global component while passing it down to child components as a prop. In such cases, we can utilize the memoization technique mentioned earlier to prevent re-rendering of unaffected child components.

Avoid memory leaks in React

A memory leak is a commonly faced issue when developing React applications. It causes many problems, including:

  • This affects the project's performance by reducing the amount of available memory.

  • Slowing down the application

  • crashing the system

You might see this warning message while developing the React application and you don't know where this thing comes from:

Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, canceling all subscriptions and asynchronous tasks in auseEffectcleanup function.

Consider the scenario where you execute the asynchronous call to get data and display it to the user. But when the request is being called and still hasn't been done, you navigate to another page. Since the component was unmounted and the function is being called in a component that is no longer mounted, it causes a memory leak issue - and in the console, you will get a warning.

Example of unsafe code:

const [value, setValue] = useState('checking value...');
useEffect(() => {
    fetchValue().then(() => {
      setValue("done!"); // ⚠️ what if the component is no longer mounted ?
      // we got console warning of memory leak
    });
}, []);

There are a few ways to eliminate memory leaks. Some of them are as follows.

  • Using Boolean Flag

      const [value, setValue] = useState('checking value...');
      useEffect(() => {
      let isMounted = true;
      fetchValue().then(() => {
            if(isMounted ){
            setValue("done!"); // no more error
            } 
          });
         return () => {
          isMounted = false;
          };
      }, []);
    

    In the above code, I’ve created a boolean variable isMounted, whose initial value is true. When isMounted is true, the state is updated and the function is returned. Otherwise, if the action is unmounted before completion, then the function is returned isMounted as false. This ensures that when a new effect is to be executed, the previous effect will be first taken care of.

  • Using use-state-if-mounted Hook

      const [value, setValue] = useStateIfMounted('checking value...');
          useEffect(() => {
              fetchValue().then(() => {
                setValue("done!"); // no more error
              });
          }, []);
    

    In the above code, I’ve used a hook that works just like React’s useState, but it also checks that the component is mounted before updating the state!

  • Using AbortController

      useEffect(() => {  
          let abortController = new AbortController();  
          // your async action is here  
          return () => {  
          abortController.abort();  
          }  
          }, []);
    

    In the above code, I’ve used AbortController to unsubscribe the effect. When the async action is completed, then I abort the controller and unsubscribe the effect.

We've just covered five optimization techniques for enhancing application performance in React and JavaScript. Since this discussion has been quite extensive, let's continue our exploration in Part II. See you there!