Calling APIs and storing data on the device
Load the Brew & Bean menu from an API with loading, error and refresh states, save a cart on the device with AsyncStorage, and send the order to the server. Learn where each kind of data belongs.
A useful app shows live data and remembers things. In this tutorial the Brew & Bean app loads its menu from an API (with loading and error states and pull to refresh), saves a cart on the device so it survives closing the app, and sends the order to the server with a POST request.
Prerequisites: the previous lessons (tabs, MenuItem, app/item/[id].js), and a laptop with Python. If you have built the Brew & Bean API in the Backend track, use it; otherwise the small server below is enough.
Overview
What we will do
- 1Run a small API on your laptop.
- 2Point the app at it and load the menu.
- 3Save the cart on the device.
- 4Send the order to the server.
Step 1: A small API to talk to
Save this as main.py in a new folder on your laptop (not inside the Expo project). It serves GET /menu and POST /orders with the shared Brew & Bean ids and prices, and computes the total on the server, as a real API should. Orders are kept in memory and lost when the server restarts. This is for learning only.
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
MENU = [
{"id": "espresso", "name": "Espresso", "category": "coffee", "price": 120},
{"id": "cappuccino", "name": "Cappuccino", "category": "coffee", "price": 180},
{"id": "cold-brew", "name": "Cold Brew", "category": "coffee", "price": 220},
{"id": "filter-coffee", "name": "Filter Coffee", "category": "coffee", "price": 80},
{"id": "masala-chai", "name": "Masala Chai", "category": "tea", "price": 90},
{"id": "croissant", "name": "Butter Croissant", "category": "bakery", "price": 150},
{"id": "banana-bread", "name": "Banana Bread", "category": "bakery", "price": 130},
{"id": "veg-sandwich", "name": "Veg Grilled Sandwich", "category": "food", "price": 160},
]
PRICES = {m["id"]: m["price"] for m in MENU}
ORDERS = {}
app = FastAPI()
class OrderItem(BaseModel):
item_id: str
qty: int
class OrderIn(BaseModel):
customer_name: str
phone: str
items: list[OrderItem]
pickup_time: str
@app.get("/menu")
def get_menu():
return MENU
@app.post("/orders", status_code=201)
def create_order(order: OrderIn):
for i in order.items:
if i.item_id not in PRICES or i.qty < 1:
raise HTTPException(status_code=422, detail="Unknown item or bad quantity")
total = sum(PRICES[i.item_id] * i.qty for i in order.items)
order_id = len(ORDERS) + 1
ORDERS[order_id] = {"id": order_id, "status": "received", "total": total, **order.model_dump()}
return ORDERS[order_id]The server computes the total itself. A client must never be trusted to send the price.
pip install fastapi uvicorn
uvicorn main:app --host 0.0.0.0 --port 8000--host 0.0.0.0 makes the server reachable from other devices on your Wi-Fi, such as your phone. Allow it if your firewall asks.
Step 2: Find your laptop's address
On the phone, localhost means the phone itself, not your laptop. You need your laptop's address on the Wi-Fi network. On Windows run ipconfig and look for the IPv4 Address of your Wi-Fi adapter. On macOS or Linux run ifconfig or ip addr. It looks like 192.168.1.5. Test it in the phone's browser: open http://192.168.1.5:8000/menu and you should see the JSON.
Create a file named .env in the project root (next to package.json):
EXPO_PUBLIC_API_URL=http://192.168.1.5:8000Use your own address. Variables that start with EXPO_PUBLIC_ are copied into the app at build time. Restart npx expo start after changing the file.
Not a secret
Anything in an EXPO_PUBLIC_ variable ends up inside the app and can be read by anyone who has it. Never put passwords, secret API keys or tokens there. Use it for things like the server address.
Plain http:// is fine for testing on your own network with Expo Go. Release builds of Android apps block unencrypted HTTP by default, so a published app must talk to an https:// server.
Step 3: An API helper
Keep all network code in one file so screens stay simple and the address is in one place. Create lib/api.js.
const BASE_URL = process.env.EXPO_PUBLIC_API_URL;
async function request(path, options) {
const response = await fetch(BASE_URL + path, {
headers: { "Content-Type": "application/json" },
...options,
});
if (!response.ok) {
throw new Error("Server error " + response.status);
}
return response.json();
}
export function getMenu() {
return request("/menu");
}
export function placeOrder(order) {
return request("/orders", { method: "POST", body: JSON.stringify(order) });
}fetch only rejects on network failure. A 404 or 500 still resolves, so we check response.ok ourselves and throw.
Step 4: Load the menu with loading and error states
Mobile networks fail often, especially on the move in Tier-2 and Tier-3 towns. Every screen that loads data needs three states: loading, error (with a retry) and success. In app/(tabs)/index.js, replace the static MENU import with state that is filled from the API.
import { useState, useEffect, useCallback } from "react";
import { View, Text, TextInput, FlatList, Pressable, ActivityIndicator, StyleSheet } from "react-native";
import MenuItem from "../../components/MenuItem";
import { CATEGORIES } from "../../data/menu";
import { getMenu } from "../../lib/api";
export default function Home() {
const [menu, setMenu] = useState([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState(null);
const [query, setQuery] = useState("");
const [category, setCategory] = useState("all");
const load = useCallback(async () => {
try {
setError(null);
setMenu(await getMenu());
} catch (e) {
setError("Could not load the menu. Check your connection and try again.");
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const items = menu.filter(
(m) =>
(category === "all" || m.category === category) &&
m.name.toLowerCase().includes(query.trim().toLowerCase())
);
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#4a2c17" />
</View>
);
}
if (error) {
return (
<View style={styles.center}>
<Text style={styles.error}>{error}</Text>
<Pressable style={styles.retry} onPress={() => { setLoading(true); load(); }}>
<Text style={styles.retryText}>Try again</Text>
</Pressable>
</View>
);
}
return (
<View style={styles.container}>
<TextInput
style={styles.search}
placeholder="Search the menu"
value={query}
onChangeText={setQuery}
autoCorrect={false}
/>
<View style={styles.chips}>
{CATEGORIES.map((c) => (
<Pressable
key={c}
onPress={() => setCategory(c)}
style={[styles.chip, category === c && styles.chipActive]}
>
<Text style={[styles.chipText, category === c && styles.chipTextActive]}>{c}</Text>
</Pressable>
))}
</View>
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <MenuItem item={item} />}
refreshing={refreshing}
onRefresh={() => { setRefreshing(true); load(); }}
ListEmptyComponent={<Text style={styles.empty}>No items match your search.</Text>}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#fff8f0", paddingHorizontal: 16, paddingTop: 16 },
center: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: "#fff8f0", padding: 24 },
error: { fontSize: 16, color: "#8b1e1e", textAlign: "center", marginBottom: 16 },
retry: { backgroundColor: "#4a2c17", paddingVertical: 12, paddingHorizontal: 24, borderRadius: 10 },
retryText: { color: "white", fontSize: 16 },
search: { backgroundColor: "white", borderRadius: 10, paddingHorizontal: 14, paddingVertical: 10, fontSize: 16, marginBottom: 12 },
chips: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginBottom: 14 },
chip: { paddingVertical: 6, paddingHorizontal: 14, borderRadius: 20, backgroundColor: "#ead9c8" },
chipActive: { backgroundColor: "#4a2c17" },
chipText: { color: "#4a2c17", textTransform: "capitalize" },
chipTextActive: { color: "white" },
empty: { textAlign: "center", color: "#8a6f5a", marginTop: 24 },
});Pulling the list down calls load again. CATEGORIES still comes from data/menu.js; only the items come from the API.
Test both paths. With the server running you should see the menu. Stop the server (Ctrl+C), pull to refresh, and the error message and retry button appear. Start it again and tap Try again.
Step 5: Save the cart on the device
State in React is lost when the app is closed. To keep data, write it to device storage. AsyncStorage is a simple key-value store that holds strings. It is good for settings, a cart or a small cache. It is not encrypted, so never keep passwords or tokens in it (the login lesson uses secure storage for those). Install it the Expo way:
npx expo install @react-native-async-storage/async-storageExpo chooses the version that matches your SDK. Restart the dev server after installing.
Create lib/cart.js. Objects must be turned into a string with JSON.stringify when saving and back with JSON.parse when loading.
import AsyncStorage from "@react-native-async-storage/async-storage";
const KEY = "cart";
export async function getCart() {
const raw = await AsyncStorage.getItem(KEY);
return raw ? JSON.parse(raw) : [];
}
export async function addToCart(item) {
const cart = await getCart();
const existing = cart.find((c) => c.item_id === item.id);
if (existing) {
existing.qty += 1;
} else {
cart.push({ item_id: item.id, name: item.name, price: item.price, qty: 1 });
}
await AsyncStorage.setItem(KEY, JSON.stringify(cart));
return cart;
}
export async function clearCart() {
await AsyncStorage.removeItem(KEY);
}The cart is a list of { item_id, name, price, qty }. All AsyncStorage calls are asynchronous and return promises.
Now the detail screen needs the item from the API instead of MENU, and an Add to cart button. Update app/item/[id].js:
import { useState, useEffect } from "react";
import { View, Text, Pressable, ActivityIndicator, StyleSheet } from "react-native";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { getMenu } from "../../lib/api";
import { addToCart } from "../../lib/cart";
export default function ItemDetail() {
const { id } = useLocalSearchParams();
const router = useRouter();
const [item, setItem] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
getMenu()
.then((menu) => setItem(menu.find((m) => m.id === id) || null))
.catch(() => setItem(null))
.finally(() => setLoading(false));
}, [id]);
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#4a2c17" />
</View>
);
}
if (!item) {
return (
<View style={styles.center}>
<Text style={styles.name}>Item not found</Text>
</View>
);
}
async function handleAdd() {
await addToCart(item);
router.push("/cart");
}
return (
<View style={styles.container}>
<Stack.Screen options={{ title: item.name }} />
<Text style={styles.name}>{item.name}</Text>
<Text style={styles.price}>₹{item.price}</Text>
<Pressable style={styles.button} onPress={handleAdd}>
<Text style={styles.buttonText}>Add to cart</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 24, backgroundColor: "#fff8f0" },
center: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: "#fff8f0" },
name: { fontSize: 28, fontWeight: "bold", color: "#2b1a0e" },
price: { fontSize: 24, color: "#4a2c17", marginTop: 12 },
button: { backgroundColor: "#4a2c17", padding: 14, borderRadius: 10, marginTop: 32, alignItems: "center" },
buttonText: { color: "white", fontSize: 16 },
});Downloading the whole menu to show one item is wasteful. We do it only because this small API has no GET /menu/{id}. A real API would have one.
Step 6: Show the cart and place the order
The cart screen reloads from storage every time it comes into focus. useFocusEffect runs when the tab is shown, not only when it is first created. A tab screen stays mounted, so a normal useEffect would show stale data. The callback must be wrapped in useCallback.
import { useState, useCallback } from "react";
import { View, Text, Pressable, Alert, StyleSheet } from "react-native";
import { useFocusEffect } from "expo-router";
import { getCart, clearCart } from "../../lib/cart";
import { placeOrder } from "../../lib/api";
export default function Cart() {
const [cart, setCart] = useState([]);
const [sending, setSending] = useState(false);
useFocusEffect(
useCallback(() => {
getCart().then(setCart);
}, [])
);
const total = cart.reduce((sum, c) => sum + c.price * c.qty, 0);
async function handlePlaceOrder() {
setSending(true);
try {
const order = await placeOrder({
customer_name: "Asha",
phone: "9876543210",
items: cart.map((c) => ({ item_id: c.item_id, qty: c.qty })),
pickup_time: "18:30",
});
await clearCart();
setCart([]);
Alert.alert("Order placed", "Order number " + order.id + ". Total ₹" + order.total);
} catch (e) {
Alert.alert("Could not place order", "Please try again.");
} finally {
setSending(false);
}
}
if (cart.length === 0) {
return (
<View style={styles.center}>
<Text style={styles.empty}>Your cart is empty.</Text>
</View>
);
}
return (
<View style={styles.container}>
{cart.map((c) => (
<View key={c.item_id} style={styles.row}>
<Text style={styles.name}>{c.qty} x {c.name}</Text>
<Text style={styles.price}>₹{c.price * c.qty}</Text>
</View>
))}
<Text style={styles.total}>Total: ₹{total}</Text>
<Pressable style={styles.button} onPress={handlePlaceOrder} disabled={sending}>
<Text style={styles.buttonText}>{sending ? "Placing..." : "Place order"}</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20, backgroundColor: "#fff8f0" },
center: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: "#fff8f0" },
empty: { fontSize: 18, color: "#6b4f3a" },
row: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 10 },
name: { fontSize: 17, color: "#2b1a0e" },
price: { fontSize: 17, color: "#4a2c17" },
total: { fontSize: 20, fontWeight: "bold", marginTop: 16 },
button: { backgroundColor: "#4a2c17", padding: 14, borderRadius: 10, marginTop: 24, alignItems: "center" },
buttonText: { color: "white", fontSize: 16 },
});The name and phone are fixed here to keep the lesson short. A real app would ask the customer. The total shown on screen is only a preview; the order's real total is what the server returns.
Open a Cappuccino and tap Add to cart: the Cart tab shows "1 x Cappuccino ₹180". Add it again and it becomes "2 x Cappuccino ₹360". Close the app completely and reopen it: the cart is still there. Tap Place order and an alert shows the order number and the server's total; the cart is then emptied.Which storage for what
Choosing a place to keep data
| Need | Use | Notes |
|---|---|---|
| Small settings, a cart, a cached list | AsyncStorage | Key-value strings, not encrypted, asynchronous |
| Tokens, passwords, secrets | expo-secure-store | Encrypted by the system keystore (login lesson) |
| Large or structured data you query | expo-sqlite | A real SQL database on the device (offline lesson) |
| Photos and files | expo-file-system | Files in the app's own folder |
Common mistakes
Watch out for
- Using
localhostin the API address. On a phone it points to the phone, not your laptop. - Forgetting to restart Expo after editing
.env. - Not checking
response.ok. A 500 error then looks like success. - Having no loading or error state, so a slow network shows a blank screen.
- Trusting prices sent by the client. The server must compute the total.
- Storing secrets in AsyncStorage or in
EXPO_PUBLIC_variables.
I am building a React Native (Expo) screen that loads data from my API with fetch.
Here is my code:
[paste your component and api helper]
Review it for these problems: no loading state, no error state, no check of response.ok, no way to retry, and state updates after the screen has closed. Show a corrected version and explain each change in one line.What to expect: A good answer adds loading and error states, checks response.ok, offers a retry, and may mention cancelling or ignoring results after unmount. Run the result with your server switched off to check the error path.
Try it: Quantity buttons and Clear cart
On the Cart screen add a "Clear cart" button that empties the cart (use clearCart). Then add + and - buttons beside each row that change the quantity; removing the last unit should remove the row. Save the result back to AsyncStorage.
Key takeaways
- On a phone, use your laptop's network address, not localhost; keep the API address in one place such as EXPO_PUBLIC_API_URL.
- fetch does not reject on 404 or 500; check response.ok and throw.
- Every data screen needs loading, error with retry, and success states.
- AsyncStorage keeps small unencrypted key-value data; use JSON.stringify and JSON.parse for objects.
- Never trust the client for prices; the server computes totals. Never keep secrets in AsyncStorage or public env variables.
Check yourself
Finishing the quiz marks the lesson as done.
1. Your app on a phone cannot reach http://localhost:8000. Why?
2. What does fetch do when the server answers with HTTP 500?
3. What is AsyncStorage suitable for?
4. Why does the cart screen use useFocusEffect instead of useEffect with an empty dependency list?
5. Who should compute the order total?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.