DEV Community

vithano for Workiz

Posted on • Edited on

Optimizations in React part 1

Do we even need optimizations?

Bounce rate credit: siteimprove.com

Seeing as most people leave a website within the first 5 seconds of nothing, we should make sure we are in a good spot, we can use Lighthouse to run a performance report from the inspect tab.

After looking at our website over at Workiz.com
we've noticed we had some place to improve so we decided to refactor some stuff and optimize some other stuff.

Re-Rendering

Let's start at the beginning, when does a React component re-render?

  1. When either the props or state change
  2. When a parent component re-renders
  3. When a hook changes

Let's take a look at the next component:

const Counter = () => {
    const initialCount = 
parseInt(window.localStorage.getItem("count") ?? "0");
    const [count, setCount] = useState(initialCount);
    const increment = () => {
        window.localStorage.setItem('count', count + 1);
        setCount(count + 1);
    }
    return (
      <>
        Count: {count}
        <button onClick={increment}>+</button>
      </>
    );
  }
Enter fullscreen mode Exit fullscreen mode

We have a component that has some initial state initialCount, which it gets from the localStorage, and a function "increment" which increments the count by 1, and then stores that count in the localStorage.

For the sake of readability I will rename some of the functions

const getCountFromLS = () => parseInt(window.localStorage.getItem("count") ?? "0");
const setCountToLS = (count) =>
window.localStorage.setItem('count', count);
Enter fullscreen mode Exit fullscreen mode
const initialCount = getCountFromLS();
const [count, setCount] = useState(initialCount);
Enter fullscreen mode Exit fullscreen mode

Every time we "increment", we notice that the initialCount is being read again from the localStorage even though we don't even use it beyond the first render.

Luckily for us useState accepts either a value or a callback function, meaning we can turn our initialCount into a function that returns a value, instead of just a value.
We can store the function in the scope of the Counter and call it only from the initialization callback we receive from useState.

const initialCount = () => getCountFromLS();
const [count, setCount] = useState(()=>initialCount());
Enter fullscreen mode Exit fullscreen mode

This is called Lazy Initialization, it's a very subtle change, but now we are actually giving useState a function instead of a value, it's the same as this:

const initialCount = () => getCountFromLS();
const [count, setCount] = useState(initialCount);
Enter fullscreen mode Exit fullscreen mode

Now every time our component re-renders it doesn't affect the initialCount as it's now being called only once during the first initialization of the component and never again...

...unless someone moves away from our Counter.
In that case when they go back to it, we will need to render the Counter for the first time again and do another heavy IO operation (getting stuff from the localStorage is expensive).

Which is why we should probably move the function call outside our component's life cycle, we would generally want to bring all our data somewhere at the top of our application instead of when a component requires it.

