A Beginner guide to Mastering Re-rendering in React

A Beginner guide to Mastering Re-rendering in React

"Navigating the Fundamentals of Reconciliation and Techniques for Optimizing Re-rendering"

Featured on Hashnode

I had been working professionally with React for a year now, and while I have been able to build functional and feature-rich applications, I realized that I didn't have a solid understanding of how React's re-rendering process works. I think this is true for many React developers who can build functional apps but lack a deeper understanding of one of the fundamental processes in React. And that's why in this blog post, I want to dive deeper into this topic and share my understanding of how React's re-rendering process works using graphical interfaces and how it can be optimized for better performance.

The highlight of the topic, that will be covered in this blog post

In this blog post, we will understand:

  • why component re-render?

  • what is Reconciliation?

  • how do state changes or props changes affect re-rendering in components?

  • how do hooks work, and how do they impact the re-rendering in different ways?

  • We will explore best practices for using hooks to optimize re-rendering and improve the performance of your React applications.

Some basics

What is re-rendering?

A second or subsequent render to update the state is called re-rendering.

Why component re-render?

Re-rendering can occur for any of the three reasons listed below

  • update in the state:
  1. using useState() hook: It is the most common way to manage the component's state. It returns an array with two elements, the current state value and a function to update a state value.

  2. using useReducer() hook: It is similar to useState(), it is used to manage the component's state, but it is useful in case of complex state update, where a component has multiple state values that depend on each other.

  3. using context API/useContext() hook: It is used to manage a component's state that needs to be shared across components using context provider and useContext() hook.

  4. using redux: Redux is a popular library that can be used to manage the global state of React applications.

note: We have not mentioned hooks like useEffect(),because useEffect() hook's primary task is to handle side effects like data fetching, subscriptions or manually changing the DOM after component re-render. By default, it runs after every re-render and hence component and its effect will run again. useEffect() can be used in combination with the setState() method or the useState() hook to update the state in response to a specific event or change in the component's props or state.

  • update in props:
  1. When component props change, React will compare the new props with the previous props and re-render the component if there is a difference.

  2. It means the parent component passes the props to the child component and the child component will re-render with new props.

  3. React make use of shallow comparison which means it compares props by reference rather than by value. If the new props object is the same as the previous props object then React will not re-render.

  4. React compares the virtual DOM representation of the component before and after the update and only updates the parts that have changed.

  • update in the parent component: Change in parent component state, and all subsequent child components re-render(we will see this part later in detail).

Where re-render occurs?

Re-rendering can occur on the server side or client side. On the server side, re-rendering occurs when a new page is requested and the server generates a new HTML document to be sent to the browser. On the client side, re-rendering can occur through the use of client-side routing and javascript libraries such as react or angular, which allows for updating only specific parts of the page rather than the entire page when navigating between different sections of the application.

What is necessary and unnecessary re-render?

  • necessary re-render

If a user types in an input field, the component that manages its state needs to update itself on every key entered in the input field.

  • unnecessary re-render

If a user types in the input field and the entire page re-renders on every key entered in the input field. It affects the app's performance and causes the loss of the user's machine's battery.

What is Reconciliation?

When a component's props or state change, react update the component's virtual DOM and display state. After updating VDOM it compares with the actual DOM and makes the changes in DOM to keep VDOM and DOM in synchronization, this process is called reconciliation.

Triggers for Re-rendering

React component re-rendering process can be triggered by a change in the component's state or props or an update in the parent component. Let's look at the work in detail.

Re-rendering in components using useState() hook and passing props to the descendant component

In this example, we have App component, which renders CounterOutside component, which renders CounterInside component. We are passing props from CounterOutside component to CounterInside component. We are incrementing the counter on click of the button using useState() hook.

//App component
function App() {
  return (
    <div className="AppMain">
      <b>AppComponent</b>
      <CounterOutside />
    </div>
  );
}
export default App;

//CounterOutside component
const CounterOutside = () => {
  let [count, setCount] = useState(0);
  return (
    <div className="container">
      <div>
        <b className="text">CounterOutsideComponent</b>
        <p className="text">ParentComponent Rerender- {count}</p>
        <button className="btn" onClick={incrementHandler}>
          Increment
        </button>
        {/*passing props to child component*/}
        <CounterInside parentCount={count} parentStyle={temporaryStyle} />
      </div>
    </div>
  );
};
export default CounterOutside;

