Skip to main content

Optimising for Performance

Introduction

In most cases performance issues occur in the JS thread, not the native thread. This is easily observed using the perf monitor overlay available from the developer menu. This displays the current frames per second (FPS) for both the UI (native) thread and the JS thread. For the most part, the UI thread stays around 60 fps. This is even true for lower end devices. The JS thread however tends to be much lower, and often will only sit at 60 FPS while the app is idle, and in some instances drops as low as ~0 FPS.

perf monitor

These frame drops are mostly occurring as a result of performance issues around component renders, particularly those of complex UI elements. Many screens throughout the app render a large number of visual components, and even small performance issues in a component can balloon into the significant frame drops we are seeing.

This section aims to cover best practices when working on the project and React Native with the goal of improving component renders and maintaining as close to 60 FPS on the JS thread as possible.

Much of this section will implement many of the things covered in the sections on memoising vales and memoising components so it's important you're all over that stuff to really be able to put these tips into practice.

Avoiding unnecessary renders

Using the Profiler, a tool such as why-did-you-render or even a console.log() at the top level of a component, it's possible to observe superfluous renders occurring at each level of the component tree. Once you have observed these renders you can compare that to what you would expect for the given user interface. For example, if over the course of 30 seconds a UI element only visually changes twice, but you observe 30 re-renders, that could mean an issue somewhere in the component tree. In the case where that component is expensive to re-render, you could be looking at the source of frame drops in the JS thread.

Component tree remounts

In this example, the component seems fairly innocuous and does what plenty of other React components do.

const GQLClientProvider = ({ children }) => {
const [gqlClient, setGqlClient] = useState(undefined);

useEffect(() => {
// Fetches config and calls `setGqlClient` with the retrieved value
}, []);

return gqlClient ? (
<GQLProvider value={gqlClient}>{children}</GQLProvider>
) : (
<>{children}</>
);
};

Because the value of gqlClient has an initial value of undefined, the conditional in the return statement will return the right hand side of the ternary. Shortly after, the setGqlClient state setter hook is called with a new value, which triggers a re-render of the provider. As a result gqlClient is now truthy and the component returns the left hand side of the ternary, which will unmount the complex tree of components that is the children rendered beneath this provider, and mount a completely new tree of components.

Unnecessary renders of invisible components

In some cases it's possible to observe renders that do not need to take place, and can't be seen visually. An example is list components, where each list item renders a loading component while loading before rendering the actual list item. In a list that exceeds the amount of items that can be rendered on the screen, it means those loading components don't need to be rendered and the overall render time of the component is impacted.

const ListContainer = ({ data }) => {
const LoadingComponent = useMemo(
() => (
<>
{Array.from({ length: 10 }, (_, index) => (
<ListItemSkeleton />
))}
</>
),
[skeletonCount]
);

return <FlatList data={data} LoadingComponent={LoadingComponent} />;
};

In this example, if there was 150 items in a list we don't need to render 150 skeletons. Instead we can just render enough to fill the screen (in this example 10 skeletons) and give the impression that we are loading a lot of elements, when in reality we are only rendering the bare minimum

Minimising renders triggered by useEffect

Another common cause of unnecessary renders is updating state in useEffect. For example, here is an old version of useSettingsSelector:

export const useSettingsSelector = <T extends UserSettingsKey>(
persistSetting: T,
fallback?: UserSettings[T],
useCache = false
) => {
const clientId = useSelector((state) => state.auth.clientId ?? "anonymous");
const result = useSelector(
(state) =>
state.settings?.[clientId]?.[persistSetting] ??
fallback ??
INITIAL_SETTINGS[persistSetting]
);
const [cachedValue, setCachedValue] = useState(result);

// Only return updated value if client id changes
useEffect(() => {
setCachedValue(result);
}, [clientId, useCache ? null : result]);

return cachedValue;
};

