Logo
Published on
ยท11 min read

Improving React Native App Performance with Increased Data Volume

I apologize in advance for any awkward expressions in English.

English is not my native language, and I have relied on ChatGPT's assistance to proceed with the translation.

Overview

We have added a feature to the Simple Vocab Buddy app that allows users to download and use the '[2022 Revised] Essential English Words.'

While testing this feature, we encountered a significant slowdown in the word list screen as the number of words increased (approximately 3,000 words).

It was not just one issue but a combination of various problems (a real conundrum). I'll summarize what problems we encountered, although it's a bit embarrassing.

From react-native-async-storage to react-native-mmkv

Speed when retrieving data

Data saved by users or downloaded data was initially stored locally using the react-native-async-storage library.

There were no issues when the data volume was small, but when trying to fetch several thousand items at once, it could take up to 10 seconds. ๐Ÿ˜ฑ

Switching to react-native-mmkv

I switched to the react-native-mmkv library.

The following introduction caught my eye:

~30x faster than AsyncStorage

After implementing it, the data retrieval speed noticeably improved. It now takes less than a second to fetch around 3,000 items.

You can find more detailed information about this library at react-native-mmkv.

Data Migration

  • I undertook the task of transitioning code originally implemented with react-native-async-storage to react-native-mmkv. It was quite a hassle, but it provided an opportunity to identify issues in the existing code.

  • I added migration code to allow data previously stored with AsyncStorage to be re-stored as MMKV data.

    • I referred to the code in the Migrate from AsyncStorage documentation for guidance in carrying out the migration.
    • The example code in the documentation made the migration process relatively straightforward.
  • I also added code to enable the restoration of backup data stored with AsyncStorage to MMKV data.

    • There was an option for users to back up their data to Google Drive during Google login.
    • When restoring previously backed-up data, it was necessary to distinguish between the old AsyncStorage data and the new MMKV data.

Fully Synchronous

The introduction of the MMKV library states:

Fully synchronous calls, no async/await, no Promises, no Bridge.

I found it a bit unusual to use synchronous calls since I was more accustomed to asynchronous usage with AsyncStorage. However, using MMKV synchronously was, of course, more convenient.

Conclusion

I thought there were no alternatives to using AsyncStorage when storing data locally in React Native.

If you happen to search for a local storage library for your React Native project early on, I would highly recommend using react-native-mmkv from the beginning.

Excessive Use of 'filter'

Not only did it take time to retrieve the data, but it also took time to filter the retrieved data based on selected criteria.

Embarrassingly, I used the filter function without much thought:

  • Filtering by search text if there is a search query
  • Filtering by selected status
  • Filtering by selected quiz results
  • Finally, sorting based on the selected sorting criteria (sorting will be covered in the next section).

That's three rounds of filtering for 3,000 pieces of data, and then sorting. ๐Ÿ˜จ

Changes Made

  • Return early if there are no filter conditions.

  • Combined the three filtering rounds into one:

const filterSentences = useCallback(() => {
    const searchTextNormalized = normalizeSearchText(searchText);

    // ... Skip the code for early return if no filter conditions.

    // Combined the three filtering rounds into one.
    const result = group.sentences.filter((sentence) => {
      return (
        textMatch(sentence, searchTextNormalized) &&
        markedTypeMatch(sentence, filterType.markedTypes) &&
        quizResultMatch(sentence, filterType.quizResultTypes)
      );
    });

    // ... Skip the code for sorting.
  }, [group.sentences, filterType, searchText]);

Conclusion

Ensure that you avoid excessive use of functions like filter() and map() for better performance and efficiency.

Sorting Issues

Sorting was also a significant time-consuming issue, alongside the filtering.

Initially, we sorted based on a string id:

  filter.sort((a, b) => b.id.localeCompare(a.id));

We improved performance by adding a timestamp (createdAt) as a number type to the data and sorting based on the timestamp:

    result.sort((a, b) => {
      return b.createdAt - a.createdAt;
    });

Conclusion

Ensure you consider the sorting type.

Keep in mind that using the default sort() function without explicitly defining a sorting order can lead to unexpected results and performance issues, as it may convert number types to strings for sorting.

Unnecessary Data Retrieval

There was code that fetched data when returning to the screen (when isFocused), due to reasons like adding, modifying, or deleting data.

I added isFocused to the dependency array of useEffect() to handle this, but while logging, I noticed that it was being called even when navigating to other screens.

The issue was that there was no if (isFocused) condition, causing it to be called even when moving to different screens. It's a small mistake but quite embarrassing. ๐Ÿ˜ฐ

  const isFocused = useIsFocused();
  useEffect(() => {
    if (isFocused) { // <--- !!! Modified to fetch data only when focused
      // Fetch data
    }
  }, [isFocused]);

