A production-ready DataFetcher<T> component implementing the render props pattern in TypeScript React, with full type safety for loading, error, and success states.
import { useState, useEffect, useCallback, ReactNode } from 'react';
// State types for different fetch phases
type LoadingState = {
status: 'loading';
data: null;
error: null;
};
type ErrorState = {
status: 'error';
data: null;
error: Error;
};
type SuccessState<T> = {
status: 'success';
data: T;
error: null;
};
type IdleState = {
status: 'idle';
data: null;
error: null;
};
// Union type for all possible states
type FetchState<T> = IdleState | LoadingState | ErrorState | SuccessState<T>;
// Render props payload with refetch capability
interface RenderProps<T> extends FetchState<T> {
refetch: () => void;
isLoading: boolean;
isError: boolean;
isSuccess: boolean;
}
// Component props
interface DataFetcherProps<T> {
url: string;
options?: RequestInit;
children: (props: RenderProps<T>) => ReactNode;
initialData?: T;
enabled?: boolean;
onSuccess?: (data: T) => void;
onError?: (error: Error) => void;
}
// The DataFetcher component using render props pattern
function DataFetcher<T>({
url,
options,
children,
initialData,
enabled = true,
onSuccess,
onError,
}: DataFetcherProps<T>): ReactNode {
const [state, setState] = useState<FetchState<T>>(() => {
if (initialData !== undefined) {
return { status: 'success', data: initialData, error: null };
}
return { status: 'idle', data: null, error: null };
});
const fetchData = useCallback(async () => {
setState({ status: 'loading', data: null, error: null });
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data: T = await response.json();
setState({ status: 'success', data, error: null });
onSuccess?.(data);
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
setState({ status: 'error', data: null, error });
onError?.(error);
}
}, [url, options, onSuccess, onError]);
useEffect(() => {
if (enabled) {
fetchData();
}
}, [enabled, fetchData]);
const renderProps: RenderProps<T> = {
...state,
refetch: fetchData,
isLoading: state.status === 'loading',
isError: state.status === 'error',
isSuccess: state.status === 'success',
};
return children(renderProps);
}
// Example usage with typed data
interface User {
id: number;
name: string;
email: string;
}
interface Post {
id: number;
title: string;
body: string;
userId: number;
}
// User List Component using DataFetcher
function UserList(): ReactNode {
return (
<DataFetcher<User[]>
url="https://jsonplaceholder.typicode.com/users"
onSuccess={(users) => console.log(`Fetched ${users.length} users`)}
onError={(error) => console.error('Failed to fetch users:', error)}
>
{({ data, isLoading, isError, error, refetch }) => {
if (isLoading) {
return <div className="loading">Loading users...</div>;
}
if (isError) {
return (
<div className="error">
<p>Error: {error.message}</p>
<button onClick={refetch}>Retry</button>
</div>
);
}
if (!data) {
return null;
}
return (
<div>
<button onClick={refetch}>Refresh</button>
<ul>
{data.map((user) => (
<li key={user.id}>
{user.name} - {user.email}
</li>
))}
</ul>
</div>
);
}}
</DataFetcher>
);
}
// Nested DataFetchers example - fetch user then their posts
function UserWithPosts({ userId }: { userId: number }): ReactNode {
return (
<DataFetcher<User>
url={`https://jsonplaceholder.typicode.com/users/${userId}`}
>
{({ data: user, isLoading: userLoading, isError: userError, error: userErr }) => {
if (userLoading) return <div>Loading user...</div>;
if (userError) return <div>User error: {userErr.message}</div>;
if (!user) return null;
return (
<div>
<h2>{user.name}</h2>
<DataFetcher<Post[]>
url={`https://jsonplaceholder.typicode.com/posts?userId=${userId}`}
>
{({ data: posts, isLoading, isError, error }) => {
if (isLoading) return <div>Loading posts...</div>;
if (isError) return <div>Posts error: {error.message}</div>;
if (!posts) return null;
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}}
</DataFetcher>
</div>
);
}}
</DataFetcher>
);
}
// Conditional fetching example
function ConditionalFetch({ shouldFetch }: { shouldFetch: boolean }): ReactNode {
return (
<DataFetcher<User[]>
url="https://jsonplaceholder.typicode.com/users"
enabled={shouldFetch}
>
{({ data, status, refetch }) => (
<div>
<p>Status: {status}</p>
{data && <p>Loaded {data.length} users</p>}
<button onClick={refetch}>Manual Fetch</button>
</div>
)}
</DataFetcher>
);
}
export { DataFetcher, UserList, UserWithPosts, ConditionalFetch };
export type { FetchState, RenderProps, DataFetcherProps };The render props pattern is a technique for sharing code between React components using a prop whose value is a function. This DataFetcher
The type system is designed around discriminated unions. The FetchStateisError, TypeScript knows that error is an Error, not null. This pattern eliminates runtime type errors and provides excellent autocomplete in IDEs. The RenderProps
The component manages its internal state using useState with a lazy initializer that handles the initialData prop. The fetchData function is memoized with useCallback to prevent unnecessary re-renders and is triggered by useEffect when enabled changes. The enabled prop allows for conditional fetching, useful for dependent queries or user-triggered fetches. Error handling normalizes unknown errors into Error instances for consistent typing.
The examples demonstrate three common patterns: basic usage with UserList showing loading, error, and success states; nested DataFetchers in UserWithPosts showing how to compose data dependencies; and ConditionalFetch demonstrating deferred or manual fetching. Each example maintains full type safety - the User and Post interfaces flow through the generic, ensuring data access is type-checked.
This pattern shines when you need maximum flexibility in rendering. Unlike hooks, render props allow the parent to control the entire render tree based on fetch state. However, deeply nested render props can lead to "callback hell" - consider extracting child components or switching to hooks for simpler cases. Use this pattern when building reusable data-fetching primitives, implementing complex loading states, or when you need to share fetch logic across components with vastly different UIs.