Mobile implementation
Expo screens.
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)
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
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
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 (<PressableonPress={() => toggle.mutate()}hitSlop={12}className="p-2 rounded-full active:bg-bg-hover"><Heartsize={22}fill={isBookmarked ? '#ff4d6d' : 'transparent'}color={isBookmarked ? '#ff4d6d' : '#9090a8'}/></Pressable>)}
Three mobile-specific touches:
Pressableinstead ofbutton.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
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 (<FlatListdata={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
<Tabs.Screenname="bookmarks"options={{title: 'Bookmarks',tabBarIcon: ({ color }) => <Heart size={22} color={color} />,}}/>
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
Try it
Mobile bookmarks, full flow:
- Add the API client + hooks.
- Drop the BookmarkButton on every product card.
- Add the dedicated Bookmarks tab.
- Test: tap a heart, switch to Bookmarks tab β see it. Tap again to remove. Pull-to-refresh to confirm server state matches.
- 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