This hook fetches the setting value from the Redux store, but rather than just returning that value it first returned the previous value. It then triggered a re-render with the new value via the state setter inside the useEffect hook. By just using the value returned from the selector it effectively removes the need for a render all together.

Where possible, derive state from values equal to the initial state value, rather than setting a falsy state value just to update it on mount. See a potential solution to this problem.

Memoising renders

Re-renders caused by a parent component

Consider memoising components to reduce renders, particularly when those components are observed regularly re-rendering with the same props. By default React.memo will perform a shallow equality comparison on its props and skip rendering if their values are unchanged.

For more information on using React.memo check out the section on memoising components.

Object and array literals, inline arrow functions

The comparison method used by React will only compare the identity of non-primitive props, it does not perform a deep comparison. This means passing object and array literals, and inline arrow functions can cause superfluous re-renders on memoised components. In each of the below examples the component will re-render:

<MemoisedComponent objectProp={{ ... }} />
<MemoisedComponent arrayProp={[ ... ]} />
<MemoisedComponent functionProp={() => { ... }} />

Each of those props will be recreated when the parent component re-renders, giving them a new reference and breaking memoisation of the consuming component. To avoid this issue, you can use useMemo for non-primitive values and useCallback at component call sites in order to maintain a safe memory reference between renders, rather than giving them new identities despite the same logical value. It's important that you pass correct, similarly optimised deps to the dep arrays on these hooks so they are only redeclared when necessary.

For more information check out the section on optimising values.

Default values passed as props or dependencies

A more subtle variation of the above issue is in the declaration of non-primitive default values.

const OuterComponent = ({ value = {} })  => {
return <MemoisedComponent value={value} />
}

const OuterComponent = () => {
const value = useSelector(...) ?? [];
return <MemoisedComponent value={value} />
}

In this case any fallback to the right hand side of the ?? operator will declare an empty array or object with a new reference each render. This can defeat component memoisation if that default value is used. It can be easily solved by just declaring the default value in the outer scope of a file and using that.

const EMPTY_ARRAY = [];
const OuterComponent = () => {
const value = useSelector(...) ?? EMPTY_ARRAY;
return <MemoisedComponent value={value} />
}

Setting the key prop on list items

When rendering components in a list, whether that be a FlatList or just via .map(), it is important that the key property is set and its value is derived from a unique piece of element data, not the index of the element. This will allow React to effectively reuse the item.

Using index in the testID and other props on lists

Similar to the above note, you should avoid using the index of an element to create props. Doing so could mean a re-render of an item even though its output has stayed the same. An example of this is in a testID - the value of a testID shouldn't determine renders, but if you base the ID off of the element index it can induce unnecessary renders.

// DON'T DO THIS
testID={`list-item-${index}`}

Conditionally rendering components

It's common to write components that render a bunch of elements if one condition is fulfilled, and another element or null if another condition is fulfilled. The main instance you'll encounter of this is rendering a loading component if a loading value is true, null or an error if an error value is true, and the component if neither of those conditions is true and you have the correct data.

When writing components like this, a handy pattern to employ is the use of a container which is responsible for determining those conditions, and then moving all component specific logic to each individual child component. In doing so, you defer any additional computations performed by a component to when they're actually needed.

export const Race = () => {
const { loading, error, data } = useQuery(RACE_QUERY);

const { race } = data;

const { availability } = useRacingAvailability(race);

const entrantsForRace = useSelector((state) =>
selectEntrantsForRace(race.id)
);

const filteredRaces = useMemo(() => {
return complexFilteringFunction(race);
}, [race]);

// even more hooks, logic and declarations

if (loading) return <LoadingComponent />;

if (error) return null;

return (
<View onLayout={onLayout}>
<FlatList {...props} />
</View>
);
};

In this example our Race component is sending off a graphql query, and depending on that response it may return either a loading component, null or the component. There is a chance for optimisation here though, because if error is truthy, the component is just returning null, meaning all those effects, hooks, and other component logic are running unnecessarily. As soon as error or loading is truthy, we can ditch any logic we would run if that component renders.

