Mobile implementation

Expo screens.

9 minmedium

Mobile bookmarks: heart icon on every product card, dedicated Bookmarks tab in the bottom nav. The shared types from chapter 2 carry over verbatim β€” only the rendering changes.

The API client (Expo flavor)

apps/mobile/lib/api/bookmarks.ts
import { BookmarkSchema, type Bookmark } from '@my-saas/shared'
import { authedFetch } from '../api'
export async function listBookmarks(): Promise<Bookmark[]> {
const res = await authedFetch('/api/bookmarks')
const json = await res.json()
return json.data.map((b: unknown) => BookmarkSchema.parse(b))
}
export async function createBookmark(productId: number) {
const res = await authedFetch('/api/bookmarks', {
method: 'POST',
body: JSON.stringify({ product_id: productId }),
})
return BookmarkSchema.parse((await res.json()).data)
}
export async function deleteBookmark(id: number) {
await authedFetch(`/api/bookmarks/${id}`, { method: 'DELETE' })
}

Notice how close this is to web. The difference: authedFetch reads the JWT from SecureStore and includes it as a Bearer token (mobile doesn't use cookies). The shape of the result is identical because the Zod schema is identical.

The hooks β€” same as web

apps/mobile/hooks/use-bookmarks.ts
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import * as api from '../lib/api/bookmarks'
export function useBookmarks() {
return useQuery({ queryKey: ['bookmarks'], queryFn: api.listBookmarks })
}
export function useToggleBookmark(productId: number) {
const qc = useQueryClient()
const { data: bookmarks = [] } = useBookmarks()
const existing = bookmarks.find((b) => b.product_id === productId)
return useMutation({
mutationFn: () =>
existing ? api.deleteBookmark(existing.id) : api.createBookmark(productId),
onMutate: async () => {
await qc.cancelQueries({ queryKey: ['bookmarks'] })
const prev = qc.getQueryData(['bookmarks'])
qc.setQueryData(['bookmarks'], (old: any[] = []) =>
existing
? old.filter((b) => b.id !== existing.id)
: [...old, { id: -1, product_id: productId, user_id: 0, created_at: new Date().toISOString() }],
)
return { prev }
},
onError: (_e, _v, ctx) => qc.setQueryData(['bookmarks'], ctx?.prev),
onSettled: () => qc.invalidateQueries({ queryKey: ['bookmarks'] }),
})
}

Literally identical to the web version. React Query works the same in Expo. This is the leverage you bought with the shared types.

The heart icon β€” React Native + NativeWind

apps/mobile/components/BookmarkButton.tsx
import { Pressable } from 'react-native'
import { Heart } from 'lucide-react-native'
import { useBookmarks, useToggleBookmark } from '../hooks/use-bookmarks'
export function BookmarkButton({ productId }: { productId: number }) {
const { data: bookmarks = [] } = useBookmarks()
const toggle = useToggleBookmark(productId)
const isBookmarked = bookmarks.some((b) => b.product_id === productId)
return (
<Pressable
onPress={() => toggle.mutate()}
hitSlop={12}
className="p-2 rounded-full active:bg-bg-hover"
>
<Heart
size={22}
fill={isBookmarked ? '#ff4d6d' : 'transparent'}
color={isBookmarked ? '#ff4d6d' : '#9090a8'}
/>
</Pressable>
)
}

Three mobile-specific touches:

  • Pressable instead of button.
  • hitSlop=12 β€” extends the tappable area outside the visual icon. Crucial on touch; a 22px target alone is frustrating.
  • lucide-react-native β€” same icon set, native-friendly render. Same prop names.

The dedicated Bookmarks tab

apps/mobile/app/(tabs)/bookmarks.tsx
import { View, Text, FlatList } from 'react-native'
import { useBookmarks } from '../../hooks/use-bookmarks'
import { useProducts } from '../../hooks/use-products'
import { ProductCard } from '../../components/ProductCard'
export default function BookmarksScreen() {
const { data: bookmarks = [], isLoading } = useBookmarks()
const { data: products = [] } = useProducts()
const items = bookmarks
.map((b) => products.find((p) => p.id === b.product_id))
.filter(Boolean)
if (isLoading) return <View className="p-6"><Text>Loading…</Text></View>
if (items.length === 0) return (
<View className="p-6">
<Text className="text-text-secondary">
No bookmarks yet β€” tap a heart on any product to save it.
</Text>
</View>
)
return (
<FlatList
data={items}
keyExtractor={(item) => String(item!.id)}
renderItem={({ item }) => <ProductCard product={item!} />}
contentContainerClassName="p-4 gap-3"
/>
)
}

FlatList instead of .map() β€” it virtualises the list. On a phone with 200 bookmarks, a flat .map() hangs the JS thread. FlatList renders only the visible window.

Tab registration

apps/mobile/app/(tabs)/_layout.tsx (add this Tab)
<Tabs.Screen
name="bookmarks"
options={{
title: 'Bookmarks',
tabBarIcon: ({ color }) => <Heart size={22} color={color} />,
}}
/>
Why a dedicated tab vs. nested in profile? On mobile, taps to discoverability matter. A bookmark tab in the bottom nav means "one tap from anywhere". Nested under profile means "tap profile, scroll, tap bookmarks". Use tabs for the 3-5 most-used features.

Persistence β€” the offline bonus

React Query for mobile has a persister plugin. With ~20 lines of setup (covered in chapter 4), the bookmark list survives an app cold-start while offline. That comes nearly free.

Quick check

Why use FlatList instead of map() inside a ScrollView for a bookmarks list?

Try it

Mobile bookmarks, full flow:

  1. Add the API client + hooks.
  2. Drop the BookmarkButton on every product card.
  3. Add the dedicated Bookmarks tab.
  4. Test: tap a heart, switch to Bookmarks tab β€” see it. Tap again to remove. Pull-to-refresh to confirm server state matches.
  5. Test offline (toggle airplane mode): heart still toggles optimistically, errors gracefully. We'll queue these for real in chapter 4.

What's next

Next lesson β€” Desktop implementation. Same feature on Wails. Keyboard shortcut (Cmd/Ctrl+D), persistent sidebar, the desktop's "power user" angle.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled β€” suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub