A mobile data layer must assume unreliable networks, interrupted screens, stale cached data, and malformed responses. Keep transport logic small, validate at the edge, and preserve cancellation from the screen to fetch.
Build One Validated JSON Helper
import { ZodError, type ZodType } from "zod";
export class ApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
) {
super(code);
}
}
export async function fetchJson<T>(
path: string,
schema: ZodType<T>,
init: RequestInit = {},
): Promise<T> {
const response = await authenticatedFetch(path, init);
const body: unknown = await response.json().catch(() => null);
if (!response.ok) {
const code =
typeof body === "object" && body !== null && "code" in body && typeof body.code === "string"
? body.code
: "REQUEST_FAILED";
throw new ApiError(response.status, code);
}
return schema.parse(body);
}
Domain functions supply the expected schema:
import { z } from "zod";
const profileSchema = z.object({
id: z.string(),
displayName: z.string(),
avatarUrl: z.string().url().nullable(),
});
export const getProfile = (signal?: AbortSignal) =>
fetchJson("/profile", profileSchema, { signal });
The inferred type stays tied to runtime validation. Do not separately declare an interface that can drift from the schema.
Preserve Query Cancellation
TanStack Query passes an AbortSignal; forward it all the way to fetch.
import { queryOptions } from "@tanstack/react-query";
export const profileQuery = queryOptions({
queryKey: ["profile"],
queryFn: ({ signal }) => getProfile(signal),
staleTime: 30_000,
});
Cancellation avoids finishing obsolete work when a screen unmounts or a query key changes. Treat AbortError as cancellation, not a user-facing failure.
Separate Retry Policy by Operation
export function shouldRetry(failureCount: number, error: unknown): boolean {
if (failureCount >= 2) return false;
if (error instanceof Error && error.name === "AbortError") return false;
if (error instanceof ZodError) return false;
if (error instanceof ApiError) {
return error.status === 429 || error.status >= 500;
}
return error instanceof TypeError; // fetch reports transient transport failures as TypeError
}
Reads can usually retry. Mutations require an idempotency key and should never blindly retry validation errors, permissions, or a purchase operation.
Virtualize Lists and Pass Primitive Props
import { memo, useCallback } from "react";
import { FlatList, Pressable, Text } from "react-native";
type Result = { id: string; title: string; selected: boolean };
const ResultRow = memo(function ResultRow({
id,
title,
selected,
onSelect,
}: {
id: string;
title: string;
selected: boolean;
onSelect(id: string): void;
}) {
return (
<Pressable
accessibilityRole="button"
onPress={() => onSelect(id)}
style={{ minHeight: 44, justifyContent: "center" }}
>
<Text>{selected ? `${title}, selected` : title}</Text>
</Pressable>
);
});
export function ResultsList({ data, onSelect }: { data: Result[]; onSelect(id: string): void }) {
const renderItem = useCallback(
({ item }: { item: Result }) => (
<ResultRow
id={item.id}
title={item.title}
selected={item.selected}
onSelect={onSelect}
/>
),
[onSelect],
);
return <FlatList data={data} renderItem={renderItem} keyExtractor={(item) => item.id} />;
}
Use FlashList or LegendList when their measurement and recycling behavior benefits the screen. Do not render a collection by mapping every item inside a ScrollView.
Keep Reducers Pure and Persist Outside
type ChatState = { draft: string; messageIds: string[] };
type ChatAction =
| { type: "draftChanged"; value: string }
| { type: "messageAdded"; id: string };
export function chatReducer(state: ChatState, action: ChatAction): ChatState {
switch (action.type) {
case "draftChanged":
return { ...state, draft: action.value };
case "messageAdded":
return { draft: "", messageIds: [...state.messageIds, action.id] };
}
}
Network requests, analytics, and storage writes run in a command/repository layer or effect after the state transition. This keeps replay and tests deterministic.
Loading and Error UX
- Keep cached content visible during background refresh.
- Use a full-screen error only when the whole screen cannot function.
- Put Retry beside the failed operation.
- Give offline mutations a clear queued or unsent state.
- Do not show raw exception text, stack traces, or provider payloads.
- Announce important failures to assistive technology with a short message and next action.
Sources
Cancellation follows the official TanStack Query cancellation contract. Runtime parsing follows Zod. The transport, retry, list, and reducer recipes are independently written.