Project 4: Weather App with fetch and Error States

React Native with Expo: 10 Real Projects · Building Real Apps · 7 min read · Updated

Loading data safely

  1. The user presses Search and the state becomes loading.
  2. The async function calls fetch and waits.
  3. The server answers with JSON, or the request fails.
  4. The state becomes ok with data, or error with a message, and the screen shows it.

Most apps talk to servers. This project loads live weather and teaches the pattern that every data screen needs: show a spinner while waiting, show data on success and show a clear message on failure. You use the built-in fetch function, with no extra library.

Concepts in this project

  • REST API and JSON: you request a URL and get text in JSON form, which res.json() turns into an object.
  • fetch and async/await: await fetch(url) waits without freezing the screen. Check res.ok, because fetch does not throw on HTTP errors like 404.
  • Union types for state: idle | loading | error | ok. Each case carries only the data that makes sense. TypeScript makes sure you handle each one.
  • Two requests: first look up the city's coordinates with the free Open-Meteo geocoding endpoint, then ask for the weather at those coordinates. No API key is needed. Check the service's current terms and URLs before you build.
  • Separation: the network code is in its own file. The screen only shows state.

Create the project

  1. Use a blank Expo TypeScript project. No extra library is needed.
  2. Add weather.ts and replace App.tsx.

`weather.ts`

export type Weather = {
  city: string;
  country: string;
  temperature: number;
  windSpeed: number;
  description: string;
};

const CODES: Record<number, string> = {
  0: "Clear sky", 1: "Mostly clear", 2: "Partly cloudy", 3: "Overcast",
  45: "Fog", 48: "Fog", 51: "Light drizzle", 61: "Rain", 63: "Rain", 65: "Heavy rain",
  71: "Snow", 80: "Rain showers", 95: "Thunderstorm",
};

export const describe = (code: number) => CODES[code] ?? "Unknown";

export async function fetchWeather(city: string): Promise<Weather> {
  const geoUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`;
  const geoRes = await fetch(geoUrl);
  if (!geoRes.ok) throw new Error("Could not look up the city");
  const geo = await geoRes.json();
  const place = geo.results?.[0];
  if (!place) throw new Error("City not found");

  const url =
    `https://api.open-meteo.com/v1/forecast?latitude=${place.latitude}&longitude=${place.longitude}` +
    `&current=temperature_2m,weather_code,wind_speed_10m`;
  const res = await fetch(url);
  if (!res.ok) throw new Error("Weather service error");
  const data = await res.json();

  return {
    city: place.name,
    country: place.country ?? "",
    temperature: data.current.temperature_2m,
    windSpeed: data.current.wind_speed_10m,
    description: describe(data.current.weather_code),
  };
}

`App.tsx`

import { useState } from "react";
import { SafeAreaView, View, Text, TextInput, Pressable, ActivityIndicator, StyleSheet } from "react-native";
import { Weather, fetchWeather } from "./weather";

type State =
  | { kind: "idle" }
  | { kind: "loading" }
  | { kind: "error"; message: string }
  | { kind: "ok"; weather: Weather };

export default function App() {
  const [city, setCity] = useState("");
  const [state, setState] = useState<State>({ kind: "idle" });

  const search = async () => {
    if (!city.trim()) return;
    setState({ kind: "loading" });
    try {
      const weather = await fetchWeather(city.trim());
      setState({ kind: "ok", weather });
    } catch (e) {
      setState({ kind: "error", message: (e as Error).message });
    }
  };

  return (
    <SafeAreaView style={styles.screen}>
      <Text style={styles.title}>Weather</Text>
      <View style={styles.row}>
        <TextInput
          style={styles.input}
          value={city}
          onChangeText={setCity}
          placeholder="City"
          onSubmitEditing={search}
          returnKeyType="search"
        />
        <Pressable style={styles.button} onPress={search}>
          <Text style={styles.buttonText}>Search</Text>
        </Pressable>
      </View>

      {state.kind === "loading" && <ActivityIndicator size="large" style={styles.gap} />}
      {state.kind === "error" && <Text style={[styles.gap, styles.error]}>{state.message}</Text>}
      {state.kind === "ok" && (
        <View style={styles.card}>
          <Text style={styles.city}>
            {state.weather.city}, {state.weather.country}
          </Text>
          <Text style={styles.temp}>{Math.round(state.weather.temperature)}°C</Text>
          <Text style={styles.desc}>{state.weather.description}</Text>
          <Text style={styles.wind}>Wind {state.weather.windSpeed} km/h</Text>
        </View>
      )}
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, padding: 20, backgroundColor: "#f5f6fa" },
  title: { fontSize: 28, fontWeight: "700", marginBottom: 12 },
  row: { flexDirection: "row", gap: 8 },
  input: { flex: 1, backgroundColor: "#fff", borderRadius: 10, padding: 12, fontSize: 16, borderWidth: 1, borderColor: "#d0d4dc" },
  button: { backgroundColor: "#3b5bdb", justifyContent: "center", paddingHorizontal: 16, borderRadius: 10 },
  buttonText: { color: "#fff", fontWeight: "600" },
  gap: { marginTop: 32 },
  error: { color: "#c92a2a", textAlign: "center" },
  card: { marginTop: 24, padding: 20, borderRadius: 14, backgroundColor: "#fff", alignItems: "center" },
  city: { fontSize: 22, fontWeight: "600" },
  temp: { fontSize: 56, fontWeight: "700", marginVertical: 8 },
  desc: { fontSize: 18 },
  wind: { color: "#555", marginTop: 6 },
});

How it works

  • fetchWeather throws an Error with a friendly message at each failure point. The screen only has to catch it.
  • search sets loading, awaits the data, then sets ok or error.
  • The screen renders one block for each kind of state with simple && checks.
  • encodeURIComponent makes the city name safe to put in a URL (for example, for names with spaces).

Expected output

You see a search box and a Search button. Type London and press Search. A spinner shows briefly, then a card with London, GB, a large temperature such as 11°C, a description such as Partly cloudy and the wind speed. The numbers are live and will differ from this example. Type a nonsense word and the app shows "City not found". Turn on airplane mode and search again: a short error message appears and the app keeps running.

Your task

  1. Build the app and search three cities.
  2. Test the error paths: a nonsense city and airplane mode.
  3. Show an emoji next to the description using a lookup table keyed by weather code.
  4. Remember the last city with AsyncStorage and search for it when the app opens.

How to check it worked

  • A real city shows live data.
  • Errors show a message, never a crash.
  • You can explain why res.ok must be checked.

Frequently asked questions

Why doesn't fetch throw on a 404?

fetch only rejects when the network request itself fails. A 404 or 500 is still a response, so you must check res.ok and throw your own error.

Why use a union type for state?

It makes impossible states impossible, such as loading and error at once. TypeScript also warns you if you forget to handle a case.

Axios or fetch?

fetch is built in and enough for most apps. Axios adds conveniences like interceptors. For caching and retries, use TanStack Query on top of either, as in project 7.