Mobile developer working with React Native
MOBILE DEVELOPMENTREACT NATIVE
12/08/2021 • Ruben Coucke

4 tips for improving the performance of your React Native application

React Native is a very useful framework for creating multi-platform mobile applications, but have you ever felt like it was slow? This might be because you’re doing a lot more re-renders than necessary. In this article, we’ll go over 4 tips to reduce the amount of re-renders, improving the general performance of your React Native application. However, keep in mind that reducing the amount of re-renders of components is not always more performant, especially if the components are very simple.

The examples in this blog post will give you an idea on how to reduce re-renders, but will not really improve performance (it might even slow it down) because the components and calculations used here are very simple.

1. Memoize your components

One of the most important steps in improving the performance of a React Native application is to memoize the components that you’re using. In short, this means just surrounding your component with React.memo, but there’s a lot more to it.

Take a look at the following example:

const BigExpensivePureComponent = () => {
  return <View/>
}
 
export const HelloWorldScreen: React.FC = () => {
   const [counter, setCounter] = useState<number>(0);
  
  return (
     <>
        <TouchableOpacity onPress={() => setCounter(counter + 1)}/>
        <BigExpensivePureComponent />
     </>
  )
}

In the above example, every time the TouchableOpacity is pressed, the HelloWorldScreen re-renders and with it, its children re-render. This is not really necessary because the result of BigExpensivePureComponent will always be the same. Preferably, whenever HelloWorldScreen re-renders, the same BigExpensivePureComponent as the one in the previous render is reused. This is where React.memo comes into play. By surrounding the BigExpensivePureComponent with React.memo, we make sure that the result that is rendered by the BigExpensivePureComponent is reused every time its parent component is re-rendered.

const BigExpensivePureComponent = React.memo(() => {
  return <View/>
})

Of course, this doesn’t mean that a memoized component never re-renders. There are three possible ways that a memoized component will re-render:

  • The internal state of the memoized component changes
  • A property of the memoized component changes
  • The propsAreEqual function returns false

1.1 The internal state of a memoized component changes

A memoized component will re-render whenever the internal state of that component changes. The following example will update the counter value by 1 every 100 milliseconds.

const BigExpensivePureComponent = React.memo(() => {
   const [counter, setCounter] = useState<number>(0);
 
   useEffect(() => {
       const timeout = setInterval(() => {
           setCounter(lastValue => lastValue + 1)
       }, 100)
       return () => {
           clearTimeout(timeout)
       }
   }, [])
  
   return <Text>{counter}</Text>
})

In this example, even though the component is memoized, it still re-renders because the internal state of the component changes. If this wasn’t the case, React.memo would be very useless.

1.2 A property of the memoized component changes

The following example does the same as the previous, except this time the counter value is passed to the BigExpensivePureComponent as a property.

const BigExpensivePureComponent = React.memo<{counter: number}>(({counter}) => {
   return <Text>{counter}</Text>
})
 
export const HelloWorldScreen: React.FC = () => {
   const [counter, setCounter] = useState<number>(0);
 
   useEffect(() => {
       const timeout = setInterval(() => {
           setCounter(lastValue => lastValue + 1)
       }, 100)
       return () => {
           clearTimeout(timeout)
       }
   }, [])
 
   return <BigExpensivePureComponent counter={counter}/>;
}

Because the counter is passed in as a property, the BigExpensivePureComponent re-renders every time the counter value changes. If the counter value does not change, but for some reason the HelloWorldScreen does re-render, the BigExpensivePureComponent does not re-render and instead uses its value from the previous render.

1.3 The propsAreEqual function returns false

The React.memo function has a second parameter called propsAreEqual which can be set to modify the behavior of when a memoized component re-renders.

The following example explains this further:

const BigExpensivePureComponent = React.memo<{counter: number}>(({counter}) => {
   return <Text>{counter}</Text>
}, ((previousProps, nextProps) => {
   return previousProps.counter === nextProps.counter;
}));

In this example, you can see that the second parameter of React.memo is filled in. This function receives 2 parameters: the property values before the re-render of this component and the values of the properties after a possible re-render. It returns a Boolean that says whether the properties received by the memoized components are equal or not. Whenever this method returns true, it means that the component will return its memoized value instead of re-rendering the component. It’s possible to just always return true in case you never want the component to re-render, even if the property values change.

2. React Native checks on equality by reference, not by value

It’s very important to mention that the following tips only apply to memoized components (most default React Native components are already memoized components). Imagine that you have a screen HelloWorldScreen with a memoized component JoinStrings like this.

const HelloWorldScreen: React.FC = () => {
	const stringsToJoin = ['hello', 'world'];
 
	return (
		<JoinStrings strings={stringsToJoin}/>
	)
}

As you can see, the JoinStrings component has a strings property that takes in an array of strings. If this is your whole screen then this is okay. HelloWorldScreen will never re-render and as such, JoinStrings will never re-render. The total amount of renders on this screen is 1 for HelloWorldScreen and 1 for JoinStrings. Now imagine this screen changes to:

const HelloWorldScreen: React.FC = () => {
	const [counter, setCounter] = useState<number>(0);
	const stringsToJoin = ['hello', 'world'];
 
	return (
		<>
			<TouchableOpacity onPress={() => setCounter(counter + 1)}/>
			<JoinStrings strings={stringsToJoin}/>
		</>
	)
}

Now, every time a user presses the TouchableOpacity, the counter will go up by one and because this changes the state of the counter, the whole screen re-renders. Because the whole screen re-renders, the stringsToJoin array is re-created and passed into the JoinStrings component. You’d think this is fine, because the array contains the same values, but React Native thinks that this array is a new array and re-renders the JoinStrings component.

The same can be said with functions and objects as well. In the above example, the TouchableOpacity component will also re-render when it’s pressed, because the onPress property of that component is re-created on each re-render. So in total, 3 re-renders take place when pressing this one button (once for the HelloWorldScreen, once for the JoinStrings component and once for the TouchableOpacity component)!

Why is this? Because React Native does not check arrays, functions and objects by value, but rather by reference. This is very important to keep in mind when working on improving the performance of your application. This is different when passing in a Boolean, a string or a number to a property of a component. As an example:

const HelloWorldScreen: React.FC = () => {
	const [counter, setCounter] = useState<number>(0);
	const stringsToJoin = 'hello,world';
 
	return (
		<>
			<TouchableOpacity onPress={() => setCounter(counter + 1)}/>
			<JoinStrings string={stringsToJoin}/>
		</>
	)
}

This time, the JoinStrings component receives a string as a property and now every time the button is pressed, only the HelloWorldScreen will re-render, because the value of stringsToJoin stays the same.

So what if you don’t want a component that has an array property to re-render every time the parent component re-renders and the property value stays the same? You have to make sure the reference of the array stays the same. You can do this in several ways, which I’ll describe in the following tips.

3. The useRef, useMemo and useCallback hooks

Continuing with the previous examples, let’s describe ways to decrease the amount of re-renders of child components that have an array, object or function as property. However, it’s not always best to use these hooks to optimize performance. In short, the useMemo and useCallback hooks should really only be used for computationally expensive calculations or for reference equality. For more information regarding when it’s best to use these hooks, see here.

3.1 The useRef hook

In the following example, both the TouchableOpacity and the JoinStrings component will not re-render every time the button is pressed. This is because the reference of the properties passed into those components stays the same. The result is that only 1 re-render in total happens: the re-render of HelloWorldScreen.

const HelloWorldScreen: React.FC = () => {
	const [counter, setCounter] = useState<number>(0);
	const stringsToJoin = useRef(['hello', 'world']).current;
	const updateCounter = useRef(() => setCounter(counter + 1)).current;
 
	return (
		<>
			<TouchableOpacity onPress={updateCounter}/>
			<JoinStrings strings={stringsToJoin} />
		</>
	)
}

The useRef hook creates a new value that always keeps the same reference to that value. You can use this hook for values that never need to change. In this case, it’s okay to use it for stringsToJoin because the value will always stay the same. Using it for updateCounter is wrong, because the counter value inside const updateCounter = useRef(() => setCounter(counter + 1)).current; is 0 when the reference is created. That means it will always be 0 whenever the updateCounter method is called. This is very important to keep in mind, because now the counter will now never go above 1, even if the button is pressed 100 times. This is a limitation of using the useRef hook.

The useRef hook can also be used to store values that you want to use inside your component, but you don’t want these values to cause re-renders. Take a look at the following example:

const HelloWorldScreen: React.FC = () => {
  const [counter, setCounter] = useState<number>(0);
  const stringsToJoin = useRef(['hello', 'world']).current;
  const updateCounter = useRef(() => {
     setCounter(counter + 1);
     stringsToJoin.push(`${counter}`)
  }).current;
 
  useEffect(() => {
     console.log('Inside the useEffect hook.')
  }, [stringsToJoin])
 
  return (
     <>
        <TouchableOpacity onPress={updateCounter}/>
        <JoinStrings strings={stringsToJoin} />
     </>
  )
}

In this example, the stringsToJoin value updates every time the button is pressed, but it never prints the line ‘Inside the useEffect hook.’. This is because even though the value of stringsToJoin updates, React Native doesn’t see it as a change that should cause re-renders and so it never goes into the useEffect.

The final usage of useRef is to call methods on components. For example:

const HelloWorldScreen: React.FC = () => {
	const [counter, setCounter] = useState<number>(0);
	const buttonRef = useRef<TouchableOpacity>();
	const stringsToJoin = useRef(['hello', 'world']).current;
	const updateCounter = useRef(() => setCounter(counter + 1)).current;
 
	const someMethod = () => {
	   buttonRef.current?.setOpacityTo(0.5)
	}
 
	return (
		<>
			<TouchableOpacity ref={buttonRef} onPress={updateCounter}/>
			<JoinStrings strings={stringsToJoin} />
		</>
	)
}

By creating an empty ref, we can assign this ref to the TouchableOpacity ref property. Afterwards, we can use this ref to call methods on it. In this case, we can call the method someMethod to change the opacity of the TouchableOpacity to 0.5.

In short, the useRef hook can be used for 2 purposes:

  • Storing a value and updating it without causing any re-renders
  • Storing a reference to a component and use it to call methods on that component

3.2 The useCallback hook

In the previous examples, the updateCounter method would never be recreated and thus we would never be able to let the counter value go above 1. This can be fixed with the useCallback hook. The useCallback hook is meant for memoizing functions so that they will be equal by reference. As a result, components that receive this function don’t re-render solely because the reference of the function changed. The following example shows the usage of this hook.

const HelloWorldScreen: React.FC = () => {
  const [counter, setCounter] = useState<number>(0);
  const [stringsToJoin, setStringsToJoin] = useState<string[]>(['hello', 'world']);
  const updateCounter = useCallback(() => setCounter(lastValue => lastValue + 1), [setCounter]);
 
  return (
     <>
        <TouchableOpacity onPress={updateCounter}/>
        <JoinStrings strings={stringsToJoin} />
     </>
  )
}

The first parameter of the useCallback hook is the function itself that you want to memoize. The second parameter is the dependencies array. Whenever a value changes that is inside this array, the updateCounter will update its value and reference. In this example, this means that whenever the setCounter value changes, we re-create the updateCounter function, allowing the counter value to go higher than 0.

Now imagine that somewhere in the HelloWorldScreen, we call setStringsToJoin([‘new’, ‘hello’, ‘world’]). This would cause a re-render but only for the HelloWorldScreen and JoinStrings component, not the TouchableOpacity because the reference to the updateCounter did not change (the setCounter value did not change).

In short, if you want to make sure that a child component that receives a function as a property does not re-render every time the parent component re-renders, you make sure that the function is created using a useCallback hook with the correct dependency array.

3.3 The useMemo hook

Take a look at the following example:

const HelloWorldScreen: React.FC = () => {
  const [counter, setCounter] = useState<number>(0);
  const stringsToJoin = ['hello', 'world', `${counter}`];
 
  return (
     <>
        <TouchableOpacity onPress={() => setCounter(counter + 1)} style={style}/>
        <JoinStrings strings={stringsToJoin} />
     </>
  )
}

In this example, the last value of the stringsToJoin array will always be the value of the counter. However, as said earlier in tip 2, if for some reason the HelloWorldScreen receives a new re-render for any other reason than the counter value changing, the JoinStrings component also re-renders.

Okay, a solution you might think of to fix this issue is the following:

const HelloWorldScreen: React.FC = () => {
	const [counter, setCounter] = useState<number>(0);
	const [stringsToJoin, setStringsToJoin] = useState<string[]>(['hello', 'world']);
 
	useEffect(() => {
		setStringsToJoin([stringsToJoin[0], stringsToJoin[1], `${counter}`]);
	}, [counter])
 
	return (
		<>
			<TouchableOpacity onPress={() => setCounter(counter + 1)}/>
			<JoinStrings strings={stringsToJoin} />
		</>
	)
}

In this example, each time the counter changes, we update the stringsToJoin array by using the useEffect hook. By passing in the state variable stringsToJoin to the JoinStrings component, we make sure that the JoinStrings component only re-renders when the values inside the stringsToJoin array change and this is a fine way of doing it. However, using useMemo, we can greatly reduce the amount of code needed to do this calculation:

const HelloWorldScreen: React.FC = () => {
  const [counter, setCounter] = useState<number>(0);
  const stringsToJoin = useMemo(() => ['hello', 'world', `${counter}`], [counter]);
 
  return (
     <>
        <TouchableOpacity onPress={() => setCounter(counter + 1)} style={style}/>
        <JoinStrings strings={stringsToJoin} />
     </>
  )
}

The useMemo hook works the same way as the useCallback hook, except that instead of returning a function, it returns a constant. The first parameter of the useMemo hook is a function that returns the constant that we want, the second parameter is again a dependency array.

In this case, the JoinStrings component only re-renders when the counter changes because only then does the stringsToJoin array reference change. In this example, it isn’t really worth making the stringsToJoin a useMemo or even a state variable. Imagine there’s some complicated and computationally expensive method to calculate the value of stringsToJoin, then we don’t want this calculation done every time the HelloWorldScreen re-renders. This is where useMemo really comes in handy.

In short, if you want to make sure that a child component that receives an object or an array as a property does not re-render every time the parent component re-renders, you can use a state variable or a constant created by useMemo with the correct dependency array. Keep in mind that whenever you see that you’re updating a state variable in a useEffect, see if you can make the code shorter by using useMemo to create the constant as this is cleaner and much easier to read.

4. Two usefel performance debugging libraries

4.1 Why did you render

WDYR is a very useful library, because it tells you why a Pure Component (a memoized component) re-renders! This comes in very handy when you see that a specific component re-renders a lot of times, but you don’t know why. For example: if you enable it for the code below, you’ll see that the library prints something to the console each time you press the button.

const HelloWorldScreen: React.FC = () => {
	const [counter, setCounter] = useState<number>(0);
	const stringsToJoin = ['hello’,’world'];
 
	return (
		<>
			<TouchableOpacity onPress={() => setCounter(counter + 1)}/>
			<JoinStrings strings={stringsToJoin}/>
		</>
	)
}

It will say that the TouchableOpacity re-renders because the onPress property changes and it’ll give you a before and after of that property. The same happens for the JoinStrings component. WDYR will print the reason why the JoinStrings component re-renders (in this case the stringsToJoin property) and you’ll see that the before value is exactly the same as the after value. By now, you know how to solve this. 😉

wdyr - why did you render

4.2 React DevTools

You may have already heard about React DevTools. This handy library is mainly used for React applications, but it can also be used for React Native applications. In my personal experience, the standalone package is easier to use when developing for React Native and so that is what I linked here. There are 2 main parts to the DevTools: Components and Profiler.

The Components part is useful for checking what the total hierarchy over your application is while also allowing you to check what properties are going into each specific component.

React devtools gif

The Profiler part is the most useful when trying to improve performance. It allows you to see a flame graph showing how long it took for each component to render. It also shows you the amount of times a component re-renders and much more. You can use this utility to keep track of whether or not the changes you’re making to your code are actually improving performance.

React DevTools screenshot

Takeaways for improving performance in React Native

  1. Memoize your components so that they don’t re-render, unless
    1. the internal state of the memoized component changes.
    2. a property of the memoized component changes.
    3. the propsAreEqual function returns false
  2. Keep in mind that React Native checks on equality by reference, not by value.
  3. If you don’t want a component with an array property to re-render every time the parent component re-renders and the property value stays the same, you can use
    1. the useRef hook, which creates a new value that always keeps the same reference to that value.
    2. the useCallback hook, meant for memoizing functions so that they will be equal by reference. As a result, components that receive this function don’t re-render solely because the reference of the function changed.
    3. the useMemo hook, which works the same way as the useCallback hook, except that instead of returning a function, it returns a constant.

Make use of performance debugging libraries such as WDYR and React DevTools to see which components are eating up valuable calculation time.