React Native developer; or, the Modern Procustes
Two choices: stretch the framework or remove the performance bottleneck
TL;DR
If any JS logic takes >100ms, the user feels it.
In my case, a function which looped over ~300 items, was taking ~2500ms to run.
I tried to optimize and memoize as much as I could.
In the end, created an API for it.
Context:
I picked the task of creating User onboarding for the Social Calendar mobile app at RealDevSquad.
For that, I had to create a dropdown component for the user to be able to pick their preferred timezone.
After understanding the nature of the problem NNG group's user analysis, I concluded that vvo/tzdb would be fit for providing pertinent data, and react-native-dropdown-picker would be fit for the dropdown component itself.
A chance encounter with performance bottlenecks:
Now, I created the TimezonePicker component, where I invoked DropdownPicker from react-native-dropdown-picker and getTzOptions, a function to transform vvo/tzb's data into an array of objects with keys: label and value. The label should be such that it has Timezone and cities, and then the current UTC offset but represented in GMT.
getTzOptions looked something like the following:
import { getTimeZones } from "@vvo/tzdb";
let initialValueIndex = 0;
export function getTzOptionsAndUserTzIndex(userTz) {
const tzOptions = getTimeZones({ includeUtc: true }).map((tz, index) => {
if (tz.name === userTz) {
initialValueIndex = index;
}
const cities = tz.mainCities.join(", ");
const utcOffset = tz.currentTimeFormat.substring(0, 6);
return {
label: `${cities} - ${tz.alternativeName} (GMT ${offset})`,
value: tz.name,
};
});
return { tzOptions, initialValueIndex };
}
In the TimezonePicker component, I was simply calling this util, setting tzOptions[initialValueIndex].value as the initial state, and passing tzOptions to items prop in DropdownPicker invocation.
import React, {memo, useMemo, useState} from 'react';
import DropDownPicker from 'react-native-dropdown-picker';
import {Text, View} from 'react-native';
import {getTzOptionsAndInitialValueIndex} from '../../../utils/timezonePicker.utils';
import {styles} from './TimezonePicker.styles';
function TimezonePicker() {
const userTz = useMemo(() => Intl.DateTimeFormat().resolvedOptions().timeZone, []);
const [isOpen, setIsOpen] = useState(false);
const {tzOptions, initialValueIndex} = useMemo(() => getTzOptionsAndInitialValueIndex(userTz),
[],
);
const [currentTzValue, setCurrentTzValue] = useState(
tzOptions[initialValueIndex.current].value,
);
return (
<View style={styles.pickerContainer}>
<Text style={styles.label}>Timezone:</Text>
<DropDownPicker
open={isOpen}
value={currentTzValue}
items={tzOptions}
setOpen={setIsOpen}
setValue={setCurrentTzValue}
searchable={true}
listMode="MODAL"
style={styles.dropdown}
containerStyle={styles.dropdownContainer}
itemSeparator={true}
listItemContainerStyle={styles.listItemContainer}
/>
</View>
);
}
export default memo(TimezonePicker);
To me, it looked like it would have more than decent performance, as I had made the component pure, and wrapped all functions calls in useMemo. Unfortunately, when the component was being mounted, I noticed a delay of 2-3 seconds.
Laying in a bed too small:
I read React Native perf overview, and Log rocket RN perf article.
According to the docs, if anything takes more than 100ms to execute, the user is going to notice a delay. In my case, I saw using performance.now()
that getTzOptions was taking ~2500ms on average on my Android device in the release build!
What does getTzOptions do? It simply loops over some ~320 items and then finds cities, time zones, and current offsets to be arranged in the label. For optimization, I tried so many things from the fastest looping techniques in JS to removing string.split()
, but I could only shave off 500-800ms at max. This alone was taking about ~3333ms before and ~2400ms, **after optimisations** on my Android phone.
I asked ChatGpt, and it suggested making the function async, but that too did not have any effect.
Severing legs of the problem:
Finally, I solved this by creating an API for getting tzOptions and initialValueIndex, which looks something like this:
import React, {memo, useCallback, useEffect, useMemo, useState} from 'react';
import DropDownPicker, {ValueType} from 'react-native-dropdown-picker';
import {ActivityIndicator, Text, View} from 'react-native';
import {styles} from './TimezonePicker.styles';
import {colors} from '../../../constants/colors';
import {useQuery} from '@tanstack/react-query';
import getTimezoneOptions from '../../../api/getTimezoneOptions';
function TimezonePicker() {
const userTz = useMemo(
() => Intl.DateTimeFormat().resolvedOptions().timeZone,
[],
);
const [isOpen, setIsOpen] = useState(false);
const [currentTzValue, setCurrentTzValue] = useState<ValueType | null>(null);
const {isFetching, isLoading, data} = useQuery({
queryKey: ['timezone-options'],
queryFn: () => getTimezoneOptions(userTz),
placeholderData: {
success: false,
data: {tzOptions: [], initialValueIndex: 0},
},
staleTime: Infinity,
});
const TzActivityIndication = useCallback(
({color, size}: {color: string; size: number}) => (
<ActivityIndicator color={color} size={size} />
),
[],
);
const {tzItems, initialValueIndex} = useMemo(
() => ({
tzItems: data?.data?.tzOptions || [],
initialValueIndex: data?.data?.initialValueIndex || 0,
}),
[data?.data?.initialValueIndex, data?.data?.tzOptions],
);
useEffect(() => {
if (tzItems.length > 0) {
setCurrentTzValue(tzItems[initialValueIndex].value);
}
}, [initialValueIndex, tzItems]);
const loading = useMemo(
() => isFetching || isLoading,
[isFetching, isLoading],
);
return (
<View style={styles.pickerContainer}>
<Text style={styles.label}>Timezone:</Text>
{loading ? (
{/* loading in DropdownPicker is not working, hence added explicit one */}
<ActivityIndicator color={colors.secondaryColor} size={'small'} />
) : (
<DropDownPicker
open={isOpen}
setOpen={setIsOpen}
value={currentTzValue}
setValue={setCurrentTzValue}
items={tzItems}
loading={loading}
searchable={true}
listMode="MODAL"
style={styles.dropdown}
containerStyle={styles.dropdownContainer}
itemSeparator={true}
itemSeparatorStyle={{
backgroundColor: colors.inputBorderColor,
}}
listItemContainerStyle={styles.listItemContainer}
ActivityIndicatorComponent={TzActivityIndication}
activityIndicatorColor={colors.secondaryColor}
activityIndicatorSize={30}
/>
)}
</View>
);
}
export default memo(TimezonePicker);
This resulted in an almost native-like performance, where the ActivityIndicator shows until the data is loaded from API, and only once.
Pre:
Post:
Note: The Post gif looks like it is having a jank, but that is not the case. Transition is quite smooth on phone, but the recording is having it perhaps due to lower fps.
P.S.: There are other approaches like creating Native Modules to do the heavy operations on the native side and then get the result back to JS thread. Also, https://github.com/corbt/next-frame seems like something that could help out as well.