The last project brings together a typical shop: products from an API, filters, a cart and two tabs. It adds Zustand, a small, popular state library. A cart is a good example of global state because many screens read and change it, and the tab bar even shows its count.
Concepts in this project
- Zustand store:
createmakes a store with state and actions. Components read what they need with a selector likeuseCart((s) => s.add), and re-render only when that part changes. - Selectors and derived data:
cartCountandcartTotalare plain functions over the lines, so they cannot go out of date. - Tabs:
Tabsfrom Expo Router gives a bottom tab bar. The Cart tab label shows the live count. - FlatList grid:
numColumns={2}makes a product grid. A horizontal FlatList shows category chips. - TanStack Query loads the products (project 7).
- Data source: the Fake Store API at fakestoreapi.com. It is a free practice API, so check that it is still online. Prices are demo values, and the checkout here only clears the cart.
Create the project
- Use an Expo Router project.
- Install:
npm install zustand @tanstack/react-query. - Create the files below. Note that
cart.ts(the store) lives insrc, andcart.tsx(the screen) lives inapp.
my-app/
app/
_layout.tsx Tabs + QueryClientProvider
index.tsx Shop tab: products
cart.tsx Cart tab
src/
api.ts fetchProducts
cart.ts the Zustand store
`src/cart.ts`
import { create } from "zustand";
export type Product = { id: number; title: string; price: number; image: string; category: string };
type Line = { product: Product; qty: number };
type CartState = {
lines: Line[];
add: (p: Product) => void;
decrease: (id: number) => void;
remove: (id: number) => void;
clear: () => void;
};
export const useCart = create<CartState>((set) => ({
lines: [],
add: (p) =>
set((s) => {
const found = s.lines.find((l) => l.product.id === p.id);
return found
? { lines: s.lines.map((l) => (l.product.id === p.id ? { ...l, qty: l.qty + 1 } : l)) }
: { lines: [...s.lines, { product: p, qty: 1 }] };
}),
decrease: (id) =>
set((s) => ({
lines: s.lines.flatMap((l) => (l.product.id !== id ? [l] : l.qty > 1 ? [{ ...l, qty: l.qty - 1 }] : [])),
})),
remove: (id) => set((s) => ({ lines: s.lines.filter((l) => l.product.id !== id) })),
clear: () => set({ lines: [] }),
}));
export const cartCount = (lines: Line[]) => lines.reduce((n, l) => n + l.qty, 0);
export const cartTotal = (lines: Line[]) => lines.reduce((sum, l) => sum + l.product.price * l.qty, 0);
`src/api.ts`
import { Product } from "./cart";
export async function fetchProducts(): Promise<Product[]> {
const res = await fetch("https://fakestoreapi.com/products");
if (!res.ok) throw new Error("Could not load products");
return res.json();
}
`app/_layout.tsx`
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Tabs } from "expo-router";
import { Text } from "react-native";
import { cartCount, useCart } from "../src/cart";
const client = new QueryClient();
function CartLabel() {
const count = useCart((s) => cartCount(s.lines));
return <Text>Cart ({count})</Text>;
}
export default function Layout() {
return (
<QueryClientProvider client={client}>
<Tabs>
<Tabs.Screen name="index" options={{ title: "Shop", tabBarIcon: () => <Text>🛍</Text> }} />
<Tabs.Screen name="cart" options={{ title: "Cart", tabBarLabel: () => <CartLabel />, tabBarIcon: () => <Text>🛒</Text> }} />
</Tabs>
</QueryClientProvider>
);
}
`app/index.tsx`
import { useState } from "react";
import { View, Text, Image, Pressable, FlatList, ActivityIndicator, StyleSheet } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { fetchProducts } from "../src/api";
import { useCart } from "../src/cart";
export default function Shop() {
const add = useCart((s) => s.add);
const [category, setCategory] = useState<string | null>(null);
const { data, isLoading, isError, refetch, isRefetching } = useQuery({ queryKey: ["products"], queryFn: fetchProducts });
if (isLoading) return <ActivityIndicator size="large" style={{ marginTop: 40 }} />;
if (isError || !data)
return (
<View style={styles.center}>
<Text style={styles.error}>Could not load products.</Text>
<Pressable onPress={() => refetch()}>
<Text style={styles.link}>Try again</Text>
</Pressable>
</View>
);
const categories = [...new Set(data.map((p) => p.category))];
const shown = category ? data.filter((p) => p.category === category) : data;
return (
<View style={styles.screen}>
<FlatList
horizontal
data={categories}
keyExtractor={(c) => c}
style={styles.chips}
ListHeaderComponent={
<Pressable onPress={() => setCategory(null)} style={[styles.chip, category === null && styles.chipOn]}>
<Text style={category === null ? styles.chipTextOn : undefined}>All</Text>
</Pressable>
}
renderItem={({ item }) => (
<Pressable onPress={() => setCategory(item)} style={[styles.chip, category === item && styles.chipOn]}>
<Text style={category === item ? styles.chipTextOn : undefined}>{item}</Text>
</Pressable>
)}
/>
<FlatList
data={shown}
numColumns={2}
keyExtractor={(p) => String(p.id)}
refreshing={isRefetching}
onRefresh={refetch}
renderItem={({ item }) => (
<View style={styles.card}>
<Image source={{ uri: item.image }} style={styles.image} resizeMode="contain" />
<Text numberOfLines={2} style={styles.name}>{item.title}</Text>
<Text style={styles.price}>${item.price.toFixed(2)}</Text>
<Pressable style={styles.button} onPress={() => add(item)}>
<Text style={styles.buttonText}>Add to cart</Text>
</Pressable>
</View>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 8 },
center: { flex: 1, alignItems: "center", justifyContent: "center" },
error: { color: "#c92a2a" },
link: { color: "#3b5bdb", marginTop: 8 },
chips: { flexGrow: 0, marginBottom: 8 },
chip: { paddingVertical: 6, paddingHorizontal: 12, borderRadius: 16, backgroundColor: "#e3e6ee", marginRight: 6 },
chipOn: { backgroundColor: "#3b5bdb" },
chipTextOn: { color: "#fff" },
card: { flex: 1, margin: 6, padding: 10, backgroundColor: "#fff", borderRadius: 12 },
image: { height: 110, width: "100%" },
name: { fontSize: 13, marginTop: 8, minHeight: 34 },
price: { fontWeight: "700", marginVertical: 4 },
button: { backgroundColor: "#3b5bdb", padding: 8, borderRadius: 8, alignItems: "center" },
buttonText: { color: "#fff", fontSize: 13 },
});
`app/cart.tsx`
import { View, Text, Image, Pressable, FlatList, StyleSheet } from "react-native";
import { cartTotal, useCart } from "../src/cart";
export default function CartScreen() {
const { lines, add, decrease, remove, clear } = useCart();
const total = cartTotal(lines);
return (
<View style={styles.screen}>
<FlatList
data={lines}
keyExtractor={(l) => String(l.product.id)}
ListEmptyComponent={<Text style={styles.empty}>Your cart is empty.</Text>}
renderItem={({ item }) => (
<View style={styles.row}>
<Image source={{ uri: item.product.image }} style={styles.image} resizeMode="contain" />
<View style={{ flex: 1 }}>
<Text numberOfLines={2}>{item.product.title}</Text>
<Text style={styles.price}>${(item.product.price * item.qty).toFixed(2)}</Text>
</View>
<Pressable style={styles.qty} onPress={() => decrease(item.product.id)}><Text>-</Text></Pressable>
<Text style={styles.qtyText}>{item.qty}</Text>
<Pressable style={styles.qty} onPress={() => add(item.product)}><Text>+</Text></Pressable>
<Pressable onPress={() => remove(item.product.id)}><Text style={styles.del}> ✕</Text></Pressable>
</View>
)}
/>
{lines.length > 0 && (
<View style={styles.footer}>
<Text style={styles.total}>Total: ${total.toFixed(2)}</Text>
<Pressable style={styles.checkout} onPress={clear}>
<Text style={styles.checkoutText}>Place demo order</Text>
</Pressable>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 12 },
empty: { textAlign: "center", color: "#888", marginTop: 40 },
row: { flexDirection: "row", alignItems: "center", backgroundColor: "#fff", padding: 10, borderRadius: 10, marginBottom: 8, gap: 8 },
image: { width: 48, height: 48 },
price: { fontWeight: "700", marginTop: 2 },
qty: { width: 28, height: 28, borderRadius: 14, backgroundColor: "#e3e6ee", alignItems: "center", justifyContent: "center" },
qtyText: { minWidth: 18, textAlign: "center" },
del: { color: "#d6336c" },
footer: { paddingTop: 8 },
total: { fontSize: 20, fontWeight: "700", marginBottom: 8 },
checkout: { backgroundColor: "#2b8a3e", padding: 14, borderRadius: 10, alignItems: "center" },
checkoutText: { color: "#fff", fontWeight: "600" },
});
How it works
addincreases the quantity if the product is already in the cart, or adds a new line.decreaseremoves a line when its quantity reaches zero.flatMapmakes that easy.- The shop reads only
addfrom the store, so it does not redraw when quantities change elsewhere. - The cart tab label reads the count with a selector and updates instantly.
- Place demo order clears the cart. A real checkout needs a payment provider and a server, which this app does not include.
Expected output
The Shop tab shows category chips (All and the categories from the API) above a two-column grid of products with pictures, titles and prices. Tap Add to cart on two products: the tab shows Cart (2). Open the Cart tab: the lines show images, titles, line prices and − and + buttons. Press + on a line and the total goes up. Press − until the quantity is zero and the line disappears. Press the ✕ to remove a line. Place demo order empties the cart.
Your task
- Build the app and fill the cart from two categories.
- Persist the cart with Zustand's
persistmiddleware and AsyncStorage. - Add a product detail screen with a larger image and description.
- Add a promo code text box that applies a fixed discount, and show the saved amount.
How to check it worked
- The cart count in the tab bar matches the items.
- Quantities and totals stay correct as you tap + and −.
- You can explain why a selector keeps screens from redrawing.