This example is a little contrived, but it illustrates a very common pattern throughout the codebase.

So how can this be optimised?

// This is the `container`.
export const Race = () => {
const { loading, error, data } = useQuery(RACING_QUERY);

if (loading) return <LoadingComponent />;

if (error) return null;

return <RaceView race={data.race} />;
};

// This is our `view`.
export const RaceView = ({ race }) => {
const { availability } = useRacingAvailability(race);

const entrantsForRace = useSelector((state) =>
selectEntrantsForRace(race.id)
);

const filteredRaces = useMemo(() => {
return complexFilteringFunction(race);
}, [race]);

// even more hooks, logic and declarations

return (
<View onLayout={onLayout}>
<FlatList {...props} />
</View>
);
};

In this example we've run the bare minimum amount of logic necessary to determine if a component will be rendered, and in doing so have deferred all that logic exclusive to the actual RaceView to run when it's needed, not when we're just going to show a loading component or null. This means we have reduced the amount of unnecessary computation being run, which can potentially result in some big performance gains depending on the component.

We have also given ourselves the guarantee at runtime that our race data is there and our various functions won't throw an error because they'll only ever run when that value is truthy. Much better than a bunch of if (race) checks throughout it all.

Another common use case for this is feature flagging. If a feature is disabled, then we know ahead of time that we don't need to run any logic exclusive to a component.

export const FeatureFlaggedComponent = () => {
const isEnabled = useFeatureFlag(FEATURE_FLAG);

if (!isEnabled) return null;

return <FeatureFlaggedComponentView />
}

const FeatureFlaggedComponentView = () => {
// a bunch of expensive logic and effects

return ( ... )
}

In this example we have made sure we aren't running expensive component logic, e.g. hooks/effects/array filtering, unless necessary.

This also applies to child components that don't need to re-render when the state of their parent changes. Consider the following component structure:

const ParentComponent = () => {
const isFocused = useIsFocused();
usePoll(() => { ... }, { shouldPoll: isFocused });

return <ChildComponent {...otherProps} />
}

The hooks of that component have no effect on the ChildComponent, which means re-rendering it when they change is unnecessary. To address this we can split it out:

const ParentComponent = () => {
const isFocused = useIsFocused();
usePoll(() => { ... }, { shouldPoll: isFocused });

return <MemoisedChildComponent {...otherProps} />
}

const MemoisedChildComponent = React.memo((props) => {
return <ChildComponent {...otherProps} />
});

Now as long as the props of the MemoisedChildComponent don't change, it won't re-render even if the ParentComponent state updates trigger a re-render.

Reducing render times

A common React heuristic when making optimisations is to address the slow render before the re-render. React is, for the most part, render happy. This means that a couple extra renders isn't necessarily the end of the world, so if your UI is slow and you are dropping frames it could be worth examining why your renders are slow before moving onto reducing them.

To do this, it's important to establish a baseline render time with which to evaluate the effectiveness of any optimisations you apply. You can then start to break down that render time between the component and all of its child components and further descendants. This way you can work on the more expensive renders and not waste time on the inexpensive ones.

Ideally this will allow you to isolate problem components and score wins across the whole render tree by fixing them. In some cases though the issue can be spread between multiple child components, and this will require more work in addressing.

You can fix this render time by implementing any or all of the above tips.

Colocating state

Proper state management can at the very least save renders, and in some cases drastically improve performance. Let's look at an example:

export const Component = () => {
const [currentRace, setCurrentRace] = useState([]);

// logic

return (
<>
<ChildComponent
currentRace={currentRace}
setCurrentRace={setCurrentRace}
/>
<OtherChildComponent />
</>
);
};

In this example we can see that the currentRace state probably doesn't need to live inside Component, and every time that piece of state changes it will re-render all of its children. The only component that really cares about this state though is ChildComponent, because that's the only component that consumes it.

