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. Checkres.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
- Use a blank Expo TypeScript project. No extra library is needed.
- Add
weather.tsand replaceApp.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}` +
`¤t=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
fetchWeatherthrows anErrorwith a friendly message at each failure point. The screen only has to catch it.searchsetsloading, awaits the data, then setsokorerror.- The screen renders one block for each kind of state with simple
&&checks. encodeURIComponentmakes 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
- Build the app and search three cities.
- Test the error paths: a nonsense city and airplane mode.
- Show an emoji next to the description using a lookup table keyed by weather code.
- 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.okmust be checked.