Typed API client
fetch wrapper + React Query.
One tiny module owns all your HTTP calls. Screens use React Query hooks; the hooks use a typed api client. That's the whole pattern. Get it right once, you write screen code without thinking about fetch.
The API client
import Constants from 'expo-constants'const API_URL = Constants.expoConfig?.extra?.apiUrl ?? 'http://localhost:8080'async function request<T>(method: string, path: string, body?: unknown, token?: string): Promise<T> {const res = await fetch(`${API_URL}${path}`, {method,headers: {'Content-Type': 'application/json',...(token ? { Authorization: `Bearer ${token}` } : {}),},body: body ? JSON.stringify(body) : undefined,})const json = await res.json()if (!res.ok) throw new ApiError(json.error?.code, json.error?.message, res.status)return json as T}export const api = {get: <T,>(path: string, token?: string) => request<T>('GET', path, undefined, token),post: <T,>(path: string, body: unknown, token?: string) => request<T>('POST', path, body, token),put: <T,>(path: string, body: unknown, token?: string) => request<T>('PUT', path, body, token),del: <T,>(path: string, token?: string) => request<T>('DELETE', path, undefined, token),}export class ApiError extends Error {constructor(public code: string, message: string, public status: number) {super(message)}}
Tiny: 25 lines. Returns the envelope's data field wrapped in your generated TS type. Errors become a typed ApiError with the code from Grit's response envelope.
The React Query hook
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'import type { User } from '@grit/shared/types/user'import { api } from '@/lib/api'import { useAuth } from './use-auth'export function useUsers() {const { token } = useAuth()return useQuery({queryKey: ['users'],queryFn: () => api.get<{ data: User[] }>(`/api/users`, token).then((r) => r.data),enabled: !!token,})}export function useCreateUser() {const qc = useQueryClient()const { token } = useAuth()return useMutation({mutationFn: (input: { email: string; name: string }) =>api.post<{ data: User }>('/api/users', input, token),onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),})}
Using it in a screen
import { FlatList, Text, View } from 'react-native'import { useUsers } from '@/hooks/use-users'export default function HomeScreen() {const { data: users, isLoading, error } = useUsers()if (isLoading) return <Text>Loading...</Text>if (error) return <Text>Error: {error.message}</Text>return (<FlatListdata={users ?? []}keyExtractor={(u) => u.id}renderItem={({ item }) => (<View><Text>{item.name}</Text><Text>{item.email}</Text></View>)}/>)}
That's the whole pattern. Screen state lives in React Query; the API client is type-safe; mutations invalidate the queries that depend on them. Add a new endpoint by adding a hook.
Error UI ā toast + boundary
import Toast from 'react-native-toast-message'export function useCreateUser() {return useMutation({mutationFn: (input) => api.post(...),onError: (err) => {if (err instanceof ApiError && err.code === 'VALIDATION_ERROR') {// form-level handlingreturn}Toast.show({ type: 'error', text1: err.message })},})}
Toast for unrecoverable errors; onError branches by err.code for ones the form should display inline. Same pattern you saw in the Concepts course.
Quick check
Try it
Build the chapter assignment:
- In
apps/mobile/hooks/, adduse-users.tsusing the pattern above. - In
apps/mobile/app/(tabs)/index.tsx, render the user list. - Confirm TypeScript autocompletes
user.emailwithout an explicit type annotation ā that's the proof the sync is working. - Paste a screenshot of the rendered list in
notes.md.
What's next
Chapter 3 ā Mobile Auth. Login screens, secure token storage with SecureStore / Keychain, and silent refresh-on-401.
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