App Performance Best Practices
Since React Native for Vega apps are just React Native apps, public documentation on React Native best practices also apply. Consider these key best practices when building React Native for Vega apps, along with key differences for Vega compared to iOS or Android.
Avoiding re-renders and re-definitions
memo
Using React's memo
improves performance by preventing unnecessary re-renders of functional components. By default, React re-renders a component whenever its parent re-renders, even if its props haven't changed. React's memo
optimizes this by shallowly comparing the previous and new props, skipping a re-render if they are the same. This is beneficial for components that receive stable props that don't need to update frequently, such as list items, buttons, or UI elements with heavy rendering logic. When combined with useCallback
and useMemo
, memo
can also minimize wasted renders, improving efficiency in React apps.
By default, memo
performs a shallow comparison of props using the Object.is
static method to determine if a component should re-render. In some cases, you may need to provide your own arePropsEqual
function to customize this comparison. A common use case for this is when passing inline styles or anonymously defined prop values to your function component. In these cases, the props are equal by value but not in memory, so the Object.is()
comparison will mark them as not equal. A customized comparison is useful when dealing with complex objects or functions as props, where a shallow comparison isn’t sufficient. A custom comparison function allows finer control over when a component should update, improving performance in scenarios where you require deep comparisons or specific conditions.
Two useful tools to investigate React component re-renders the "Record why each component rendered while profiling" flag from the react-devtools
profiler, and the community package why-did-you-render (WDYR).
It's generally best to wrap the user defined function components in the memo
API. However, using memo
and useMemo
everywhere may not be useful. See React’s article, Should you add memo
everywhere? on when and how to use memo
and useMemo
.
React compiler and eslint-plugin-react-compiler
In React 19 release candidate (RC), React has introduced a static React code compiler that automatically applies memoization through memo
, useMemo
, and useCallback
. The compiler assumes your app follows the Rules of React, is semantic JavaScript or TypeScript, and tests for nullable and optional values and properties before accessing them. While React says "the compiler is experimental and has many rough edges", we recommend that you onboard to the react compiler's ESLint plugin, eslint-plugin-react-compiler
, which does not require upgrading to React 19. To onboard, see Installing eslint-plugin-react-hooks
.
Strict mode
Wrapping a React Native app in <StrictMode>
is beneficial because it helps identify potential issues in the app during development. <StrictMode>
runs additional checks and warns about deprecated lifecycle methods, unsafe side effects, and other potential problems that could lead to performance issues or bugs. It also double-invokes certain functions, like component constructors and useEffect
callbacks, so no unintended side effects result. This debugging tool helps developers catch problems early, leading to more stable and efficient apps. <StrictMode>
doesn’t impact production builds, but using it in development ensures best practices and prepares the app for future React and React Native updates.
Suspense and lazy
React's lazy
component and <Suspense>
boundary can significantly improve startup time and overall app performance. By enabling code splitting, React's lazy
allows components to load only when needed, reducing the initial bundle size and speeding up the app's initial render. React's <Suspense>
works alongside this by allowing the app to "suspend" rendering until the necessary components load, creating a smooth user experience without blocking the UI. This approach not only optimizes app startup times but also enhances bundle loading efficiency, loading only the required code for each view.
useCallback
Using useCallback
in React improves performance by memoizing function instances, preventing unnecessary re-creations on each render. useCallback
is beneficial when passing functions as props to child components. This helps avoid unnecessary re-renders when those components rely on referential equality checks. For instance, when it’s wrapped in React.memo
. useCallback
is also useful where functions are effect dependencies (useEffect
), preventing unintended re-executions of side effects. Use useCallback
judiciously, since excessive use may increase memory usage and complexity. When used correctly, useCallback
optimizes rendering efficiency and reduces performance overhead in React apps.
You should add items to the dependency array for useCallback
so the memoized function has access to the latest values of any reactive dependencies it references. Omitting dependencies can lead to stale closures, where the function continues to use outdated values from previous renders, which may cause unexpected behavior or bugs.
useEffect()
Although not specific to React Native, how you use React's useEffect
can impact performance. It is worth reading React's article, You Might Not Need an Effect. As always, ensure your useEffect
dependency array contains all reactive values (props, state, variables, and functions declared within your component body, etc.). Using the eslint-plugin-react-compiler will help you detect missing dependencies in your useEffect's dependency array.
useMemo
React's useMemo
improves performance by memoizing the result of expensive computations, preventing unnecessary recalculations on every render. Without useMemo
, functions that perform complex operations—such as filtering large lists, performing mathematical calculations, or processing data—would execute on every render, even if their dependencies haven't changed. By wrapping such computations in useMemo
, React only re-evaluates them when their dependencies update, reducing CPU usage and improving responsiveness. This is particularly useful in scenarios where a component frequently re-renders due to unrelated state changes. Use useMemo
selectively, since excessive use can add memory overhead without significant benefits.
You should add items to the dependency array for useMemo
to ensure that the memoized value is recalculated whenever any of its dependencies change. Omitting dependencies can result in stale or incorrect values, while unnecessary dependencies can cause unnecessary recalculations, reducing performance benefits.
useState and useTransition
Minimizing React's useState
usage can improve performance by reducing unnecessary re-renders. Every state update triggers a re-render, so excessive state management can lead to performance bottlenecks, especially in complex components. By minimizing useState
usage, such as by using refs (useRef
) for mutable values that don't affect rendering, or by deriving state instead of storing redundant values, you can make components more efficient. The useTransition
hook enhances performance by prioritizing urgent state updates, such as user input. At the same time, it defers less critical state updates such as search results. This prevents UI blocking, ensuring a smoother experience by keeping interactions responsive even during expensive state transitions. Wrapping set state calls in the startTransition
function from useTransition
allows React to interrupt renders for stale state updates, and prioritizes the render for the most recent state update.
Sample app
Before optimization
import React, { useState, useEffect } from 'react';
import {
View,
Text,
Button,
FlatList,
TouchableOpacity
} from 'react-native';
// Bad practice: Non-memoized data and function
const BadPracticeApp = () => {
const [count, setCount] = useState(0);
const [selectedItem, setSelectedItem] = useState(null);
// Expensive calculation on each render, and new function instance on BadPracticeApp level re-render
const sumOfSquares = (num) => {
console.log('Calculating sum of squares...');
return Array.from(
{ length: num },
(_, i) => (i + 1) ** 2
).reduce((acc, val) => acc + val, 0);
};
// data is recreated on every BadPracticeApp re-render
const data = Array.from(
{ length: 50 },
(_, i) => `Item ${i + 1}`
);
return (
<View style={{ flex: 1, padding: 20 }}>
<Text>Sum of squares: {sumOfSquares(10)}</Text>
<Button
title="Increase Count"
onPress={() => setCount(count + 1)}
/>
<FlatList
data={data}
keyExtractor={(item) => item} // anoynmous function used, so new function instance created on every re-render
renderItem={({ item }) => (
<TouchableOpacity onPress={() => setSelectedItem(item)}>
<Text
style={{
padding: 10,
backgroundColor:
item === selectedItem ? 'lightblue' : 'white'
}}>
{item}
</Text>
</TouchableOpacity>
)} // anoynmous function used, so new function instance created on every re-render and returned function component is not memoized
/>
</View>
);
};
export default BadPracticeApp;
After optimization
import React, {
memo,
StrictMode,
useState,
useMemo,
useCallback,
useTransition
} from 'react';
import {
View,
Text,
Button,
FlatList,
TouchableOpacity,
ActivityIndicator
} from 'react-native';
// Memoized Item component to prevent unnecessary re-renders
const Item = memo(({ item, isSelected, onPress }) => {
return (
<TouchableOpacity onPress={() => onPress(item)}>
<Text
style={{
padding: 10,
backgroundColor: isSelected ? 'lightblue' : 'white'
}}>
{item}
</Text>
</TouchableOpacity>
);
});
const BestPracticeApp = () => {
const [count, setCount] = useState(0);
const [selectedItem, setSelectedItem] = useState(null);
const [isPending, startTransition] = useTransition(); // For handling non-urgent updates
// Memoize the expensive calculation
const sumOfSquares = useMemo(() => {
console.log('Calculating sum of squares...');
return Array.from(
{ length: 10 },
(_, i) => (i + 1) ** 2
).reduce((acc, val) => acc + val, 0);
}, []); // Only recalculated if dependencies change (none in this case)
// Memoize the data to avoid unnecessary array recreation
const data = useMemo(
() => Array.from({ length: 50 }, (_, i) => `Item ${i + 1}`),
[]
);
// Memoize the renderItem function to avoid unnecessary re-creations
const renderItem = useCallback(
({ item }) => (
<Item
item={item}
isSelected={item === selectedItem}
onPress={handleSelectItem}
/>
),
[selectedItem] // Only re-created when selectedItem changes
);
// Simulate a delayed process (e.g., fetching additional data) when an item is selected
const handleSelectItem = (item) => {
startTransition(() => {
setSelectedItem(item); // Mark the selected item
});
};
return (
<StrictMode>
<View style={{ flex: 1, padding: 20 }}>
<Text>Sum of squares: {sumOfSquares}</Text>
<Button
title="Increase Count"
onPress={() => setCount(count + 1)}
/>
<Text>Count: {count}</Text>
<FlatList
data={data}
keyExtractor={(item) => item}
renderItem={renderItem}
getItemLayout={(data, index) => ({
length: 50,
offset: 50 * index,
index
})}
/>
{isPending && (
<ActivityIndicator size="large" color="#0000ff" />
)}
</View>
</StrictMode>
);
};
export default BestPracticeApp;
How to handle large lists
React Native offers quite a few different list components.
ScrollView
Within the context of performance, it is typically better to use the FlatList
component over the ScrollView
component since FlatList
virtualization renders child components lazily. The ScrollView
component renders all its React child components at once. For that reason, a large number of child components for your ScrollView
component, the more performance is impacted. Rendering will slow down and memory usage will increase. However, for lists with a smaller data set, using ScrollView
is perfectly acceptable. Remember to memoize the ScrollView
and its children components accordingly.
FlatList
Setting the FlatList
required data
and renderItem
props does't unlock all of the performance benefits of using a FlatList
component. Set other props in your FlatList
component to improve your app's fluidity (FPS), CPU utilization, and memory usage. Apply all of the best practices outlined in React Native's official Optimizing FlatList
Configuration article. In our testing, we've found getItemLayout
, windowSize
, and initialNumToRender
props as well as adding memoization to child components especially important for fluidity and CPU utilization. If your app involves nested FlatList
usage to support vertical and horizontal scrolling, you should memoize your nested FlatList
components.
FlashList
Vega fully supports Shopify’s FlashList
, which provides a more performant alternative to React Native’s FlatList
component. Swapping from FlatList
to FlashList
is trivial, since both have the same component props. However, some FlatList
specific props no longer work for FlashList
. These props include windowSize
, getItemLayout
, initialNumToRender
, maxToRenderPerBatch
, and updateCellsBatchingPeriod
. You can find a full list at the end of this usage article.
Set a few key props, and follow these best practices, to unlock better performance for FlashList
.
- First, make sure to set the
estimatedItemSize
prop for yourFlashList
. Using theestimatedItemSize
prop inFlashList
helps improve performance by allowing the list to pre-render the appropriate number of items, minimizing blank space and load time, while also enhancing responsiveness during fast scrolls by avoiding unnecessary re-renders and large render trees. For more details, you can read this Estimated Item Size Prop article. - Using item recycling in
FlashList
improves performance by reusing off-screen components instead of destroying them, preventing unnecessary re-renders and reducing memory usage. To optimize this, avoid usinguseState
for dynamic properties in recycled components, since state values from previous items can carry over, leading to inefficiencies. For more details, see this Recycling article. - Remove the key prop from item components and their nested components. For more details, see this Remove key prop article.
- If you have different types of cell components and they differ quite a lot, consider leveraging the
getItemType
prop. For more details, see thisgetItemType
article.
The Image component
React Native's Image
component on iOS and Android does not provide out-of-box performance optimizations, such as caching. React Native developers usually use a community package such as react-native-fast-image
or expo-image
for memory or disk level caching for images in their React Native app. For React Native for Vega, we've built caching mechanisms throughout the native implementation of the Image
component. They perform like an Image
from react-native-fast-image
or expo-image
.
We recommend you have the same image asset available in several sizes and resolutions for your use case. For example, if rendering Image components within a FlatList
or ScrollView
component, it's better to use a cropped version or a thumbnail sized asset. This will lead to fewer CPU cycles spent decoding your image and less memory usage for the raw image asset.
The Animated library
React Native provides an Animated
library for fluid animations on React Native's core components. Animations are expensive operations in React Native. Frame by frame, the JS thread computes updates to the animation to send over the bridge to the native side to generate the frame. Using animation can also affect other processes running at the same time. This can overload the JS thread so it cannot process timers, executing React hooks, component updates, or other processes. To avoid this, set the useNativeDriver
prop on your animation to true. useNativeDriver
is set to true in native animations, while
useNativeDriver is set to
false` in JS animations. For more on this, see Using the native driver.
It's also recommended that you schedule the animation with InteractionManager
. InteractionManager.runAfterInteractions()
takes a callback function, or a PromiseTask
object, which executes only after the current animations or other interactions are completed. Delaying the execution of a newly scheduled animation reduces the chance of overloading the JS thread and helps improve overall app responsiveness and fluidity.
Optimizing your focus management UI
When a component gains focus, its onFocus
callback is invoked, and when a component loses focus, its onBlur
callback is invoked. This is the primary way to drive UI changes while the user navigates through your app. Keep your onFocus
and onBlur
handler functions as simple as possible. There are several ways to do this.
If your component is only drawing a border around the focused item, use conditional styling. For example, styles={isFocused ? styles.focusedStyle : styles.blurredStyle}
. Make sure every combination of your onFocus
and onBlur
invocation causes at most one React render cycle. This way the JS thread performs minimal work and the user navigation remains responsive.
If your component requires more complex UI updates when the focus is gained for a component, perform that work through a native animation so the work is not done on the JS Thread itself. Examples of such a UI updates are enlarging the selected view, text, or image, or changing the opacity. To optimize further, add a debounce mechanism where this native animation starts only if the user focuses on the item for over a set amount of time. While you will incur a slight penalty of the animation starting a bit later, this delay will prevent the animation from kicking off for every focused item when the user is fast scrolling or navigating in your app.
Listeners, event subscriptions, and timers
Some Turbo Modules (TMs) such as VegaAppState
, DeviceInfo
, or Keyboard
allow you to register listeners on certain events. These listeners are often created within a useEffect
hook. To avoid any dangling memory, clean up your listeners in the return function of your useEffect
hook.
useEffect(() => {
const keboardShowListenerHandler = Keyboard.addListener(
'keyboardDidShow',
handleKeyboardDidShow
);
return () => {
keboardShowListenerHandler.remove();
};
}, []);
Reducing overdraw
Your React Native for Vega app is like a canvas. When you have nested views with different background colors occupying the same space, you're painting over the same area over and over. For example, if a full-screen black view is covered by a full-screen grey view, the black layer becomes "fully overdrawn." That makes it invisible, but it is still processed. While some overdraw is acceptable, especially it it's partial, minimizing these redundant draw operations improves performance. The goal is to draw each pixel as few times as possible per frame.
You can detect overdraw in your app by running your app with the ?SHOW_OVERDRAWN=true
launch query argument.
vda shell vlcm launch-app "pkg://com.amazon.keplersampleapp.main?SHOW_OVERDRAWN=true"
UI elements on your canvas will now contain a semi-transparent tint to them. The resulting color indicates how many times a canvas was overdrawn.
- True color: No overdraw
- Blue color: Overdrawn 1 time.
- Green color: Overdrawn 2 times.
- Pink color: Overdrawn 3 times.
- Red color: Overdrawn 4 or more times.
Ideally, no part of your app should contain a pink or red tint, meaning don’t overdraw it with 3 or more layers.
Reducing bundle size
With any React app, it's crucial to minimize your bundle size for optimizing memory usage and improving app launch time. react-native-bundle-visualizer
can help identify expensive import statements or unused dependencies. Once it’s installed, you can run the following command to automatically open an HTML file in your browser for you to interactively view.
npx react-native-bundle-visualizer
Here’s a sample React Native for Vega app bundle that is trying to use debounce from lodash via import { debounce } from 'lodash';
. After this import statement, you can visualize the bundle like this.
From the image above, we can see that lodash
is 493.96 KB (17.8%). After switching this import statement to import debounce from 'lodash/debounce';
, the bundle size reduces dramatically.
Now, lodash
is only 13.19 KB (0.6%). Changing this import statement reduced the app's bundle size by 480.77 KB.
Improving initial app launch times
Utilize Vega's Native SplashScreen
API to display your splash screen efficiently. This API renders a splash screen natively from raw image assets bundled in your vpkg, ensuring immediate visibility when your app launches. This approach frees up your app's JavaScript thread to handle critical tasks like content loading and network calls, instead of rendering a JavaScript based splash screen. The native splash screen automatically dismisses after your app's first frame renders. Two methods are used to customize when a splash screen is dismissed. Call usePreventHideSplashScreen()
, which overrides the splash screen auto-dismissal, and also call useHideSplashScreenCallback()
, which hides the splash screen.
Related topics
- Measure App KPIs
- Discover Performance Issues Using Vega ESLint Plugin
- If you are developing a WebView app, see Vega Web App Performance Best Practices
Last updated: Sep 30, 2025