//CounterInside component
const CounterInside = (props) => {
  setTimeout(() => {
    setStyle(props.parentStyle);
  }, 100);
  return (
    <div className="container">
      <div>
        <b className="text">CounterInsideComponent</b>
        <p className="text">ChildComponent Rerender- {props.parentCount}</p>
        <p className="text">(props.count)</p>
      </div>
    </div>
  );
};
export default CounterInside;

Here is a graphical representation of the above code which shows which component gets updated on state change by clicking on the increment button.

From the above example, we get to know that on state change of CounterOutside component, parent App the component does not re-render but the child CounterInside the component gets affected. Re-render is a snapshot, like a photo taken from the camera, it checks what is the difference between an older snapshot and a newer snapshot which is generated after the state change.

The important point to note is:- The entire app and parent components do not re-render whenever a state variable changes. When a component re-renders then it also re-renders all of its child components.

In the above example, you can argue that the child component re-renders because we are passing props to child component. Let's check whether the state change of the child component is because of the props or not?

Rerendering in components using useState() hook and not passing props to the descendant component

The below code is similar to the above, In this example we have App component, which renders CounterOutside component, which renders two-component, CounterInside1 component with props and CounterInside2 component without props. We are incrementing the counter on click of the button using useState() hook.

//App component
function App() {
  return (
    <div className="AppMain">
      <b>AppComponent</b>
      <CounterOutside />
    </div>
  );
}
export default App;

//CounterOutside component
const CounterOutside = () => {
  let [count, setCount] = useState(0);
  return (
    <div className="container">
      <div>
        <b className="text">CounterOutsideComponent</b>
        <p className="text">ParentComponent Rerender- {count}</p>
        <button className="btn" onClick={incrementHandler}>
          Increment
        </button>
        {/*passing props to child component*/}
        <CounterInside1 parentCount={count} parentStyle={temporaryStyle} />
        {/*not passing props to child component*/}
        <CounterInside2/>
      </div>
    </div>
  );
};
export default CounterOutside;

//CounterInside1 component
const CounterInside1 = (props) => {
  setTimeout(() => {
    setStyle(props.parentStyle);
  }, 100);
  return (
    <div className="container">
      <div>
        <b className="text">CounterInside1Component</b>
        <p className="text">ChildComponent Rerender- {props.parentCount}</p>
        <p className="text">(props.count)</p>
      </div>
    </div>
  );
};
export default CounterInside1;

//CounterInside2 component
const CounterInside2 = (props) => {
  setTimeout(() => {
    setStyle(props.parentStyle);
  }, 100);
  return (
    <div className="container">
      <div>
        <b className="text">CounterInside2Component</b>
      </div>
    </div>
  );
};
export default CounterInside2;

Here is a graphical representation of the above code which shows which component gets updated on state change by clicking on the increment button.

From the above example, we get to know that on state change of parent CounterOutside component, child CounterInsideComponent1 and CounterInsideComponent2 get rerendered.

The important point to note is:- Child components re-render on state change of parent component irrespective of whether props are being passed or not.

It is difficult for React to know whether CounterInsideComponent2 directly or indirectly depends on the parent component state change props.count or not. React keep UI in sync with the application state hence it does not take a risk of showing falsy or older UI and decides to re-render the child component irrespective of whether props are being passed or not.

Rerendering in useContext() hook

Let's see how re-rendering works in case of context.

In this example, SenderComponent and ReceiverComponent are wrapped inside ContextProvider component. SenderComponent send data using useContext hook to the ReceiverComponent. Both SenderComponent and ReceiverComponent has its child component.

//App component
function App() {
  return (
     //components are wrapped with ContextProvider
    <ContextProvider>
      <div className="AppMain">
        <b>ContextProvider</b>
        <div className="flexCol">
          <CounterOutside1 />
          <CounterOutside2 />
        </div>
      </div>
    </ContextProvider>
  );
}
export default App;

//CounterContext component
import React from "react";
const initialState = {};
const CounterContext = React.createContext(initialState);
export default CounterContext;

//ContextProvider component
import React, { useState } from "react";
import CounterContext from "./CounterContext";

const ContextProvider = (props) => {
  const [countContext, setCountContext] = useState(0);
  const [styleContext, setStyleContext] = useState({});
  return (
    <CounterContext.Provider
      value={{ countContext, setCountContext, styleContext, setStyleContext}}
    >
      {props.children}
    </CounterContext.Provider>
  );
};
export default ContextProvider;

