Async/Await & Error Handling

Every fetch() and axios call in the previous lesson returned a Promise — a JavaScript object representing a value that isn't available yet, but will be (or will fail) at some point in the future. async/await is the standard modern syntax for working with Promises without chaining .then() calls, and it's used everywhere in real React Native apps. This lesson covers that syntax properly, how to handle errors around it, how to run multiple requests at once, and how to wire all of it into a component's UI state.

async/await: The Standard Way to Work With Promises

Marking a function async does two things: it lets you use await inside it to pause execution until a Promise settles, and it means the function itself always returns a Promise — even if you write a plain return statement inside it.

async function getGreeting() {
  return 'hello';
}

// getGreeting() does NOT return the string 'hello' directly —
// it returns a Promise that resolves to 'hello'
getGreeting().then(message => console.log(message)); // 'hello'

// inside another async function, await unwraps that Promise for you
async function run() {
  const message = await getGreeting();
  console.log(message); // 'hello'
}

try/catch: The Standard Error-Handling Pattern

When a Promise rejects — a network request fails, for example — an await-ed call throws, exactly like a synchronous error. Wrapping your await calls in a try/catch block is the standard, idiomatic way to handle that:

async function loadArticles() {
  try {
    const response = await fetch('https://api.example.com/articles');

    if (!response.ok) {
      throw new Error(`Server responded with ${response.status}`);
    }

    const data = await response.json();
    return data;
  } catch (error) {
    // runs for BOTH a network failure (no internet, DNS error)
    // AND the manually thrown error above for a bad status
    console.error('Failed to load articles:', error.message);
    throw error; // re-throw so the caller (e.g. a component) can react too
  }
}

Notice the if (!response.ok) check and manual throw inside the try block — this is the important gotcha carried over from the previous lesson. fetch()'s Promise only rejects on a genuine network failure; it does not reject just because the server returned a 404 or 500. Without that manual check and throw, a failed request would silently be treated as a success, and catch would never run for it.

Running Requests Concurrently With Promise.all()

If you need data from two independent endpoints — say, a user's profile and that same user's posts — awaiting them one after another wastes time: the second request doesn't start until the first one fully finishes, even though neither depends on the other's result.

// Slower: sequential — the second request waits for the first to fully finish
async function loadProfileSequential(userId) {
  const user = await fetch(`https://api.example.com/users/${userId}`).then(r => r.json());
  const posts = await fetch(`https://api.example.com/users/${userId}/posts`).then(r => r.json());
  return { user, posts };
}

Promise.all() starts every request at the same time and waits for all of them to finish together, which can be significantly faster whenever the requests don't depend on each other:

// Faster: both requests fire at the same time
async function loadProfileConcurrent(userId) {
  const [userResponse, postsResponse] = await Promise.all([
    fetch(`https://api.example.com/users/${userId}`),
    fetch(`https://api.example.com/users/${userId}/posts`),
  ]);

  const user = await userResponse.json();
  const posts = await postsResponse.json();
  return { user, posts };
}

If each request takes roughly 500ms, the sequential version takes around 1 second total (500ms + 500ms, one after the other), while the concurrent version takes around 500ms total, since both requests are in flight over the network at the same time. This gap only grows as you add more independent requests.

Note: Promise.all() rejects as soon as any one of its Promises rejects — so a single failed request fails the whole group. If you need the others to still complete even when one fails, look into Promise.allSettled(), which always waits for every Promise and reports each one's individual outcome.

A Full Loading/Error/Data Pattern With useState and useEffect

In a component, the most common pattern for showing fetched data is to track three states — whether it's still loading, whether it failed, and the data itself — using useState, and kick off the fetch inside useEffect (both covered in State & Hooks):

import React, { useState, useEffect } from 'react';
import { View, Text, ActivityIndicator } from 'react-native';

function ArticleList() {
  const [articles, setArticles] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function load() {
      try {
        setLoading(true);
        setError(null);
        const response = await fetch('https://api.example.com/articles');
        if (!response.ok) {
          throw new Error(`Server responded with ${response.status}`);
        }
        const data = await response.json();
        setArticles(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }
    load();
  }, []);

  if (loading) {
    return <ActivityIndicator size="large" />;
  }

  if (error) {
    return <Text>Something went wrong: {error}</Text>;
  }

  return (
    <View>
      {articles.map(article => (
        <Text key={article.id}>{article.title}</Text>
      ))}
    </View>
  );
}

This three-state shape — loading, error, and the actual data — is one of the most common patterns you'll write in any React Native app that talks to a network, and it's worth internalizing: it makes exactly what the UI should render at any given moment unambiguous.

A Brief Note on Error Boundaries

Everything above handles errors from async code — failed requests, rejected Promises. But a completely different category of error can happen: a bug during rendering itself (for example, reading a property off undefined somewhere in your JSX), which try/catch around a fetch call can't protect against. React's answer to that is an Error Boundary — a special component that catches rendering errors in its child tree and shows a fallback UI instead of crashing the whole app. Implementing one is outside the scope of this lesson, but it's worth knowing the concept exists: many production apps wrap their root navigator in one, so a single unexpected rendering bug in a screen doesn't take down the entire app.

Next up: Networking and async patterns are now covered. The next lesson is about persisting data on the device itself: Local Storage with AsyncStorage.