This demonstrates why it's important that state lives as close to where it's consumed as possible. This will help to prevent components re-rendering when irrelevant state changes occur. An improved version of the above example would be:

export const Component = () => {
// logic

return (
<>
<ChildComponent />
<OtherChildComponent />
</>
);
};

const ChildComponent = () => {
const [currentRace, setCurrentRace] = useState([]);

return <View />;
};

This way we are isolating re-renders to where they need to happen, nowhere else. This also applies to any instances of state from useReducer, context, and redux selectors.

Kent C. Dodds has a great article on this topic https://kentcdodds.com/blog/state-colocation-will-make-your-react-app-faster

Use purpose built components

React Native exports a number of key components for achieving certain layouts. These components are optimised out of the box for their specific task, and come with various props that allow you to maximise their performance. A perfect example of this is FlatList, which provides optimised, virtualised lists out of the box. For more information on optimising lists check out the dedicated section.

Measure early and measure often

It's important to measure at the start, during, and after your work. That baseline can then be used to evaluate any changes to the component, as well as any optimisations you make. It is also crucial in catching issues early, because catching excessive re-renders in development is much better than catching them when a user complains the UI is slow and unresponsive.

Use the React DevTools profiler, manually test your work on devices, or even just console log something to get an idea of how something is performing, and how often it is rendering. Keep the RN performance monitor open as often as is practical, and keep an eye out for any frame drops.

For more information on using the profiler check out our dedicated section on profiling with React Dev Tools.

Test on low end devices

It is critical that you test your work on low end devices. It's easy to develop using the iOS simulator, but performance there isn't remotely comparable to performance on an older Android device, for example. To correctly gauge the true performance of your work it should be tested on a range of lower end devices.

Measurements are generally based on execution time, and more specifically component render time and render count. To measure these you can use the React DevTools Profiler (launched via npx react-devtools), as well as the perf plugin tool developed by Callstack.

On top of measuring total execution time of a given operation, you should also analyse how that breaks down into its smaller composite parts. In the case of components, you should measure how long component logic takes to execute as well as render time of a component's children. Doing this will allow you to reliably locate the source of performance issues.

The following is a list of lower end devices we support as of 01 July 2022. They provide a good place to start in your testing.

⚠️ TODO - INSERT LIST HERE ⚠️

Seriously consider if you need to install a package

As Javascript developers, particularly in the React ecosystem, it's pretty standard practice to bring in a third party package to solve issues or implement features. Javascript as a whole supports this idea of reusable, shareable modules, allowing us to minimise code duplication and the reinvention of the wheel. It's fairly standard practice to yarn add moment axios lodash once your create-react-app command has finished.

The issue in doing this is that many of these packages are created to be run on the web, or on a node server somewhere. The unique constraints of mobile devices, particularly in a Javascript application, means we need to be very conscious of inducing bundle size increases and dependencies when we install these packages.

Essentially, the more code in the overall app bundle, the longer the app will take to load. In a React Native app, being Javascript, an easily overlooked source of large bundle sizes is excessive third party packages. This is made worse by the fact that React Native doesn't support tree shaking meaning even unused code will remain in the final JS bundle.

If you require a package to solve a problem or achieve functionality, first consider if it's something you can solve yourself. The codebase might already have an example of it being handled, or other developers on the team may have solved the issue in the past and have a good idea how to handle it.

If it is necessary to install a third party package, be sure to use something like bundlephobia to evaluate the size of the package. Also check to see if the package supports importing only subsets, for example lodash allows you to import only the function you need directly:

// DO THIS
import isEqual from "lodash/isEqual";

// NOT THIS
import { isEqual } from "lodash";

Many packages have mobile or React Native specific versions, so always check for them first. Using web first packages can mean massively increased bundle sizes and load times that could be avoided using mobile versions of the package.