//Sender component
const senderComponent = () => {
  let [count, setCount] = useState(0);
  const sender = useContext(CounterContext);
  const incrementHandler = () => {
    setCount((count = count + 1));
    sender.setCountContext(count);
  };

  return (
    <div className="container">
      <div>
        <b className="text">SenderComponent</b>
        <button className="btn" onClick={incrementHandler}>
          Increment
        </button>
        <div className="colContainer">
          <CounterInside1 />
        </div>
      </div>
    </div>
  );
};
export default SenderComponent;

//Receiver component
const ReceiverComponent = () => {
  let [count, setCount] = useState(0);
  const sender = useContext(CounterContext);
  const receiver = useContext(CounterContext);
  return (
    <div>
      <div>
        <b className="text">ReceiverComponent</b>
        <p className="text">
          ParentComponent Rerender- {receiver.countContext}
        </p>
        <div className="colContainer">
          <CounterInside3 parentCount={count} parentStyle={temporaryStyle} />
        </div>
      </div>
    </div>
  );
};

export default ReceiverComponent;

Here is a graphical representation of the above code which shows which component gets updated when sending data and receiving data via context by clicking on the increment button.

From the above example, we get to know that SenderComponent sends data using context, ReceiverComponent and its child component re-rendered.

The important point to note is:- Component consuming the context and its child component will re-render after updating the state of the context provider component.

In the above scenario, the following components will re-render:

  • Receiver component: This component will re-render because it is consuming the context and it will receive the updated context value from the context provider component.

  • Receiver component's child component: As we see in the above examples state change of the parent component results in the re-rendering of its child components.

In the above scenario, the following components will not re-render:

  • Sender component: This component is updating the state of the context provider component but it is not consuming the context, so it will not re-render.

  • Parent component of sender and receiver component: These components are not consuming the state and will not be affected by the context update.

  • App component and its child component: These components are wrapped inside the context provider but they are not consuming the context, so they will not re-render.

The important point to note is:- When the context provider component updates its state, it updates the context value and that causes all the components that are consuming the context to re-render.

Rerendering in other hooks?

  • useRef(): It allows you to add a reference to a DOM node or value that persists across re-render. It does not trigger a re-render.

  • useReducer(): It works similarly to useState() hook. It is used to manage complex state logic in applications.

    syntax->const [state, dispatch] = useReducer(reducer, initialState)

    It allows you to manage the state of a component using a reducer function, It allows you to handle state updates in a centralized and predictable way. When the state changes, the component re-render.

Performance optimization techniques to avoid re-rendering

One of the key ways to improve the performance of a React application is to minimize the unnecessary re-renders. There can be several techniques that can be used to achieve this such as useMemo(), useCallback() and React.memo.

Optimization using React.memo and React.PureComponent

To stop some of the re-renders and increase the speed of the application we make use of some optimization techniques apart from hooks are React.memo and React.PureComponent. We use React.PureComponent with class-based components while React.memo works with functional components, the working of both is exactly similar.

In this example we have App component, which renders CounterOutside component, which renders two-component, CounterInside1 component with props and CounterInside2 component without props. But now both the child components are wrapped with React.memo.

//App component
function App() {
  return (
    <div className="AppMain">
      <b>AppComponent</b>
      <CounterOutside />
    </div>
  );
}
export default App;


//CounterOutside component
const CounterOutside = () => {
  let [count, setCount] = useState(0);
  return (
    <div className="container">
      <div>
        <b className="text">CounterOutsideComponent</b>
        <p className="text">ParentComponent Rerender- {count}</p>
        <button className="btn" onClick={incrementHandler}>
          Increment
        </button>
        {/*passing props to child component*/}
        <CounterInside1 parentCount={count} parentStyle={temporaryStyle} />
        {/*not passing props to child component*/}
        <CounterInside2/>
      </div>
    </div>
  );
};
export default CounterOutside;

//CounterInside1 component
const CounterInside1 = (props) => {
  setTimeout(() => {
    setStyle(props.parentStyle);
  }, 100);
  return (
    <div className="container">
      <div>
        <b className="text">CounterInside1Component</b>
        <p className="text">ChildComponent Rerender- {props.parentCount}</p>
        <p className="text">(props.count)</p>
      </div>
    </div>
  );
};
//wrapped component with React.memo
export default React.memo(CounterInside1);

//CounterInside2 component
const CounterInside2 = (props) => {
  setTimeout(() => {
    setStyle(props.parentStyle);
  }, 100);
  return (
    <div className="container">
      <div>
        <b className="text">CounterInside2Component</b>
      </div>
    </div>
  );
};
//wrapped component with React.memo
export default React.memo(CounterInside2);

Here is a graphical representation of the above code which shows which component gets updated on state change by clicking on the increment button.

From the above example, we get to know that on state change of parent CounterOutside component, child CounterInsideComponent1 is re-rendered but CounterInsideComponent2 is not get rerendered.

The important point to note is:- Wrapping components inside React.memo tells React that re-render this component if its props change.

React make use of the older snapshot and if none of the component's props change then it again makes use of the older snapshot.

In the above example, you can argue that why not always make use of React.memo to stop unnecessary re-rendering?

Suppose a component has a large number of props coming from its parent component then it is slower to check whether there is any change in props of the component compared to an older snapshot than a quick re-render. React.memo is not the optimized solution if we use it on every single component, it should be used if a component has lots of child components and then React.memo can help to optimize the application.

Optimization using useMemo() and useCallback()

These hooks are mainly used for the performance optimization of React applications. Both useMemo() and useCallback() hook receives a function as its first argument and a dependencies array as the second argument. The hook will return a new value only when one of the dependency's value change.

useMemo() is used to memoize the value which is expensive to compute, the value is recomputed when one of its dependencies changes. useCallback() is used to memoize the function which is expensive to compute, function is recreated when one of its dependencies changes.

useMemo() hook will call the function in its first argument and return the function's result or value. While useCallback() hook will return the function without calling it.

The important point to note is:- Caching is storing data in a cache so that it can be used later without recomputing from its source. Whereas memoization is a technique for caching, applied to function calls only.

Let's look at a simple search bar example where we can apply both the concepts useMemo() and useCallback() using the API calls.

const SearchBar = () => {
  const [apiResponse, setApiResponse] = useState();
//api call
  useEffect(() => {
    axios
      .get("https://fakestoreapi.com/products")
      .then((res) => {
        setApiResponse(res);
        console.log(res);
      })
      .catch((err) => {
        console.log(err);
      });
  }, []);

  const [inputData, setInputData] = useState("");
  const [visible, setVisible] = useState(true);
//useCallback()
  const inputDataHandler = useCallback((e) => {
    setInputData(e.target.value);
  }, []);
//useMemo()
  const items = useMemo(() => {
    if (!inputData) {
      return apiResponse?.data;
    }
    return apiResponse
      ? apiResponse.data.filter((val) =>
          val.title.toLowerCase().includes(inputData.toLowerCase())
        )
      : "";
  }, [inputData]);

  return (
    <div className="main">
      <input
        onClick={() => {
          setVisible(true);
        }}
        placeholder="Search product"
        className="inp"
        value={inputData}
        onChange={inputDataHandler}
      />
        <div
          onClick={() => {
            setVisible(true);
          }}
          className="list"
        >
          {items
            ? items.map((value) => {
                return <p>{value.title}</p>;
              })
            : ""}
        </div>
    </div>
  );
};
export default SearchBar;

In case of useMemo(), items value is computed after doing expensive operation code, hence here we use useMemo() hook. We put all the expensive operation codes inside useMemo() first argument's function, and return value of expensive operation code. This return value is stored in items variable. This value is recomputed only when changing its dependency.

In case of useCallback(), inputDataHandler is an expensive function. We put inputDataHandler code inside useCallback() hook's first argument's function. This function is recomputed only when changing its dependency.

Search bar output-

Optimization using useEffect()

useEffect() is used to perform side effects such as fetching data, updating DOM and subscribing to a service. It can also be used to optimize the performance of an application.

  • we can pass the dependency array, the effect will run only when there is a change in its dependencies, if the empty array is passed then the effect will run only once when the component is mounted.

  • We can avoid the effect to run on the initial render and make it lazy loading using {lazy: true} as a third argument in useEffect() hook.

  • We can use the cleanup function before the component is unmounted. This function is used to perform unsubscribing from a service and clean up the timer.

Conclusion

In conclusion, re-rendering is an essential part of React. Understanding how re-rendering works and optimizing it can help to improve the performance of React applications. The optimization techniques should be used only when it is necessary. As you continue to work with React, I encourage you to experiment with these concepts and see how they can improve the performance of your applications.

Happy Coding!!