Skip to content

Data Client, Cancellation, and List Recipes

A React Native guide for coding agents. Also covers fetch code examples, typed api client, query cancellation, abort signal, list performance, pure reducer.

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.

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-react-native-guide({ topic: "react-native-data-client-recipes" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems