Typed API client

fetch wrapper + React Query.

8 minmedium

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

apps/mobile/lib/api.ts
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

apps/mobile/hooks/use-users.ts
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

apps/mobile/app/(tabs)/index.tsx
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 (
<FlatList
data={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.

Don't put fetch in your screens. Every screen importing the api client directly is how codebases drift into inconsistency. Hooks are the single layer between screens and the network — typed, cached, predictable.

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 handling
return
}
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

You add `useUsers()` to a screen. It works on the simulator but fails on your physical phone with 'network request failed'. The hook itself is fine. What's the cause?

Try it

Build the chapter assignment:

  1. In apps/mobile/hooks/, add use-users.ts using the pattern above.
  2. In apps/mobile/app/(tabs)/index.tsx, render the user list.
  3. Confirm TypeScript autocompletes user.email without an explicit type annotation — that's the proof the sync is working.
  4. 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