Blogs

Building a Type-Safe API Query System in React with TypeScript Streamlined Data Fetching

Building a Type-Safe API Query System in React with TypeScript

Github Repo

The Problem: API Query Management in Modern React Apps

When building React applications with TypeScript, managing API requests can quickly become complex. Common challenges include:

  1. Type Safety Gaps: Disconnection between API endpoint definitions and response types
  2. Parameter Management: Handling path variables, query parameters, and type checking
  3. Inconsistent Error Handling: Different components handling errors in various ways
  4. Boilerplate Code: Repeating fetch logic, loading states, and error handling

In a recent NextJS project, I faced these exact challenges. After trying various solutions, I developed a comprehensive system that provides end-to-end type safety while making API interactions predictable and maintainable.

Building a Type-Safe Query System

Let's dive into each component of the system, explaining their purpose and implementation.

The Basic useFetch Hook

The foundation of our system is a simple useFetch hook that abstracts away the details of making API requests. This hook uses react-query to manage caching, refetching, and error handling.

The foundation of our system is a useFetch hook that wraps React Query:

typescript

"use client"; import { env } from "@/env"; import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import axios from "axios"; interface IUseFetch<T> { path: string; queryKey: readonly unknown[]; // Accept any readonly array for compatibility config?: Omit<UseQueryOptions<T, Error>, "queryKey" | "queryFn">; } export function useFetch<T>({ path, queryKey, ...config }: IUseFetch<T>) { const auth = { token: "" }; if (!queryKey) throw new Error("queryKey is required"); if (!path) throw new Error("path is required"); const REQUEST_URL = env.NEXT_PUBLIC_APP_URL + path; const fetchData = async (): Promise<T> => { try { const response = await axios.get(REQUEST_URL, { headers: auth?.token ? { Authorization: `Bearer ${auth?.token}`, } : {}, }); return response.data.data; } catch (error: any) { if (!error.response) { throw new Error( "Network error, please check your internet connection." ); } if (error.response.status === 401) { console.log("Unauthorized access, logging out..."); // Add your logout function here } throw error; } }; const query = useQuery<T, Error>({ queryKey, queryFn: fetchData, refetchOnWindowFocus: false, ...config, }); return query; }

Core Type Definitions

The foundation of our system is a set of well-defined types that describe our API queries:

typescript

// Base type for search parameters export type SearchParams = Record<string, string | number | boolean | undefined | null>; // Static query type with proper generics export type StaticQuery< TData = unknown, TParams = undefined, TSearchParams = SearchParams > = { queryKey: readonly unknown[]; path: string; requiresParams?: boolean; searchParams?: TSearchParams; _data?: TData; // Used for type inference only _params?: TParams; _searchParams?: TSearchParams; };

This defines a StaticQuery type with generic parameters for:

  • TData: The expected response type
  • TParams: Parameters needed in the path (like IDs)
  • TSearchParams: Query parameters

The properties with underscores (_data, _params, _searchParams) are "phantom types" that don't get used at runtime but help TypeScript infer the correct types.

For dynamic paths that require computation, we have:

typescript

// Dynamic query type with proper generics export type DynamicQuery< TData = unknown, TParams = undefined, TSearchParams = SearchParams > = { queryKey: (params: TParams, searchParams?: TSearchParams) => readonly unknown[]; path: (params: TParams, searchParams?: TSearchParams) => string; requiresParams?: boolean; searchParams?: TSearchParams | ((params: TParams) => TSearchParams); _data?: TData; _params?: TParams; _searchParams?: TSearchParams; };

We combine these into a union type:

typescript

// Query definition type combining static and dynamic export type QueryDefinition< TData = unknown, TParams = undefined, TSearchParams = SearchParams > = StaticQuery<TData, TParams, TSearchParams> | DynamicQuery<TData, TParams, TSearchParams>;

Type Inference Utilities

To extract the types from our query definitions, we create helper types:

typescript

// Utility types for type inference export type InferQueryData<T> = T extends { _data?: infer D } ? D : never; export type InferQueryParams<T> = T extends { _params?: infer P } ? P : undefined; export type InferSearchParams<T> = T extends { _searchParams?: infer S } ? S : SearchParams;

These types use conditional type inference to extract the data type, parameters type, and search parameters type from a query definition.

Query Definition Helper

To make it easier to define queries with proper typing, we create a helper function:

typescript

// Define query function with proper typings export const defineQuery = < TData = unknown, TParams = undefined, TSearchParams = SearchParams >( def: QueryDefinition<TData, TParams, TSearchParams> ): QueryDefinition<TData, TParams, TSearchParams> => def;

This function doesn't actually do anything at runtime - it's a passthrough that helps TypeScript infer correct types when defining queries.

URL Parameter Handling

For handling search parameters properly, we implement a utility:

typescript

// Helper function to build URL with search params function buildUrlWithParams(baseUrl: string, searchParams?: SearchParams): string { if (!searchParams) return baseUrl; const params = new URLSearchParams(); Object.entries(searchParams).forEach(([key, value]) => { if (value !== undefined && value !== null) { params.append(key, String(value)); } }); return params.toString() ? `${baseUrl}?${params}` : baseUrl; }

This function takes a base URL and search parameters, then constructs a proper URL with query parameters.

Creating the Typed Fetch Hook

The core of our system is a factory function that creates a type-safe hook:

typescript

// Create typed fetch hook with proper generic constraints export function createTypedFetchHook< T extends Record<string, QueryDefinition<any, any, any>>[] >(...keySets: T) { type Merged = UnionToIntersection<T[number]>; const merged = Object.assign({}, ...keySets) as Merged; // Config interface for useTypedFetch interface UseTypedFetchConfig<K extends keyof Merged & string> { params?: InferQueryParams<Merged[K]>; searchParams?: InferSearchParams<Merged[K]>; options?: Partial<UseQueryOptions<InferQueryData<Merged[K]>, Error>>; } // Return a strongly typed hook function return function useTypedFetch<K extends keyof Merged & string>( key: K, config?: UseTypedFetchConfig<K> ) { // Get the response type from the query definition type ResponseType = InferQueryData<Merged[K]>; const def = merged[key] as QueryDefinition<ResponseType, any, any>; const params = config?.params; const searchParams = config?.searchParams; const options = config?.options; if (def.requiresParams && !params) { console.error(`Query "${String(key)}" requires params but none were provided`); } // Determine queryKey and path based on static or dynamic definition let queryKey: readonly unknown[]; let basePath: string; if (typeof def.queryKey === 'function') { // Dynamic queryKey queryKey = def.queryKey(params, searchParams); basePath = def.path(params, searchParams); } else { // Static queryKey with params and searchParams added for cache differentiation const baseKey = [...def.queryKey]; // Include params in the query key if provided if (params) { baseKey.push(params); } // Include search params in the query key if provided if (searchParams && Object.keys(searchParams).length > 0) { baseKey.push(Object.keys(searchParams).sort().join(',')); baseKey.push(Object.values(searchParams) .filter(v => v !== undefined && v !== null) .map(String) .sort() .join(',')); } queryKey = baseKey; basePath = def.path; } // Add search parameters to URL const path = buildUrlWithParams(basePath, searchParams as SearchParams | undefined); // Return with explicit type annotation return useFetch<ResponseType>({ queryKey, path, ...options, }); }; }

This function:

  1. Takes any number of query key sets and merges them
  2. Creates a strongly-typed hook function that enforces correct parameters
  3. Handles both static and dynamic query definitions
  4. Constructs proper React Query keys for caching
  5. Builds URLs with proper path and query parameters
  6. Returns a properly typed query result

Invalidation Hook Creation

To complement our fetch hook, we also create an invalidation hook:

typescript

// Create typed invalidation hook export function createTypedInvalidationHook< T extends Record<string, QueryDefinition<any, any, any>>[] >(...keySets: T) { type Merged = UnionToIntersection<T[number]>; const merged = Object.assign({}, ...keySets) as Merged; return function useTypedInvalidation() { const queryClient = useQueryClient(); const invalidateQuery = <K extends keyof Merged & string>( key: K, params?: InferQueryParams<Merged[K]>, searchParams?: InferSearchParams<Merged[K]> ) => { const def = merged[key] as QueryDefinition<any, any, any>; const queryKey = typeof def.queryKey === 'function' ? def.queryKey(params as any, searchParams) : def.queryKey; return queryClient.invalidateQueries({ queryKey }); }; return { invalidateQuery }; }; }

This hook allows us to invalidate queries by their key name with proper type checking for parameters.

The Query Status Component: Handling All Query States

One of the most powerful parts of our system is the QueryStatus component, which provides a declarative way to handle all possible states of a query:

tsx

type QueryStatusProps<T> = | { query: UseQueryResult<T>; onWithLoadingState: (data: T, isLoading: boolean) => JSX.Element; onError?: | JSX.Element | (( error: unknown, refetch: () => void, isLoading: boolean ) => JSX.Element); // These props are disallowed in this branch onLoading?: never; onEmpty?: never; onSuccess?: never; } | { query: UseQueryResult<T>; onLoading?: | JSX.Element | ((className?: string, loaderClassName?: string) => JSX.Element); onError?: | JSX.Element | (( error: unknown, refetch: () => void, isLoading: boolean ) => JSX.Element); onEmpty?: JSX.Element; onSuccess: (data: NonNullable<T>) => JSX.Element; // Disallow onWithLoadingState in this branch onWithLoadingState?: never; };

This type definition creates a discriminated union with two usage patterns:

  1. Unified Mode with onWithLoadingState: Provides a single callback that receives both data and loading state
  2. Separate Handlers Mode: Provides separate renderers for loading, error, empty, and success states

Let's break down the QueryStatus component implementation:

tsx

function QueryStatus<T>(props: QueryStatusProps<T>): JSX.Element { const { query } = props; // Handle the unified mode if (hasOnWithLoadingState(props)) { if (query.isError && props.onError) { return typeof props.onError === "function" ? props.onError(query.failureReason, query.refetch, query.isLoading) : props.onError; } return props.onWithLoadingState(query.data as T, query.isLoading); } // Handle the separate mode with destructuring and defaults const { onLoading = (c, l) => <Loader className={c} loaderClassName={l} />, onError = (error, refetch, isLoading) => ( <ErrorCard error={error} isLoading={isLoading} onRetry={refetch} /> ), onEmpty = <EmptyCard />, onSuccess, } = props; // Loading state if (query.isLoading) { return typeof onLoading === "function" ? onLoading() : onLoading; } // Error state if (query.isError) { return typeof onError === "function" ? onError(query.failureReason, query.refetch, query.isLoading) : onError; } // Empty data state const isEmptyData = (data: unknown): boolean => data === undefined || data === null || (Array.isArray(data) && data.length === 0); if (isEmptyData(query.data) && onEmpty) return onEmpty; // Success state with data return onSuccess(query.data as NonNullable<T>); }

This component:

  1. First checks which mode is being used (unified or separate handlers)
  2. For unified mode, it returns onWithLoadingState callback result or error component
  3. For separate handlers mode, it:
    • Shows loading state when query.isLoading is true
    • Shows error state when query.isError is true
    • Shows empty state when data is empty (undefined, null, or empty array)
    • Shows success state with non-null data otherwise

The QueryStatus component also handles function or element props elegantly:

tsx

// For loading state return typeof onLoading === "function" ? onLoading() : onLoading; // For error state return typeof onError === "function" ? onError(query.failureReason, query.refetch, query.isLoading) : onError;

This allows consumers to provide either React elements directly or functions that return elements (with proper parameters).

The default handlers provide sensible defaults:

  1. Loading: A spinner component with customizable class names
  2. Error: An ErrorCard that shows error details and a retry button
  3. Empty: An EmptyCard that indicates no data is available

Using the System in Practice

Here's how to define API endpoints with our system:

typescript

import { defineQuery } from "@/lib/query/query.utils"; import { Post } from "@/types"; interface FirstResponseType { haseeb: string; } export const SAMPLE_KEYS = { first: defineQuery<FirstResponseType[], {string: string}, {searchParams: string}>({ queryKey: ["first"], path: "/api/first", requiresParams: true, _data: [] as FirstResponseType[] // Helps with type inference }), all: defineQuery<Post[], {id: number}>({ queryKey: ["all"], path: "posts", requiresParams: true, _data: [] as Post[] // Helps with type inference }) }

Create the typed hooks:

typescript

import { createTypedFetchHook, createTypedInvalidationHook } from "@/lib/query/query.utils"; import { SAMPLE_KEYS } from "@/lib/query/sampe.keys"; const QUERY_KEYS = { ...SAMPLE_KEYS, }; export const useTypedFetch = createTypedFetchHook(QUERY_KEYS); export const useTypedInvalidation = createTypedInvalidationHook(QUERY_KEYS); export { QUERY_KEYS };

And use them in components:

tsx

"use client"; import { Typography } from "@/components/ui/typography"; import { useTypedFetch, useTypedInvalidation } from "@/lib/query"; import QueryStatus from "@/components/match-query-status"; export default function Home() { // TypeScript knows exactly what parameters are required const postsQuery = useTypedFetch("all", { params: { id: 1 }, searchParams: { searchParams: "test" } }); const { invalidateQuery } = useTypedInvalidation(); return ( <div className="grid w-full grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]"> <Typography as="h1">Hello</Typography> {/* Using QueryStatus to handle all states */} <QueryStatus query={postsQuery} onSuccess={(data) => ( <div> {/* TypeScript knows that data is Post[] */} {data.map(post => ( <div key={post.id}>{post.title}</div> ))} </div> )} /> <button onClick={() => { invalidateQuery("all", { id: 1 }); }} > Refresh Data </button> </div> ); }

Complete Code Reference

Here's the complete implementation of our query utilities:

typescript

import { useQueryClient } from '@tanstack/react-query'; import type { UseQueryOptions } from '@tanstack/react-query'; import { useFetch } from '@/hooks/use-fetch'; // Base type for search parameters export type SearchParams = Record<string, string | number | boolean | undefined | null>; // Static query type with proper generics export type StaticQuery< TData = unknown, TParams = undefined, TSearchParams = SearchParams > = { queryKey: readonly unknown[]; path: string; requiresParams?: boolean; searchParams?: TSearchParams; _data?: TData; _params?: TParams; _searchParams?: TSearchParams; }; // Dynamic query type with proper generics export type DynamicQuery< TData = unknown, TParams = undefined, TSearchParams = SearchParams > = { queryKey: (params: TParams, searchParams?: TSearchParams) => readonly unknown[]; path: (params: TParams, searchParams?: TSearchParams) => string; requiresParams?: boolean; searchParams?: TSearchParams | ((params: TParams) => TSearchParams); _data?: TData; _params?: TParams; _searchParams?: TSearchParams; }; // Query definition type combining static and dynamic export type QueryDefinition< TData = unknown, TParams = undefined, TSearchParams = SearchParams > = StaticQuery<TData, TParams, TSearchParams> | DynamicQuery<TData, TParams, TSearchParams>; // Utility types for type inference export type InferQueryData<T> = T extends { _data?: infer D } ? D : never; export type InferQueryParams<T> = T extends { _params?: infer P } ? P : undefined; export type InferSearchParams<T> = T extends { _searchParams?: infer S } ? S : SearchParams; // Helper type for merging unions type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never; // Define query function with proper typings export const defineQuery = < TData = unknown, TParams = undefined, TSearchParams = SearchParams >( def: QueryDefinition<TData, TParams, TSearchParams> ): QueryDefinition<TData, TParams, TSearchParams> => def; // Helper function to build URL with search params function buildUrlWithParams(baseUrl: string, searchParams?: SearchParams): string { if (!searchParams) return baseUrl; const params = new URLSearchParams(); Object.entries(searchParams).forEach(([key, value]) => { if (value !== undefined && value !== null) { params.append(key, String(value)); } }); return params.toString() ? `${baseUrl}?${params}` : baseUrl; } // Create typed fetch hook with proper generic constraints export function createTypedFetchHook< T extends Record<string, QueryDefinition<any, any, any>>[] >(...keySets: T) { type Merged = UnionToIntersection<T[number]>; const merged = Object.assign({}, ...keySets) as Merged; // Config interface for useTypedFetch interface UseTypedFetchConfig<K extends keyof Merged & string> { params?: InferQueryParams<Merged[K]>; searchParams?: InferSearchParams<Merged[K]>; options?: Partial<UseQueryOptions<InferQueryData<Merged[K]>, Error>>; } // Return a strongly typed hook function return function useTypedFetch<K extends keyof Merged & string>( key: K, config?: UseTypedFetchConfig<K> ) { // Get the response type from the query definition type ResponseType = InferQueryData<Merged[K]>; const def = merged[key] as QueryDefinition<ResponseType, any, any>; const params = config?.params; const searchParams = config?.searchParams; const options = config?.options; if (def.requiresParams && !params) { console.error(`Query "${String(key)}" requires params but none were provided`); } // Determine queryKey based on static or dynamic definition let queryKey: readonly unknown[]; if (typeof def.queryKey === 'function') { // Dynamic queryKey queryKey = def.queryKey(params, searchParams); } else { // Static queryKey with params and searchParams added for cache differentiation const baseKey = [...def.queryKey]; // Include params in the query key if provided if (params) { baseKey.push(params); } // Include search params keys in the query key if provided if (searchParams && Object.keys(searchParams).length > 0) { baseKey.push(Object.keys(searchParams).sort().join(',')); // Include search param values for cache differentiation baseKey.push(Object.values(searchParams) .filter(v => v !== undefined && v !== null) .map(String) .sort() .join(',')); } queryKey = baseKey; } // Determine basePath based on static or dynamic definition let basePath: string; if (typeof def.path === 'function') { // Dynamic path basePath = def.path(params, searchParams); } else { // Static path basePath = def.path; } // Add search parameters to URL const path = buildUrlWithParams(basePath, searchParams as SearchParams | undefined); // Return with explicit type annotation return useFetch<ResponseType>({ queryKey, path, ...options, }); }; } // Create typed invalidation hook export function createTypedInvalidationHook< T extends Record<string, QueryDefinition<any, any, any>>[] >(...keySets: T) { type Merged = UnionToIntersection<T[number]>; const merged = Object.assign({}, ...keySets) as Merged; return function useTypedInvalidation() { const queryClient = useQueryClient(); const invalidateQuery = <K extends keyof Merged & string>( key: K, params?: InferQueryParams<Merged[K]>, searchParams?: InferSearchParams<Merged[K]> ) => { const def = merged[key] as QueryDefinition<any, any, any>; const queryKey = typeof def.queryKey === 'function' ? def.queryKey(params as any, searchParams) : def.queryKey; return queryClient.invalidateQueries({ queryKey }); }; return { invalidateQuery }; }; } // Extract query keys for reuse export function extractQueryKeys< T extends Record<string, QueryDefinition<any, any, any>>[] >(...keySets: T) { type Merged = UnionToIntersection<T[number]>; const merged = Object.assign({}, ...keySets) as Merged; const result: Record<string, any> = {}; for (const key in merged) { const def = merged[key] as QueryDefinition<any, any, any>; result[key] = typeof def.queryKey === 'function' ? (params: any, searchParams?: any) => (def.queryKey as Function)(params, searchParams) : def.queryKey; } return result as { [K in keyof Merged]: Merged[K] extends DynamicQuery<any, infer P, infer S> ? (params: P, searchParams?: S) => readonly unknown[] : readonly unknown[]; }; }

And here's the complete QueryStatus component:

tsx

import React, { JSX } from "react"; import ErrorCard from "./error-card"; import { type UseQueryResult } from "@tanstack/react-query"; import { cn } from "../lib/utils"; import { Loader2 } from "lucide-react"; import EmptyCard from "./empty-card"; /** * Loader component displays a spinning loader. * * @param {object} props - Component props. * @param {string} [props.className] - Optional additional classes for the container. * @param {string} [props.loaderClassName] - Optional additional classes for the loader icon. * @returns {JSX.Element} Loader element. */ function Loader({ className, loaderClassName, }: { className?: string; loaderClassName?: string; }) { return ( <div className={cn("flex h-10 w-full items-center justify-center", className)} > <Loader2 className={cn("size-5 animate-spin text-primary", loaderClassName)} /> </div> ); } /** * Props for the QueryStatus component. * * This component supports two modes: * * 1. **Unified Mode (onWithLoadingState)** * - Use this mode when you want a single callback to handle both loading and success states. * * @property {UseQueryResult<T>} query - The query result from react-query. * @property {(data: T, isLoading: boolean) => JSX.Element} onWithLoadingState - Callback invoked with query data and loading state. * @property {JSX.Element | ((error: unknown, refetch: () => void, isLoading: boolean) => JSX.Element)} [onError] - Optional custom error renderer. * * 2. **Separate Handlers Mode** * - Use separate renderers for each state: loading, error, empty, and success. * * @property {UseQueryResult<T>} query - The query result from react-query. * @property {JSX.Element | ((className?: string, loaderClassName?: string) => JSX.Element)} [onLoading] - Optional custom loading renderer. Defaults to a Loader. * @property {JSX.Element | ((error: unknown, refetch: () => void, isLoading: boolean) => JSX.Element)} [onError] - Optional custom error renderer. Defaults to an ErrorCard. * @property {JSX.Element} [onEmpty] - Optional renderer when the query returns empty data. Defaults to an EmptyCard. * @property {(data: NonNullable<T>) => JSX.Element} onSuccess - Callback invoked when the query successfully returns non-empty data. * * Note: You cannot mix modes; if `onWithLoadingState` is provided, the separate handlers must not be provided. * * @template T - The type of data returned by the query. */ type QueryStatusProps<T> = | { query: UseQueryResult<T>; onWithLoadingState: (data: T, isLoading: boolean) => JSX.Element; onError?: | JSX.Element | (( error: unknown, refetch: () => void, isLoading: boolean ) => JSX.Element); // These props are disallowed in this branch onLoading?: never; onEmpty?: never; onSuccess?: never; } | { query: UseQueryResult<T>; onLoading?: | JSX.Element | ((className?: string, loaderClassName?: string) => JSX.Element); onError?: | JSX.Element | (( error: unknown, refetch: () => void, isLoading: boolean ) => JSX.Element); onEmpty?: JSX.Element; onSuccess: (data: NonNullable<T>) => JSX.Element; // Disallow onWithLoadingState in this branch onWithLoadingState?: never; }; /** * Type guard to determine if the unified mode (onWithLoadingState) is used. * * @template T * @param {QueryStatusProps<T>} props - The props to check. * @returns {boolean} True if unified mode is used. */ function hasOnWithLoadingState<T>(props: QueryStatusProps<T>): props is { query: UseQueryResult<T>; onWithLoadingState: (data: T, isLoading: boolean) => JSX.Element; onError?: | JSX.Element | (( error: unknown, refetch: () => void, isLoading: boolean ) => JSX.Element); } { return ( "onWithLoadingState" in props && typeof props.onWithLoadingState === "function" ); } /** * QueryStatus component renders different UI elements based on the query state. * * It supports two usage modes: * * 1. **Unified Mode:** * Provide `onWithLoadingState` which receives both the query data and loading state. * * 2. **Separate Handlers Mode:** * Provide individual renderers: * - `onLoading`: Renders while loading. * - `onError`: Renders if an error occurs. * - `onEmpty`: Renders if the query data is empty. * - `onSuccess`: Renders when the query returns valid data. * * @template T - The type of data returned by the query. * @param {QueryStatusProps<T>} props - The props for handling various query states. * @returns {JSX.Element} The rendered UI element based on the query state. */ function QueryStatus<T>(props: QueryStatusProps<T>): JSX.Element { const { query } = props; if (hasOnWithLoadingState(props)) { if (query.isError && props.onError) { return typeof props.onError === "function" ? props.onError(query.failureReason, query.refetch, query.isLoading) : props.onError; } return props.onWithLoadingState(query.data as T, query.isLoading); } const { onLoading = (c, l) => <Loader className={c} loaderClassName={l} />, onError = (error, refetch, isLoading) => ( <ErrorCard error={error} isLoading={isLoading} onRetry={refetch} /> ), onEmpty = <EmptyCard />, onSuccess, } = props; if (query.isLoading) {return typeof onLoading === "function" ? onLoading() : onLoading;} if (query.isError) { return typeof onError === "function" ? onError(query.failureReason, query.refetch, query.isLoading) : onError; } const isEmptyData = (data: unknown): boolean => data === undefined || data === null || (Array.isArray(data) && data.length === 0); if (isEmptyData(query.data) && onEmpty) return onEmpty; return onSuccess(query.data as NonNullable<T>); } export default QueryStatus;

Real-World Usage Example

Here's how you'd use the QueryStatus component with both usage modes:

Unified Mode:

tsx

function PostList() { const postsQuery = useTypedFetch("all", { params: { id: 1 } }); return ( <QueryStatus query={postsQuery} onWithLoadingState={(data, isLoading) => ( <div className={isLoading ? "opacity-50" : ""}> {data?.map(post => ( <div key={post.id}> <h2>{post.title}</h2> <p>{post.content}</p> </div> ))} {isLoading && <Loader className="absolute inset-0" />} </div> )} /> ); }

Separate Handlers Mode:

tsx

function PostList() { const postsQuery = useTypedFetch("all", { params: { id: 1 } }); return ( <QueryStatus query={postsQuery} onLoading={() => <Loader className="p-8" />} onError={(error) => ( <div className="p-4 bg-red-50 text-red-700"> Error: {error.message} </div> )} onEmpty={<div className="p-4">No posts found</div>} onSuccess={(data) => ( <div className="grid gap-4"> {data.map(post => ( <div key={post.id} className="p-4 border rounded"> <h2 className="font-bold">{post.title}</h2> <p>{post.content}</p> </div> ))} </div> )} /> ); }

Conclusion

The type-safe query system we've built provides several major benefits:

  1. Complete Type Safety: TypeScript knows about your API shapes and parameters at every level
  2. Declarative Query Status Handling: The QueryStatus component makes handling all query states clean and consistent
  3. Centralized Query Definitions: Define your API endpoints once, use them everywhere with proper typing
  4. Developer Experience: Autocomplete and type checking for query keys, parameters, and response data
  5. Consistent Error Handling: Built-in handling for network issues, unauthorized access, and other common problems

This approach shines especially in larger applications, where maintaining consistency across dozens of API endpoints becomes critical for maintainability and developer productivity.

By investing in this infrastructure up front, you'll save countless hours of debugging type errors, handling edge cases, and maintaining consistency throughout your application.