Optimize React Components Using Memoization
Memoization is a technique that stores the results of a function based on the provided input. If the function is called multiple times with the same inputs, the stored results are returned each time. Memoization can be a great way to improve the performance of a React application. In fact, there is a built-in hooks, useMemo
, that you can use to memoize your components.
In the example below, the React component performs a hypothetical expensive calculation inside the expensiveOperation
function. It's a simple addition, but for demo purposes let's pretend it's a complex equation. It's important to remember that the value returned by this function never changes, because the arguments passed to it are always the same.
Inside our component is a button which updates the callCount
state when clicked. Each time you click the button, the component will re-render and expensiveOperation
will be called.
// Hypothetical expensive operation
const expensiveOperation = (oa, ob) => {
alert(`expensiveOperation(${oa}, ${ob})`);
return oa + ob;
};
export const ExpensiveComponent = () => {
// Component will re-render when callCount is updated
const [callCount, setCallCount] = useState(0);
const a = 100;
const b = 250;
// This returned value never changes when component is re-rendered
const expensiveValue = expensiveOperation(a, b);
return (
<div>
<button onClick={(e) => setCallCount(callCount + 1)}>Count</button>
<p>Count: {callCount}</p>
<p>Memoized Value: {expensiveValue}</p>
</div>
);
};
To test this yourself, try the live demo below. I've added an alert
dialog inside the expensiveOperation
function. Whenever you click the Count button, you will see an alert dialog, and the count value will increase by one.
Now if expensiveOperation
was actually performing a long and complicated task, we would have a very poorly optimized component on our hand. But if we used memoization, we only need to call the function once to perform our calculation and cache the result. And since the arguments do not change on every subsequent re-render, we only return the cached value.
In the next example, we have the same component, but this time we are wrapping the expensiveOperation
function with the useMemo
hook.
const expensiveOperation = (oa, ob) => {
alert(`expensiveOperation(${oa}, ${ob})`);
return oa + ob;
};
const ExpensiveComponent = () => {
const [callCount, setCallCount] = useState(0);
const a = 100;
const b = 250;
const memoizedValue = useMemo(
() => expensiveOperation(a, b),
[a, b]
);
return (
<div>
<button onClick={(e) => setCallCount(callCount + 1)}>Count</button>
<p>Count: {callCount}</p>
<p>Memoized Value: {memoizedValue}</p>
</div>
);
};
The useMemo
hook will call the expensiveOperation
function and cache its return value based on the hook dependencies. If the hook dependencies remain the same, it will return the cached value. Try out the demo below.
The alert dialog will appear once, because each subsequent re-render now returns the stored value instead of calling expensiveOperation
.
The useMemo
hook takes two arguments: a create function and an array of dependencies. The create function should return the value you want to cache, and the array of dependencies should contain the arguments passed to the expensive function. In the example below, the create function is returning the result of the expensive function.
// Our function that performs some long and difficult calculation
const expensiveFunction = (propA, propB, propC) => {...}
// Function arguments
const argA = 1;
const argB = 2;
const argC = 3;
// First argument is the "create" function
// Second argument is the array of dependencies
const memoValue = useMemo(
// "Create" function that returns
// the result of our expensive function.
() => expensiveFunction(argA, argB, argC),
// Hook dependencies - an array of values.
// If these change, then expensiveFunction will be re-calculated
[argA, argB, argC]
);
During a component re-render, if the dependency remains the same, useMemo
will always return the cached value. However, if one or more of the dependencies changes, then the expensive function will be called and value will be recalculated. If no array of dependencies are provided, then the expensive function will be called on every component re-render.
The useMemo
hook is a great way to optimize computationally expensive functions. But there is also another use case - reference equality.
Disclaimer before we continue:
The explanation below simplifies a lot of what Javascript actually does, but it will give you a general idea of how primitives and objects are compared. If you want to learn more, MDN has a thorough explanation of equality checks in JavaScript.
...
Javascript objects (including arrays and functions) are assigned by reference. This means that a variable stores the reference to the object's location in memory, not the actual object. Hence, reference equality is checking if two object references are the same. In the example below, two variables are initialized with identical objects. Even though the objects are identical, they will be stored separately in memory, and a different reference will be assigned to variable objA
and objB. A reference equality check on objA
and objB
will always return false, because you are comparing the object references, not their values.
// Both objects have identical "values"
let objA = {one: 1};
let objB = {one: 1};
console.log(objA === objB); // false
In contrast, primitive values, such as numbers or strings, are assigned by value. So comparing two primitive values will compare their actual values.
// Both primitives identical values
let a = 150;
let b = 150;
a === b; // true
When you pass the object to a function or assign it to a new variable, you are only passing the reference to the new variable or function parameters, not the actual value. The reference will be copied to the new variable or function parameter.
let objA = {one: 1};
// Reference of objA will be copied to copyA
let copyA = objA;
console.log(copyA === objA); //true
// Reference of objA will be copied to objB
// in the function parameter
checkReference(objA);
function checkReference(objB) {
let objC = {one: 1};
console.log(objA === objB); // true
console.log(objB === objC); // false
}
Let's look at another example. Inside myExampleFunc
we are creating an object and assigning a reference to myOjbProp
. Every time we call this function, the object is recreated, and a new reference is assigned to myOjbProp
. When we return the value of myOjbProp
, we are returning the reference to the object. Thus every new function call returns a new reference. This is why the reference equality of objA
and objB
will always be false.
const myExampleFunc = () => {
let myObjProp = {a: 1};
return myObjProp;
}
let objA = myExampleFuncObj();
let objB = myExampleFuncObj();
objA === objB; // false
Now, instead of returning the object, what if we passed it to another function? Every time the parent function is called, we would pass a new reference to the child function.
const myNestedFunc = (paramA) => {}
const myExampleFunc = () => {
let myObjProp = {a: 1};
// A new reference will be passed
// to the nested function everytime
// myExampleFunc is called
myNestedFunc(myOjbProp);
}
myExampleFuncObj();
myExampleFuncObj();
Let's take a look at how this relates to a React component.
In the example below, we have a React functional component, with a nested child component. The ParentComponent
has a variable propA
which stores a reference to an object. This reference is passed to the ChildComponent
as a prop. Every time the parent component is re-rendered, the object is recreated, and a new reference is passed down to the child component.
const ChildComponent = ({ childPropA }) => {
...
};
const ParentComponent = () => {
const propA = {showExcitement: true};
return <ChildComponent childPropA={propA} />
}
Expanding on our example, we now have ChildComponent
utilizing the useEffect
hook. This hook has a "create" function that updates the useEffectCount state. It is also using the childPropA
as a hook dependency. Based on the rules of the hook, the create function will be called the first time the ChildComponent
is rendered, and every time the value of childPropA
has changed.
For every re-render, the useEffect
hook will check the reference equality of childPropA
, comparing the previous render state to the current render state. If the references of childPropA
are not equal between each re-render, the useEffect
hook will call the "create" function, causing us to update the state of ChildComponent
.
The "create" function is the first argument you pass to a hook, e.g: useEffect(createFunction, dependencies).
const ChildComponent = ({ childPropA }) => {
const [useEffectCount, setUseEffectCount] = useState(0);
useEffect(() => {
setUseEffectCount((count) => count + 1);
// Hook dependencies
}, [childPropA]);
return <div>useEffectCount: {useEffectCount}</div>;
};
const ParentComponent = () => {
const isExcited = true;
const propA = { showExcitement: isExcited };
const [demoState, setDemoState] = useState(0);
return (
<>
<ChildComponent childPropA={propA} />
<button onClick={() => setDemoState((v) => v + 1)}>
Re-render ParentComponent (demoStateCount: {demoState}x)
</button>
</>
);
};
For demo purposes, I've added a button that updates the state of the ParentComponent
, causing it to re-render. Try it out below - every time you click the button, the useEffectCount
and the demoStateCount
will increment.
So what's the problem here? When ParentComponent
re-renders, the reference passed to the ChildComponent
always changes. This unintentionally causes a state change in the child component. What if the useEffect hook was doing an expensive operation like a HTTP request instead of a state change? We would be unintentionally performing this expensive operation, even though we did not need to.
This is where memoization comes into play. We can use the useMemo
hook to preserve our object reference during each re-render, ensuring we are always passing the same reference to the child component. When the useEffect
hook checks the reference equality of childPropA
, we can be sure it will not cause an unintended re-render.
To memoize our object reference, we simply return the object using the "create" function in our useMemo
hook.
const isExcited = true;
const propA = useMemo(
() => {
showExcitement: isExcited;
},
[isExcited]
);
const ChildComponent = ({ childPropA }) => {
const [useEffectCount, setUseEffectCount] = useState(0);
useEffect(() => {
setUseEffectCount((count) => count + 1);
// Hook dependencies
}, [childPropA]);
return <div>useEffectCount: {useEffectCount}</div>;
};
const ParentComponent = () => {
const isExcited = true;
const propA = useMemo(() => {
showExcitement: isExcited;
}, [isExcited]);
const [demoState, setDemoState] = useState(1);
return (
<>
<ChildComponent childPropA={propA} />
<button onClick={() => setDemoState((v) => v + 1)}>
Re-render ParentComponent (demoStateCount: {demoState}x)
</button>
</>
);
};
Try out the demo below. With this modification, the useEffectCount
will only increment once.
Using useMemo to preserve reference equality is a simple yet powerful way to optimize your Reach application.
One thing you may be tempted to do, but should avoid, is omitting dependencies that are being used inside the hook. The React documentation quotes:
The array of dependencies is not passed as arguments to the function. Conceptually, though, that’s what they represent: every value referenced inside the function should also appear in the dependencies array.
In fact, there is the exhaustive-deps
check that comes with eslint-plugin-react-hooks
, which verifies if dependencies are being used correctly. It is a good idea to use this rule in your project if you have not done so already. Dan Abramov has also warned against disabling this rule.
Another thing to consider - don't use anything that may have side effects (like an AJAX call) inside the memoization hook. Those should be used inside the useEffect
hook.
Finally, consider the cost of memoization before you decide to use it. Are you declaring a lot of variables or performing tasks with long execution times? If not, then memoization may not be worth it. The extra overhead for using the hook may end up costing more resources than calling a function directly.