Skip to main content
Advertisement

10.5 Generic Components and Type-Safe Context

Generic Components

Use generics to create reusable components that behave differently based on types.

// Generic Select component
interface SelectProps<T> {
options: T[];
value: T | null;
onChange: (value: T) => void;
getLabel: (option: T) => string;
getValue: (option: T) => string;
placeholder?: string;
}

function Select<T>({
options,
value,
onChange,
getLabel,
getValue,
placeholder = 'Select...',
}: SelectProps<T>) {
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const selected = options.find(opt => getValue(opt) === e.target.value);
if (selected !== undefined) onChange(selected);
};

return (
<select
value={value ? getValue(value) : ''}
onChange={handleChange}
>
<option value="">{placeholder}</option>
{options.map(option => (
<option key={getValue(option)} value={getValue(option)}>
{getLabel(option)}
</option>
))}
</select>
);
}

// Usage — types are automatically inferred
interface Country {
code: string;
name: string;
}

<Select<Country>
options={[{ code: 'US', name: 'United States' }, { code: 'KR', name: 'Korea' }]}
value={selectedCountry}
onChange={setSelectedCountry}
getLabel={c => c.name}
getValue={c => c.code}
/>

Type-Safe Context

Basic Pattern: Null Check

import { createContext, useContext, useState, ReactNode } from 'react';

interface AuthUser {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
}

interface AuthContextValue {
user: AuthUser | null;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}

// Null initial value
const AuthContext = createContext<AuthContextValue | null>(null);

// Safe custom hook — forces usage within Provider
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (context === null) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}

// Provider component
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [isLoading, setIsLoading] = useState(false);

const login = async (email: string, password: string) => {
setIsLoading(true);
try {
const userData = await authService.login(email, password);
setUser(userData);
} finally {
setIsLoading(false);
}
};

const logout = () => {
setUser(null);
authService.logout();
};

return (
<AuthContext.Provider value={{ user, isLoading, login, logout }}>
{children}
</AuthContext.Provider>
);
}

Generic Context Factory

A reusable context creation pattern.

function createSafeContext<T>(name: string) {
const Context = createContext<T | undefined>(undefined);

function useContext(): T {
const value = React.useContext(Context);
if (value === undefined) {
throw new Error(`use${name} must be used within ${name}Provider`);
}
return value;
}

return [Context.Provider, useContext] as const;
}

// Usage
interface ThemeContextValue {
theme: 'light' | 'dark';
toggleTheme: () => void;
}

const [ThemeProvider, useTheme] = createSafeContext<ThemeContextValue>('Theme');

function ThemeProviderComponent({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const toggleTheme = () => setTheme(t => t === 'light' ? 'dark' : 'light');

return (
<ThemeProvider value={{ theme, toggleTheme }}>
{children}
</ThemeProvider>
);
}

// Usage in components
function ThemeButton() {
const { theme, toggleTheme } = useTheme(); // ✅ Type-safe
return <button onClick={toggleTheme}>{theme} mode</button>;
}

Custom Hook Type Design

Basic Custom Hook

// API data fetching hook
interface UseFetchResult<T> {
data: T | null;
isLoading: boolean;
error: Error | null;
refetch: () => void;
}

function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);

const fetchData = async () => {
try {
setIsLoading(true);
setError(null);
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const json: T = await response.json();
setData(json);
} catch (e) {
setError(e instanceof Error ? e : new Error(String(e)));
} finally {
setIsLoading(false);
}
};

useEffect(() => { fetchData(); }, [url]);

return { data, isLoading, error, refetch: fetchData };
}

// Usage
interface Post {
id: number;
title: string;
body: string;
}

function PostList() {
const { data: posts, isLoading, error } = useFetch<Post[]>('/api/posts');

if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!posts) return null;

return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}

TanStack Query (React Query) Type Integration

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

// API function type definitions
interface User {
id: string;
name: string;
email: string;
}

interface CreateUserInput {
name: string;
email: string;
}

async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error('Failed to fetch user');
return res.json();
}

async function createUser(input: CreateUserInput): Promise<User> {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error('Failed to create user');
return res.json();
}

// Query hook
function useUser(id: string) {
return useQuery<User, Error>({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
enabled: !!id,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}

// Mutation hook
function useCreateUser() {
const queryClient = useQueryClient();

return useMutation<User, Error, CreateUserInput>({
mutationFn: createUser,
onSuccess: (newUser) => {
// Update cache
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.setQueryData(['user', newUser.id], newUser);
},
onError: (error) => {
console.error('Failed to create user:', error.message);
},
});
}

// Usage in component
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useUser(userId);
const { mutate: createUser, isPending } = useCreateUser();

if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!user) return null;

return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}

Render Props Pattern

interface RenderPropsPattern<T> {
data: T;
render: (data: T) => React.ReactNode;
renderEmpty?: () => React.ReactNode;
renderLoading?: () => React.ReactNode;
}

function DataRenderer<T>({
data,
render,
renderEmpty = () => <div>No data</div>,
}: RenderPropsPattern<T> & { data: T | null }) {
if (!data) return <>{renderEmpty()}</>;
return <>{render(data)}</>;
}

// Usage
<DataRenderer
data={user}
render={(u) => <UserCard user={u} />}
renderEmpty={() => <p>User not found</p>}
/>

Pro Tips

1. ComponentType<P> — Receiving Components as Props

import { ComponentType } from 'react';

interface LayoutProps {
Header: ComponentType<{ title: string }>;
Sidebar?: ComponentType;
children: React.ReactNode;
}

function Layout({ Header, Sidebar, children }: LayoutProps) {
return (
<div>
<Header title="My App" />
<div className="content">
{Sidebar && <Sidebar />}
<main>{children}</main>
</div>
</div>
);
}

2. HOC (Higher Order Component) Types

function withAuth<P extends object>(
WrappedComponent: ComponentType<P>
): ComponentType<P> {
return function AuthenticatedComponent(props: P) {
const { user } = useAuth();
if (!user) return <Navigate to="/login" />;
return <WrappedComponent {...props} />;
};
}

const ProtectedPage = withAuth(DashboardPage);
Advertisement