10.5 제네릭 컴포넌트와 Context 타입 안전성
제네릭 컴포넌트
타입에 따라 다르게 동작하는 재사용 가능한 컴포넌트를 만들 때 제네릭을 사용합니다.
// 제네릭 Select 컴포넌트
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 = '선택하세요',
}: 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>
);
}
// 사용 — 타입이 자동 추론됨
interface Country {
code: string;
name: string;
}
<Select<Country>
options={[{ code: 'KR', name: '한국' }, { code: 'US', name: '미국' }]}
value={selectedCountry}
onChange={setSelectedCountry}
getLabel={c => c.name}
getValue={c => c.code}
/>
타입 안전한 Context
기본 패턴: null 체크
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 초기값
const AuthContext = createContext<AuthContextValue | null>(null);
// 안전한 커스텀 훅 — 반드시 Provider 안에서 사용 강제
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (context === null) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
// Provider 컴포넌트
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>
);
}
제네릭 Context 팩토리
재사용 가능한 Context 생성 패턴입니다.
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;
}
// 사용
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>
);
}
// 컴포넌트에서 사용
function ThemeButton() {
const { theme, toggleTheme } = useTheme(); // ✅ 타입 안전
return <button onClick={toggleTheme}>{theme} 모드</button>;
}
커스텀 훅 타입 설계
기본 커스텀 훅
// API 데이터 페칭 훅
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 };
}
// 사용
interface Post {
id: number;
title: string;
body: string;
}
function PostList() {
const { data: posts, isLoading, error } = useFetch<Post[]>('/api/posts');
if (isLoading) return <div>로딩 중...</div>;
if (error) return <div>오류: {error.message}</div>;
if (!posts) return null;
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
TanStack Query (React Query) 타입 통합
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// API 함수 타입 정의
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();
}
// 쿼리 훅
function useUser(id: string) {
return useQuery<User, Error>({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
enabled: !!id,
staleTime: 5 * 60 * 1000, // 5분
});
}
// 뮤테이션 훅
function useCreateUser() {
const queryClient = useQueryClient();
return useMutation<User, Error, CreateUserInput>({
mutationFn: createUser,
onSuccess: (newUser) => {
// 캐시 업데이트
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.setQueryData(['user', newUser.id], newUser);
},
onError: (error) => {
console.error('사용자 생성 실패:', error.message);
},
});
}
// 컴포넌트에서 사용
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useUser(userId);
const { mutate: createUser, isPending } = useCreateUser();
if (isLoading) return <div>로딩 중...</div>;
if (error) return <div>오류: {error.message}</div>;
if (!user) return null;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
Render Props 패턴
interface RenderPropsPattern<T> {
data: T;
render: (data: T) => React.ReactNode;
renderEmpty?: () => React.ReactNode;
renderLoading?: () => React.ReactNode;
}
function DataRenderer<T>({
data,
render,
renderEmpty = () => <div>데이터 없음</div>,
}: RenderPropsPattern<T> & { data: T | null }) {
if (!data) return <>{renderEmpty()}</>;
return <>{render(data)}</>;
}
// 사용
<DataRenderer
data={user}
render={(u) => <UserCard user={u} />}
renderEmpty={() => <p>사용자를 찾을 수 없습니다</p>}
/>
고수 팁
1. ComponentType<P> — 컴포넌트를 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="내 앱" />
<div className="content">
{Sidebar && <Sidebar />}
<main>{children}</main>
</div>
</div>
);
}
2. HOC (Higher Order Component) 타입
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);