const expensiveInputOperation = getCountFromLS();
const Counter = () => {
    const [count, setCount] = useState(expensiveInputOperation);
...
Enter fullscreen mode Exit fullscreen mode

Now we are doing the expensive operation outside of our component's life cycle and just passing the value to useState.
Since "expensiveInputOperation" is a constant there is no need to use a callback function.

Now let's introduce a new component called CoolButton.
CoolButton is just a very basic button that does some really important calculation every time we click on it.

const CoolButton = ({ clickHandler }) => {
    const handler = () => {
        ReallyImportantCalculation();
        clickHandler();
    };
    return <button onClick={handler}></button>;
  };
Enter fullscreen mode Exit fullscreen mode

Let's replace the button in our Counter with our new CoolButton:

const Counter = () => {
    const [count, setCount] = useState(expensiveInputOperation);
    const increment = () => {
        setCountToLS(count + 1);
        setCount(count + 1);
    }
    return (
      <>
        Count: {count}
        <CoolButton clickHandler={increment}>+</CoolButton>
      </>
    );
  }
Enter fullscreen mode Exit fullscreen mode

Now we have a Counter which has a CoolButton inside it.
When we click the button we actually render both the Counter and the CoolButton even though nothing changed in the CoolButton.

How do we stop this from happening?

React.memo

Luckily for us, React gives us a way to counter the rendering of the parent by allowing the child to render at its own pace, and not rely on the renders of the parent.
This is the same as using React.PureComponent instead of a regular React.Component

const CoolButton = React.memo(({ clickHandler }) => {
    const handler = () => {
        ReallyImportantCalculation();
        clickHandler();
    };
    return <button onClick={handler}></button>;
  });
Enter fullscreen mode Exit fullscreen mode

Now we click the button and everything works properly, but we still keep on re-rendering the CoolButton...

Wasn't memo supposed to stop the re-renders?

To understand why this is happening it's important to remember that React checks if the props or the state changed based on shallow equality.
This means that when memo encounters an object in its props, it can't tell whether the objects are the same.

{'test':true} == {'test':true} // FALSE
Enter fullscreen mode Exit fullscreen mode

Javascript checks whether the references are the same and not if they have the same values inside them.
Going back to our component, what happened that caused the re-render?

Let's take a look at the parent component again:

const Counter = () => {
    const [count, setCount] = useState(expensiveInputOperation);
    const increment = () => {
        setCountToLS(count + 1);
        setCount(count + 1);
    }
    return (
      <>
        Count: {count}
        <CoolButton clickHandler={increment}>+</CoolButton>
      </>
    );
  }
Enter fullscreen mode Exit fullscreen mode

Every time we click the button we render Counter again.

When we render Counter all the functions are being run again, which means we get a new anonymous function called "increment" every time.
We then pass this new "increment" to our CoolButton as a prop, meaning "increment" from a render ago is not the same "increment" as we have right now, so it's only natural to re-render our button again.

What can we do?

React.useCallback

useCallback to the rescue!
This react hook ensures that we receive a reference to the function that will only change if one of the dependencies in the square brackets change, we can use this to memoize our "increment" function so that when Counter re-renders we will get the same "increment" and pass it to our CoolButton.

Attempt 1

const Counter = () => {
    const [count, setCount] = useState(expensiveInputOperation);
    const increment = useCallback(() => {
        setCountToLS(count + 1);
        setCount(count + 1);
    },[])
    return (
      <>
        Count: {count}
        <CoolButton clickHandler={increment}>+</CoolButton>
      </>
    );
  }
Enter fullscreen mode Exit fullscreen mode

Ok cool so now we click the button, but it doesn't work more than once, why is that?
That's because our function never changes, so whichever value of count it received in the beginning, that's the same count it will have until it gets destroyed, meaning it will always be 0 :(

I guess we should just add our count to the dependencies array, right?
Well...yes we can do that, but then we would get a different "increment" every time count changes... which means we will need to re-render our CoolButton as well... back to square 1.

Attempt 2

Luckily for us setCount actually receives a callback function just like our useState function, only this one gives us the previous value and expects us to give it the next one.

Meaning we can do something like this:

 const increment = useCallback(() => {
        setCountToLS(count + 1);
        setCount(prevCount => prevCount + 1);
    },[])
Enter fullscreen mode Exit fullscreen mode

Cool so now we have our setCount use a callback function.

What about the localStorage?
It still receives the same count every time, how can we fix this? Well that's easy enough -
Let's just put that call inside our setCount callback as well:

 const increment = useCallback(() => {
        setCount(prevCount => {
        setCountToLS(prevCount + 1);
        return prevCount + 1;
        })
    },[])
Enter fullscreen mode Exit fullscreen mode

And now everything works properly!

const CoolButton = React.memo(({ clickHandler }) => {
    const handler = () => {
        ReallyImportantCalculation();
        clickHandler();
    };
    return <button onClick={handler}></button>;
  });
const expensiveInputOperation = 
parseInt(window.localStorage.getItem("count") ?? "0");
const Counter = () => {
   const [count, setCount] = useState(expensiveInputOperation);
   const increment = useCallback(() => {
   setCount(prevCount => {
          window.localStorage.setItem("count", prevCount + 1);
          return prevCount + 1;
        });
    }, []);
   return (
      <>
        Count: {count}
        <CoolButton clickHandler={increment}>+</CoolButton>
      </>
      );
  }

Enter fullscreen mode Exit fullscreen mode

If you're asking yourself why aren't we wrapping our "handler" function in a useCallback as well, we should remember that memoization isn't free.
Everything in programming is a trade off, you gain some but lose other, for memoization we would need to keep this data somewhere to use it later.
Primitive types like <button>, <input>, <div>, etc. are very cheap to render so we don't have to save all of them.
We should use these techniques only when we see an impact on our user's experience, for the most part React does a pretty good job even with the re-renders.

The next part will be about useMemo, stay tuned!

Top comments (1)

Collapse
 
markrity profile image
Mark Davydov

Cool stuff :)