Project 10 Capstone: Shop App with Zustand and Tabs

React Native with Expo: 10 Real Projects · Advanced Apps and Release · 11 min read · Updated

A shared cart store

  1. The user taps Add to cart on a product.
  2. The action updates the store.
  3. The tab label reads the count from the store and shows Cart (2).
  4. The Cart tab reads the same lines and total, so all screens agree.

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: create makes a store with state and actions. Components read what they need with a selector like useCart((s) => s.add), and re-render only when that part changes.
  • Selectors and derived data: cartCount and cartTotal are plain functions over the lines, so they cannot go out of date.
  • Tabs: Tabs from 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

  1. Use an Expo Router project.
  2. Install: npm install zustand @tanstack/react-query.
  3. Create the files below. Note that cart.ts (the store) lives in src, and cart.tsx (the screen) lives in app.
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

  • add increases the quantity if the product is already in the cart, or adds a new line. decrease removes a line when its quantity reaches zero. flatMap makes that easy.
  • The shop reads only add from 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

  1. Build the app and fill the cart from two categories.
  2. Persist the cart with Zustand's persist middleware and AsyncStorage.
  3. Add a product detail screen with a larger image and description.
  4. 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.

Frequently asked questions

Zustand, Redux or Context?

Context is fine for small shared state. Zustand is lighter than Redux, has less code and avoids needless re-renders with selectors. Redux Toolkit is common in big teams, so it is worth reading about.

Why use selectors?

A selector subscribes a component to only one piece of the store. The component redraws only when that piece changes.

How would I add real payments?

Use a payment provider's official SDK or hosted checkout and a server that creates orders. Never handle card numbers yourself.