Mastering Data Types in JavaScript and their impact on React Re-rendering
JavaScript, like many other programming languages, categorises its data types into primitive types and reference types. Understanding the difference between these two categories is fundamental to mastering JavaScript, as it influences how data is stored, manipulated, and passed around in your code. This knowledge becomes even more crucial when working with React, where the efficiency of component re-renders hinges on understanding these data types and their implications. In this blog post, we will explore what primitive and reference types are, and why understanding these concepts is essential to writing performant, optimised, React code.
Primitive Types
Primitive types are the simplest forms of data in JavaScript. They include:
-
String
-
Number
-
Boolean
-
Null
-
Undefined
-
Symbol
-
BigInt
Primitive types have two key characteristics: they are immutable and they are stored directly in the variable's memory space. Immutability means that once a primitive value is created, it cannot be changed. Any operation that appears to modify a primitive value actually creates a new value, and when it comes to equality checks Javascript will check the values themselves to check whether or not they are equal.
Example of Primitive Types
let a = 10;
const b = a; // b is now 10
a = 20; // a is now 20
console.log(a); // Output: 20
console.log(b); // Output: 10
In the example above, b is assigned the value of a. However, when a is changed to 20, b remains 10. This is because a and b are independent copies of the primitive value 10, thus changing one does not affect the other.
const a = 10
const b = 10
console.log(a === b) // Output: true
In the example above, we have set two variables to the same number independently, and because Javascript is comparing the value of the two variables directly, it can correctly assert that the two are identical.
Reference Types
Reference types, on the other hand, are more complex structures. They include:
-
Object
-
Array
-
Function
Reference types are mutable, meaning their contents can be changed without altering the reference itself. When a reference type is assigned to a variable, what gets stored in the variable is a reference (or pointer) to the location in memory where the data is actually held, not the data itself.
Example of Reference Types
const obj1 = { name: "John" };
const obj2 = obj1; // obj2 now references the same object as obj1
obj1.name = "Doe"; // Changing the name property of obj1
console.log(obj1.name); // Output: Doe
console.log(obj2.name); // Output: Doe
In this example, obj2 is assigned the same reference as obj1. Therefore, when we change the name property of obj1, the change is also reflected in obj2 because both variables reference the same object in memory.
const obj1 = { name: "John" };
const obj2 = { name: "John" };
console.log(obj1 === obj2) // Output: false
const obj3 = obj1
console.log(obj1 === obj3) // Output: true
When it comes to equality checks, unlike with primitives, Javascript will not compare the values themselves, but instead will compared the memory reference of each. If the memory addresses are the same, then the values will be equal, whereas if they are different, then the values will not be (even if, on the face of it, the two reference types look equal).
In the example above, we have separately instantiated two, seemingly identical, objects: obj1 and obj2. Because reference types are compared not by their value, but instead their memory address, an equality check between these two yields false, as the two objects occupy different memory addresses. obj3, on the other hand, is made to equal obj1, which means that both variables will be pointing to the same memory address, and thus an equality check between them yields true.
React and Optimising Re-renders
Note: with the advent of React 19, and it’s introduction of a compiler which can check your code directly, the optimisation strategies described here may well no longer be necessary. For any React code pre-dating React 19, however, this advice remains highly relevant.
When working with React, understanding the difference between primitive and reference types is crucial due to the way React decides whether or not to re-render a component. The logic that React uses to determine whether or not a re-render is necessary is relatively straightforward, as it simply asks the following questions:
-
Has the parent of this component changed?
-
Have the props or state of this component changed?
If the answer to either of those questions is yes, then the component will re-render, otherwise it will not.
But how does it know whether or not the props or state have changed? Well, it simply compares the two versions (current, and incoming) using an equality check, and if they are deemed unequal, then React will assume that they have changed, whereas if they are said to be equal, then React will assume that they have not. This, surface level, equality check is known as a ‘shallow equality check’, whereas actually checking the values within the reference values is known as a ‘deep equality check’.
Naturally, therefore, React is very good at determining whether or not primitive props and state have changed, but not very good at determining whether or not reference props and state have changed. The problems that can occur with reference types being used for props or state are the following:
-
Not re-rendering when it should: If a reference type value has been mutated, then React will not re-render. If you intended for it to re-render, this will be problematic as the page will appear stuck, not changing when you expect it to.
-
Re-rendering when it shouldn’t: If a reference type value changes immutably when there is no real change, then React will re-render unnecessarily, undermining one of the primary benefits of using React which is efficient re-renders. This will be experienced in the form of performance hits, which can be quite significant depending on what it is you are rendering.
Here are some practical examples of how to avoid the above scenarios and achieve the perfect balance of the minimum number of necessary re-renders.
Practical Examples
Mutating Objects and Arrays vs. Replacing Them Immutably
Consider a scenario where you have a state variable that holds an array, and you want to update this array:
const [items, setItems] = useState([1, 2, 3]);
// Incorrect way: Mutating the array directly
items.push(4);
setItems(items);
// React will not re-render the component
// Correct way: Creating a new array reference
setItems([...items, 4]);
// React re-renders the component
In the incorrect approach, the items array is mutated directly, so the reference remains the same. React will not detect this change due to its shallow equality checks, leading to no re-render. In the correct approach, a new array is created using the spread operator, resulting in a new reference, which React detects and triggers a re-render.
Similarly, when dealing with objects:
const [user, setUser] = useState({ name: "John", age: 30 });
// Incorrect way: Mutating the object directly
user.age = 31;
setUser(user); // React will not re-render the component
// Correct way: Creating a new object reference
setUser({ ...user, age: 31 }); // React re-renders the component
By creating a new object with the updated values, you ensure that React detects the change and re-renders the component accordingly.
Anonymous Functions vs. Memorized Functions with useCallback
Passing anonymous functions directly to child components can cause unnecessary re-renders because React treats them as new references on each render. To avoid this, use the useCallback hook to memoize the function.
const MyComponent = () => {
const [count, setCount] = useState(0);
// Incorrect way: Passing an anonymous function directly
return setCount(count + 1)} />;
};
const ChildComponent = ({ onClick }) => {
console.log('ChildComponent rendered');
return <button onclick="{onClick}">Increment;
};
In the example above, every time MyComponent re-renders, a new function is created and passed to ChildComponent, causing ChildComponent to re-render unnecessarily. To avoid this, you can use the useCallback hook:
const MyComponent = () => {
const [count, setCount] = useState(0);
// Correct way: Using useCallback to memoize the function
const handleClick = useCallback(() => {
setCount(count + 1);
}, [count]);
return ;
};
const ChildComponent = ({ onClick }) => {
console.log('ChildComponent rendered');
return <button onclick="{onClick}">Increment;
};
In this corrected example, handleClick is memoized with useCallback, so it only changes when count changes, preventing unnecessary re-renders of ChildComponent.
Using React.memo to further control re-renders
Earlier I described the two different questions that React will ask to determine whether or not a component should be re-rendered. However, in some instances it would be better if a child component didn’t re-render just because it’s parent did. For example, perhaps you have a prop or state that you wish to update which is at the top of your component tree, and deeper down in the tree you have a component which is computationally expensive to re-render. In that instance, it would likely be preferable that you only update the computationally expensive component when it's props or state have changed, not when it’s parent has re-rendered.
To that end, React offers the react.memo function. Wrapping a component in this function will cause the component to become a ‘pure component’, which is to say that it only updates when it’s props or state have changed. For example:
const ExpensiveComponent = React.memo(({ data }) => {
return (
<div>
<h1>Heavy Computation Component</h1>
</div>
);
});
In this example, ExpensiveComponent is wrapped with React.memo, which makes it only re-render when its props or state have changed. This can significantly improve performance, especially in large applications where unnecessary re-renders can cause sluggish behavior. However, while a judicious application of React.memo can substantially reduce unnecessary re-renders, be mindful of the memory overhead associated with its use, as the memoization logic involves storing the previous state of the component to avoid unnecessary updates. This article goes into more detail about how, when, and why you should use React.memo.
Conclusion
Understanding the difference between primitive and reference types in JavaScript is essential for writing efficient and bug-free code, especially when working with React. Primitive types are immutable and stored directly in the variable's memory space, while reference types are mutable and stored as references to a memory location. This distinction significantly impacts how state and props are managed in React, affecting the performance and behaviour of your components. By mastering these concepts, and using tools like useCallback and React.memo, you can optimise your React applications and ensure efficient re-renders.