Conclusion

To identify unnecessary calls or potential performance bottlenecks, consider adding log statements to the parts of your code where data is fetched or actions that could cause performance degradation. This can help you pinpoint and resolve issues related to unnecessary function calls.

React.Memo / useCallback

Searching for performance improvement keywords in React Native often leads to many articles and discussions. However, I had some doubts about whether React.Memo was applied correctly in components and whether it actually improved performance.

So, I decided to add log statements to components where I applied React.Memo and found that it was not being applied correctly, and the component was re-rendering every time.

The Reason

There was a part in the higher-level component where a method was passed in the props to call the component.

The issue was that this method was recreated every time the higher-level component was re-rendered, causing it to be perceived as new props each time and preventing React.Memo from being applied.

After wrapping the passed method with useCallback and checking again with log statements, I confirmed that React.Memo was properly applied, and the component did not re-render when there were no changes.

Conclusion

After applying React.Memo, it's essential to verify its effectiveness through methods like log statements.

Using Cache

While using the MMKV library improved performance significantly, I wanted to optimize it further.

Here's what I did:

  • I named the function that retrieves data using the Key and stores the result data as Value.
  • I introduced the hasChanged value to determine whether to use cached data or fetch new data.
  • Whenever an addition, modification, or deletion occurs, I set all hasChanged values to true.
  • To ensure that different functions don't use the same hasChanged value and potentially skip fetching new data, I decided to use hasChanged values specific to each function.
const cache = {}; // An object to store cached data
const keyStates = {}; // An object to store the hasChanged status for each key

// Check whether to fetch new data or use cached data and return the data
const getFromCacheOrRead = (key, readFunction) => {
  const cachedData = cache[key];
  const hasChanged = keyStates[key];

  if (hasChanged || !cachedData) {
    const newData = readFunction();

    // Update cache
    cache[key] = newData;

    // Save hasChanged status
    keyStates[key] = false;

    return newData;
  }

  return cachedData;
};

// Set all hasChanged values to true when an addition, modification, or deletion occurs
const resetAllKeyStates = () => {
  Object.keys(keyStates).forEach((key) => {
    keyStates[key] = true;
  });
};

// Example of using getFromCacheOrRead
export const getAllGroupsFromStorage = () => {
  try {
    return getFromCacheOrRead('getAllGroupsFromStorage', () => {

      // Fetch a list of all keys
      const allKeys = storage.getAllKeys();
      const groups = [];

      // Filter and fetch only keys starting with 'group'
      for (const key of allKeys) {
        if (key.startsWith('group')) {
          groups.push(JSON.parse(storage.getString(key)));
        }
      }

      return groups;
    });
  }
  catch (e) {
    console.log('getAllGroupsFromStorage error: ', e);
    return [];
  }
}

// Example of using resetAllKeyStates
export const addGroupToStorage = (group: GroupType) => {
  try {
    const key = group.id = 'group' + new Date().getTime().toString();

    storage.set(key, JSON.stringify(group));
    resetAllKeyStates();
    return key;
  }
  catch(e) {
    console.log('addToGroupStorage error: ', e);
    return '';
  }
}

Debugging Mode

While there were many issues and modifications made, there were still some slow rendering parts in my application.

I wondered if it might be because of the debugging mode. So, I tested the app on a test device in release mode and found that the performance was significantly better.

Since I primarily test on emulators, I wished there was a way to check the performance on emulators as well.

In the command prompt, typing d displays a menu like the one below (it may vary depending on the version).

Debugging Mode

Selecting "Settings" reveals several checkboxes, as shown above.

I unchecked the "JS Dev Mode" checkbox and tested again, and the performance was similar to release mode.

Conclusion

Debugging mode can introduce various performance issues, so it's advisable to test your app in release mode or with "JS Dev Mode" disabled for a more accurate performance assessment.

Final Conclusion

To summarize:

  • Switch from react-native-async-storage to react-native-mmkv for improved performance.
  • Be mindful of excessive looping, especially with filters and maps.
  • Check for unnecessary data retrieval using logs or other monitoring tools.
  • Utilize React.memo and useCallback to minimize unnecessary re-renders and verify their effectiveness.
  • Implement caching mechanisms where possible.
  • Consider running your app in release mode or disabling "JS Dev Mode" for more accurate performance testing.

Working with React Native for the first time and focusing on feature implementation can lead to overlooking various performance optimizations.

While making these changes, I felt embarrassed, and even while writing this post, I hesitated because of my embarrassment. ๐Ÿ˜…

However, I decided to write it anyway, not only for my own future development but also in the hope that my experiences and lessons learned may help others facing similar challenges.