Enhancing React Native Apps with Optimistic UI: Mastering useOptimistic and useTransition

Fiyaz HussainFiyaz Hussain
6 min read

With the release of React 19, two powerful hooks—useOptimistic and useTransition—have significantly enhanced user experience by making UI updates smoother and more responsive. These hooks efficiently manage state updates, ensuring users don’t encounter UI freezes or unnecessary delays during asynchronous operations. In this blog, I’ll guide you through how these hooks work and demonstrate their implementation in a React Native app with a practical example.

Introduction to useOptimistic and useTransition

useOptimistic

This hook allows you to temporarily update the UI assuming that an operation will succeed, even before receiving a confirmation from the backend. If the operation fails, you can handle the error and revert the state accordingly.

useTransition

This hook helps manage state updates without blocking the UI, keeping it responsive while executing async tasks. It defers rendering updates, ensuring that other UI interactions remain smooth even when network calls are in progress.

Implementing an Optimistic UI Update in React Native

Let’s walk through an example where we optimistically update a user’s name in a React Native app.

Full Code Example

import React, { useOptimistic, useTransition, useState } from 'react';
import { View, Text, Button } from 'react-native';

export default function App() {
  // useTransition to manage async state updates without blocking UI
  const [isPending, startTransition] = useTransition();

  // useState to hold the actual confirmed name
  const [name, setName] = useState('John');

  // useOptimistic to provide an immediate UI update
  const [optimisticName, setOptimisticName] = useOptimistic(name);

  function createControlledPromise(shouldResolve, value) {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        if (shouldResolve) {
          resolve({ value, error: null });
        } else {
          reject({ value: null, error: 'Promise was rejected' });
        }
      }, 2500);
    });
  }

  const submitAction = () => {
    startTransition(async () => {
      setOptimisticName('John Doe'); // Temporary UI update

      try {
        const result = await createControlledPromise(false, 'John Doe'); // Change to `true` to test success
        console.log('Final Value is:', result.value);

        // Persist the confirmed value in useState
        setName(result.value);
      } catch (error) {
        console.error(error.error);
        setOptimisticName(name); // Revert to the original state on failure
      }
    });
  };

  return (
    <View>
      <Text>Your name is: {optimisticName}</Text>
      <Button disabled={isPending} onPress={submitAction} title='Change Name' />
    </View>
  );
}

Breakdown of Important Lines of Code

1. Managing UI Updates Efficiently

const [isPending, startTransition] = useTransition();
  • This ensures that UI updates related to async operations do not block the main UI thread, preventing potential lag.

2. Handling State and Optimistic UI Updates

const [name, setName] = useState('John');
const [optimisticName, setOptimisticName] = useOptimistic(name);
  • name holds the final confirmed value.

  • optimisticName is used to immediately reflect UI changes before confirmation.

3. Creating a Simulated API Call

function createControlledPromise(shouldResolve, value) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (shouldResolve) {
        resolve({ value, error: null });
      } else {
        reject({ value: null, error: 'Promise was rejected' });
      }
    }, 2500);
  });
}
  • This function mimics an API call with a delay and can either resolve or reject based on shouldResolve.

4. Triggering the Optimistic UI Update

startTransition(async () => {
  setOptimisticName('John Doe'); // Temporary UI update
  • setOptimisticName immediately updates the UI before the API response.

5. Handling API Response and Errors

try {
  const result = await createControlledPromise(false, 'John Doe');
  setName(result.value);
} catch (error) {
  setOptimisticName(name); // Revert on failure
}
  • If the API call fails, we revert optimisticName to the original value.

How It Works

  1. Initial State: The name state is set to 'John'.

  2. Optimistic Update: When the button is pressed, useOptimistic temporarily updates the UI to show 'John Doe'.

  3. API Simulation: The createControlledPromise function simulates a network request, which can either resolve (success) or reject (failure).

  4. State Update Handling:

    • If the promise resolves, name is updated in useState.

    • If it fails, an error message is logged, but the UI already reflected the change optimistically.

Key Benefits of This Approach

  • Smooth User Experience: Users see immediate UI updates without waiting for API calls.

  • Non-blocking UI: useTransition ensures the app remains responsive during async operations.

  • Error Handling: If the operation fails, the UI is reverted to the previous state seamlessly.

Enhancing the Implementation

To further improve this, we can:

  • Show a loading indicator while isPending is true.

  • Display an error message instead of just logging it.

  • Handle retries in case of failures.

Example: Showing a Loading Indicator

{isPending && <ActivityIndicator size='small' color='#0000ff' />}

useOptimistic vs. useState: The Debate

You might be wondering: why use useOptimistic when we can simply use useState and manually update the value before the API call?

1. The Traditional useState Approach (Manually Reverting State)

Instead of useOptimistic, you could do:

const [name, setName] = useState('John');

const submitAction = async () => {
  const previousName = name;
  setName('John Doe'); // Update UI immediately

  try {
    const result = await createControlledPromise(false, 'John Doe'); // Simulating API call
    setName(result.value); // Set final confirmed value
  } catch (error) {
    setName(previousName); // Revert UI if API fails
    console.error(error.error);
  }
};

Downsides of useState Approach:

  • Manually tracking state (previousName) can be error-prone.

  • Race conditions might occur if multiple updates happen quickly.

  • If multiple async actions update the same state, rolling back can be messy.

2. Why useOptimistic is Better

React 19 introduced useOptimistic to handle exactly this case in a cleaner way:

const [name, setName] = useState('John');
const [optimisticName, setOptimisticName] = useOptimistic(name);

const submitAction = () => {
  startTransition(async () => {
    setOptimisticName('John Doe'); // Temporary optimistic update

    try {
      const result = await createControlledPromise(false, 'John Doe');
      setName(result.value); // Persist final value
    } catch (error) {
      console.error(error.error);
    }
  });
};

Benefits of useOptimistic:

No need to manually store and revert state—React automatically keeps track of the "real" state. ✅ No race conditions—React ensures that updates are applied in order, even if multiple updates happen quickly. ✅ Cleaner code—Your UI update logic is separate from the async function, making it easier to read.

3. When to Use useOptimistic vs. useState

ApproachProsCons
Manually updating useStateSimple, doesn't require React 19Can lead to race conditions, requires manual rollback
Using useOptimisticCleaner, prevents race conditions, React handles state trackingOnly available in React 19+

Yes, you can use useState alone and manually revert the value, but useOptimistic makes optimistic updates more structured, safer, and less error-prone. If you're using React 19, it's better to use useOptimistic for handling UI updates before async actions complete.

Using useOptimistic allows for fluid, non-blocking UI updates, making it ideal for applications where responsiveness and perceived speed are crucial. Try integrating this pattern in your projects and enhance your users' experience!

Conclusion

In conclusion, the introduction of useOptimistic and useTransition hooks in React 19 marks a significant advancement in managing state updates and asynchronous operations. These hooks enhance the responsiveness and user-friendliness of applications by allowing for smooth, non-blocking UI updates. By adopting these hooks, developers can provide a more seamless and engaging user experience, making applications feel faster and more intuitive. Implementing these patterns in your projects can greatly improve the perceived performance and overall satisfaction of your users.

0
Subscribe to my newsletter

Read articles from Fiyaz Hussain directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Fiyaz Hussain
Fiyaz Hussain

Empowering Developers and Delighting Clients in Mobile Application Development, server-side Development and skillfully leveraging Google Cloud services for 4 Years and Counting!