Published on

Athoni: Mobile Optimization - How I Improved App Performance from 20-30 FPS to 50-60 FPS?

Authors
  • avatar
    Name
    Jack Nguyen
    Twitter

At Athoni, a game-based learning platform, we faced a significant performance challenge. Our app was running at a sluggish 20-30 frames per second (FPS), far below the smooth experience we wanted to deliver to our users. After diving into the issue, we identified a major culprit: an excessive number of nested React Context Providers. Here's how we tackled this problem and boosted performance by 50%.

Cover Image
Table of Contents

The Problem: Context Providers and Re-Rendering

Cover Image

In our app, we had a large number of Providers, as shown in the image below. While React Context is a powerful tool for state management, it can become a bottleneck when overused.

When we called setState on any Provider, all the child components that fetch state from the context were triggered to re-render. Combining this with heavy assets like images, animations, and Lottie files, the re-renders became overwhelming, causing the FPS to plummet.

Code example:

LanguageProvider.tsx

const LanguageProvider: React.FC = ({ children }) => {
  const [language, setLanguage] = useState('en')

  const value = useMemo(
    () => ({
      language,
      setLanguage,
    }),
    [language]
  )

  return <LanguageContext.Provider value={value}>{children}</LanguageContext.Provider>
}

App.tsx

const App: React.FC = () => {
  return (
    <LanguageProvider>
      <Home />
    </LanguageProvider>
  )
}

Home.tsx

const Home: React.FC = () => {
  const { language } = useContext(LanguageContext)

  return (
    <div>
      <h1>{language}</h1>
      <Collection />
      <img src="heavy-image.png" alt="Heavy Image" /> // Heavy asset
    </div>
  )
}

Collection.tsx

const Collection: React.FC = () => {
  const { language } = useContext(LanguageContext)
  const { collection } = useContext(CollectionContext)

  return (
    <div>
      <h2>{language}</h2>
      {collection.map((item) => (
        <Item key={item.id} item={item} />
      ))}
      <LottieView source={require('animation.json')} /> // Heavy asset
    </div>
  )
}

Example Flow of a Re-Render:

  1. LanguageProvider updates the language state.
  2. This triggers a re-render in Home and Collection.
  3. The heavy image and Lottie animation in Home and Collection are re-rendered.

This process led to a severe drop in performance, especially on devices with limited processing power.

The Solution: Optimizing Context and Rendering

To resolve this, I adopted the following strategies:

1. useMemo and React.memo

  • Why: These tools help avoid unnecessary re-renders by memoizing values and components.

  • How: I wrapped components with React.memo to ensure they only re-render when their props change. Additionally, I used useMemo to memorize expensive computations or derived state.

  • Example:

    const OptimizedComponent = React.memo(({ value }: { value: string }) => {
      console.log('Rendered with value:', value)
      return <div>{value}</div>
    })
    
    const ParentComponent = () => {
      const [count, setCount] = useState(0)
      const memoizedValue = useMemo(() => `Count: ${count}`, [count])
    
      return (
        <div>
          <button onClick={() => setCount((prev) => prev + 1)}>Increment</button>
          <OptimizedComponent value={memoizedValue} />
        </div>
      )
    }
    

    In this setup, OptimizedComponent only re-renders when memoizedValue changes.


Now, we will apply this technique (useMemo) to the previous example:

Home.tsx

const HeavyImage = React.memo(() => <img src="heavy-image.png" alt="Heavy Image" />)

const Home: React.FC = () => {
  const { language } = useContext(LanguageContext)

  return (
    <div>
      <h1>{language}</h1>
      <Collection />
      <HeavyImage />
    </div>
  )
}

Collection.tsx

const HeavyAnimation = React.memo(() => <LottieView source={require('animation.json')} />)

const Collection: React.FC = () => {
  const { language } = useContext(LanguageContext)
  const { collection } = useContext(CollectionContext)

  const CollectionItems = useMemo(
    () => collection.map((item) => <Item key={item.id} item={item} />),
    [collection]
  ) // Memorize the collection items - Alternative Way (like React.memo)

  return (
    <div>
      <h2>{language}</h2>
      {CollectionItems}

      <HeavyAnimation />
    </div>
  )
}

2. useReducer for State Management

  • Why: Managing multiple state updates with useState can cause excessive re-renders.

  • How: I replaced multiple useState calls with a single useReducer to batch updates and reduce rendering overhead.

  • Example:

    const initialState = { count: 0, loading: false }
    
    const reducer = (state, action) => {
      switch (action.type) {
        case 'increment':
          return { ...state, count: state.count + 1 }
        case 'setLoading':
          return { ...state, loading: action.payload }
        default:
          return state
      }
    }
    
    const Counter = () => {
      const [state, dispatch] = useReducer(reducer, initialState)
    
      return (
        <div>
          <button onClick={() => dispatch({ type: 'increment' })}>Increment</button>
          <button onClick={() => dispatch({ type: 'setLoading', payload: true })}>Set Loading</button>
        </div>
      )
    }
    

3. react-native-fast-image for Image Caching

  • Why: Rendering images from the network repeatedly adds to the app's workload.

  • How: I used the FastImage library to cache images and improve loading times.

  • Example:

    import FastImage from 'react-native-fast-image'
    
    const ImageComponent = () => (
      <FastImage
        style={{ width: 100, height: 100 }}
        source={{ uri: 'https://example.com/image.png' }}
        resizeMode={FastImage.resizeMode.contain}
      />
    )
    

4. useNativeDriver for Animations

  • Why: Animations running on the JavaScript thread can cause jank.
  • How: I enabled the useNativeDriver option in Animated.timing to offload animations to the native driver, improving performance.
  • Example:
    Animated.timing(animatedValue, {
      toValue: 1,
      duration: 500,
      useNativeDriver: true, // Offload to native driver
    }).start()
    

5. Reducing Nested Providers

  • Why: Excessive nesting of Providers increases the complexity of the app.
  • How: I consolidated state management logic by grouping related contexts or using a global state management library when appropriate.

The Results

By implementing these optimizations, I successfully boosted our app's performance from 20-30 FPS to 50-60 FPS. This improvement was not only measurable but also highly noticeable to our users, resulting in smoother animations, faster load times, and an overall better experience.

Conclusion

While React Context is a fantastic tool, its overuse can lead to significant performance issues. Through careful analysis and targeted optimizations, I demonstrated that even complex issues can be resolved. If you're facing similar challenges, consider these strategies to enhance your app's performance and delight your users!