React Native developer; or, the Modern Procustes

React Native developer; or, the Modern Procustes

Two choices: stretch the framework or remove the performance bottleneck

TL;DR

  1. If any JS logic takes >100ms, the user feels it.

  2. In my case, a function which looped over ~300 items, was taking ~2500ms to run.

  3. I tried to optimize and memoize as much as I could.

  4. 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.