WHAT: REST API data fetching with TanStack Query and OpenTelemetry tracing. WHEN: fetching non-GraphQL endpoints, implementing pagination, cache invalidation...
Standards for fetching REST API data using TanStack Query with OpenTelemetry tracing.
Use REST API only when data is NOT available in GraphQL schema. Always verify GraphQL schema first.
Use these standards when:
Why: GraphQL provides better type safety and reduces over-fetching, but REST is needed for legacy endpoints and external APIs.
Always use useFetch hook instead of native fetch(). It provides automatic OpenTelemetry tracing, request ID generation, and authentication middleware.
ā Good:
import { useFetch } from '@libs/networking-client';
export const useGetProducts = (params: GetProductsRequest) => {
const fetch = useFetch(); // Provides traced fetch with request IDs
return useQuery({
queryKey: PRODUCT_QUERY_KEYS.list(params),
queryFn: () => getProducts(params, queryKey, fetch),
});
};
ā Bad:
// Never use native fetch directly
export const useGetProducts = (params: GetProductsRequest) => {
return useQuery({
queryKey: ['products', params],
queryFn: async () => {
const response = await fetch('/api/products'); // ā No tracing
return response.json();
},
});
};
Why: useFetch automatically:
Real-world usage: 200+ usages in shared-mobile-modules codebase.
Use pattern: [domain, operation, params]
ā Good:
export const PRODUCT_QUERY_KEYS = {
all: ['products'] as const,
list: (params?) => ['products', 'list', params] as const,
detail: (id: string) => ['products', 'detail', id] as const,
} as const;
// Usage
queryKey: PRODUCT_QUERY_KEYS.list({ category: 'electronics' })
// Result: ['products', 'list', { category: 'electronics' }]
ā Bad:
// Flat keys - hard to invalidate
queryKey: ['products']
queryKey: ['productList']
queryKey: ['product', id, 'random'] // Inconsistent structure
Why: Hierarchical keys enable precise cache invalidation:
queryClient.invalidateQueries({ queryKey: PRODUCT_QUERY_KEYS.all }) - Invalidates all product queriesqueryClient.invalidateQueries({ queryKey: PRODUCT_QUERY_KEYS.list() }) - Invalidates only list queriesOrganize by domain:
src/data-access/query/{domain}/
āāā constants.ts # Query keys & endpoints
āāā service.ts # Fetch functions
āāā schema.ts # TypeScript types
āāā hooks.ts # TanStack Query hooks
āāā index.ts # Exports
Why: Domain-based organization keeps API logic together and makes it easy to find related code.
Define explicit interfaces for all requests and responses.
// schema.ts
/**
* Product entity from API
*/
export interface Product {
/** Unique identifier */
id: string;
/** Product name */
name: string;
/** Price in cents */
price: number;
/** Product category */
category: 'electronics' | 'clothing' | 'books';
/** Availability status */
status: 'active' | 'inactive';
/** Creation timestamp (ISO 8601) */
createdAt: string;
}
/**
* Request parameters for fetching products
*/
export interface GetProductsRequest {
cursor?: string;
limit?: number;
category?: Product['category'];
search?: string;
}
/**
* Response for product list with pagination
*/
export interface GetProductsResponse {
data: Product[];
pagination: {
nextCursor?: string;
hasMore: boolean;
};
}
Why: Explicit types provide autocomplete, catch errors at compile time, and serve as documentation.
Implement fetch functions using the traced fetch parameter from useFetch.
// service.ts
import type { DataAccessGet } from './schema';
export const getProducts: DataAccessGet<GetProductsResponse, GetProductsRequest> = async (
params,
queryKey,
fetch
) => {
// Build query params
const searchParams = new URLSearchParams();
if (params.cursor) searchParams.append('cursor', params.cursor);
if (params.limit) searchParams.append('limit', params.limit.toString());
if (params.category) searchParams.append('category', params.category);
// fetch parameter comes from useFetch() - includes tracing
const response = await fetch(`${PRODUCT_ENDPOINTS.LIST}?${searchParams}`, queryKey, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch products: ${response.status}`);
}
return await response.json();
};
Why: Centralized service functions make API calls consistent and easier to test.
Create domain-specific hooks using TanStack Query.
// hooks.ts
import { useQuery } from '@tanstack/react-query';
import { useFetch, useLocalizeParams } from '@/hooks/data-access';
/**
* Hook for fetching products list
*/
export const useGetProducts = (params: GetProductsRequest, options?) => {
const fetch = useFetch();
const localizeParams = useLocalizeParams();
const requestParams = { ...params, ...localizeParams };
return useQuery({
queryKey: PRODUCT_QUERY_KEYS.list(requestParams),
queryFn: () => getProducts(requestParams, queryKey, fetch),
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
/**
* Hook for fetching single product
*/
export const useGetProduct = (productId: string, options?) => {
const fetch = useFetch();
const localizeParams = useLocalizeParams();
return useQuery({
queryKey: PRODUCT_QUERY_KEYS.detail(productId),
queryFn: () => getProduct({ id: productId, ...localizeParams }, queryKey, fetch),
enabled: !!productId,
staleTime: 10 * 60 * 1000, // 10 minutes
...options,
});
};
Why: Custom hooks abstract TanStack Query details, include localization automatically, and provide consistent patterns.
Use useInfiniteQuery for paginated lists with infinite scroll.
// hooks.ts
import { useInfiniteQuery } from '@tanstack/react-query';
export const useGetProductsInfinite = (params: Omit<GetProductsRequest, 'cursor'>, options?) => {
const fetch = useFetch();
const localizeParams = useLocalizeParams();
const requestParams = { ...params, ...localizeParams };
return useInfiniteQuery({
queryKey: [...PRODUCT_QUERY_KEYS.list(requestParams), 'infinite'],
queryFn: ({ pageParam }) => {
return getProducts({ ...requestParams, cursor: pageParam }, queryKey, fetch);
},
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.pagination.nextCursor,
staleTime: 5 * 60 * 1000,
...options,
});
};
Why: useInfiniteQuery manages pagination state automatically and enables smooth infinite scroll UX.
// ProductList.tsx
import { useGetProducts } from '@/data-access/query/products';
export const ProductList = () => {
const { data, isLoading, error, refetch } = useGetProducts({
limit: 20,
sort: 'createdAt',
});
if (isLoading && !data) return <LoadingSpinner />;
if (error && !data) return <ErrorMessage error={error} onRetry={refetch} />;
if (!data?.data.length) return <EmptyState />;
return (
<FlatList
data={data.data}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <ProductItem product={item} />}
/>
);
};
// ProductInfiniteList.tsx
import { useGetProductsInfinite } from '@/data-access/query/products';
export const ProductInfiniteList = ({ category }) => {
const {
data: infiniteData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
error,
} = useGetProductsInfinite({ category, limit: 20 });
// Flatten pages into single array
const products = infiniteData?.pages.flatMap((page) => page.data) || [];
if (isLoading && !infiniteData) return <LoadingSpinner />;
if (error && !infiniteData) return <ErrorMessage error={error} />;
if (!products.length) return <EmptyState />;
return (
<FlatList
data={products}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <ProductItem product={item} />}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={0.5}
ListFooterComponent={isFetchingNextPage ? <LoadingSpinner /> : null}
/>
);
};
Why: Flattening pages simplifies FlatList integration. onEndReached triggers pagination automatically.
// hooks.ts
export const useCreateProduct = (options?) => {
const queryClient = useQueryClient();
const fetch = useFetch();
return useMutation({
mutationFn: (params: CreateProductRequest) => {
return createProduct(params, queryKey, fetch);
},
onSuccess: () => {
// Invalidate list to refetch
queryClient.invalidateQueries({ queryKey: PRODUCT_QUERY_KEYS.all });
},
...options,
});
};
Why: Proper cache invalidation keeps UI in sync with server state after mutations.
Configure appropriate stale times based on data volatility.
// List data changes frequently
useQuery({
queryKey: PRODUCT_QUERY_KEYS.list(params),
queryFn: () => getProducts(params, queryKey, fetch),
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
});
// Detail data is more stable
useQuery({
queryKey: PRODUCT_QUERY_KEYS.detail(id),
queryFn: () => getProduct({ id }, queryKey, fetch),
staleTime: 10 * 60 * 1000, // 10 minutes
gcTime: 15 * 60 * 1000, // 15 minutes
});
Why: Proper cache timing reduces unnecessary API calls while keeping data fresh.
Mock service functions and test hooks in isolation.
import { renderHook, waitFor } from '@testing-library/react-native';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useGetProducts } from './hooks';
import * as service from './service';
jest.mock('./service');
jest.mock('@/hooks/data-access', () => ({
useFetch: () => jest.fn(),
useLocalizeParams: () => ({ locale: 'en' }),
}));
const mockGetProducts = service.getProducts as jest.MockedFunction<typeof service.getProducts>;
test('returns products data', async () => {
const mockResponse = {
data: [{ id: '1', name: 'Test Product', price: 2999 }],
pagination: { hasMore: false },
};
mockGetProducts.mockResolvedValue(mockResponse);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useGetProducts({ limit: 10 }), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockResponse);
});
Why: Mocking enables testing without actual API calls and validates query structure.
// GraphQL query exists - use that instead
const products = useQuery({
queryKey: ['products'],
queryFn: fetchProductsRest, // Wrong
});
useEffect(() => {
fetch('/api/products').then(setData); // Wrong - no caching, no tracing
}, []);
// Inconsistent structure
const queryKey = ['products', id, 'random'];
const { data, isLoading, error } = useGetProducts(params);
Query Keys:
const KEYS = {
all: ['domain'] as const,
list: (params?) => ['domain', 'list', params] as const,
detail: (id) => ['domain', 'detail', id] as const,
} as const;
Standard Hook:
export const useGet = (params, options?) => {
const fetch = useFetch();
return useQuery({
queryKey: KEYS.list(params),
queryFn: () => service(params, queryKey, fetch),
...options,
});
};
Infinite Query:
useInfiniteQuery({
queryKey: [...KEYS.list(params), 'infinite'],
queryFn: ({ pageParam }) => service({ ...params, cursor: pageParam }, queryKey, fetch),
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.pagination.nextCursor,
});
For detailed examples and patterns, see: