타입스크립트 + React 실전 #5 Context와 제네릭 컴포넌트
#4이벤트와 폼 타이핑 까지로 컴포넌트 안쪽의 가장 흔한 타이핑은 정리됐습니다. 이번 글은 한 단계 위 — **여러 컴포넌트가 공유하는 값(Context)**과 **여러 형태의 데이터를 받아내는 컴포넌트(제네릭)**를 다룹니다.
Context의 타입 인자 — 시작값과 사용 시점이 다르다 #
createContext는 시작값을 그대로 받아 그 타입으로 추론합니다. 문제는 시작값이 의미 있는 값이 아닐 때가 많다는 것입니다. “Provider 안에서만 의미가 있고, 바깥에서는 쓰면 안 되는 값"을 어떻게 표현합니까?
세 가지 흔한 패턴이 있고, 각자의 트레이드오프가 다릅니다.
1)의미 있는 기본값을 주는 방식 #
가장 단순합니다. Provider 없이도 동작하는 의미 있는 기본값을 시작값으로 주는 것입니다. 테마처럼 “기본은 light, 필요하면 Provider로 override” 같은 경우에 적합합니다.
import { createContext, useContext } from 'react';
type Theme = 'light' | 'dark';
const ThemeContext = createContext<Theme>('light');
function useTheme() {
return useContext(ThemeContext);
}
// 사용처
function Toolbar() {
const theme = useTheme(); // Theme — 항상 의미 있는 값
return <div className={theme}>...</div>;
}장점은 단순함. 단점은 “Provider를 까먹어도 동작은 함” 이라 실수가 늦게 잡힙니다.
2) null 시작 + 안전한 useContext 헬퍼
#
Provider 안에서만 의미 있는 값(예: 사용자 정보, 카트, dispatch)일 때는 시작값을 null로 두고, 사용처에서 한 번 검사하는 헬퍼를 만들어 둡니다. 이 패턴이 실무에서 가장 많이 쓰입니다.
type User = { id: string; name: string };
const UserContext = createContext<User | null>(null);
export function useUser() {
const user = useContext(UserContext);
if (user === null) {
throw new Error('useUser는 UserProvider 안에서만 호출하세요');
}
return user; // 여기서부터 User로 좁혀짐
}
// 사용처
function Profile() {
const user = useUser(); // User (null 분기 불필요)
return <p>{user.name}</p>;
}호출하는 컴포넌트에서 매번 if (user === null)을 적지 않아도 되도록 헬퍼가 한 번 막아 줍니다. 헬퍼 안의 throw가 핵심입니다 — 타입스크립트가 그 뒤로는 null가능성을 제외해 줍니다.
3) 캐스팅으로 시작하기 — 권장하지 않음 #
createContext<User>({} as User)처럼 거짓 시작값을 캐스팅하는 패턴을 종종 봅니다. 코드는 짧지만, 사용 시점에 Provider가 없으면 빈 객체가 그대로 새어 나가서 런타임 버그로 이어집니다. 거의 항상 패턴 2가 더 안전합니다.
State + Dispatch를 함께 흘릴 때 — 두 Context로 나누기 #
Context로 상태와 변경 함수를 함께 내려보낼 때, 상태와 dispatch를 두 Context로 나누는 게 리렌더 비용을 줄여 줍니다. dispatch만 쓰는 컴포넌트가 상태가 바뀐다고 같이 리렌더되는 걸 막을 수 있습니다.
type State = { count: number };
type Action = { type: 'inc' } | { type: 'dec' };
const StateContext = createContext<State | null>(null);
const DispatchContext = createContext<React.Dispatch<Action> | null>(null);
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'inc': return { count: state.count + 1 };
case 'dec': return { count: state.count - 1 };
}
}
export function CounterProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
);
}
export function useCounterState() {
const v = useContext(StateContext);
if (v === null) throw new Error('CounterProvider 안에서만 사용');
return v;
}
export function useCounterDispatch() {
const v = useContext(DispatchContext);
if (v === null) throw new Error('CounterProvider 안에서만 사용');
return v;
}useCounterDispatch만 쓰는 컴포넌트는 count가 바뀌어도 리렌더되지 않습니다. 작은 앱에선 과한 최적화일 수 있지만, Context로 빈번한 상태를 흘리고 있다면 한 번쯤 고려해볼 패턴입니다.
제네릭 컴포넌트 — 어떤 데이터든 받아내는 컴포넌트 #
리스트, 셀렉트, 테이블 같은 컴포넌트는 “어떤 데이터든 받아서 렌더해 줘” 가 자연스러운 요구입니다. 함수에 제네릭을 쓰는 것처럼 컴포넌트에도 제네릭을 쓸 수 있습니다.
type ListProps<T> = {
items: readonly T[];
renderItem: (item: T) => React.ReactNode;
keyOf: (item: T) => string | number;
};
function List<T>({ items, renderItem, keyOf }: ListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={keyOf(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
// 사용처
type Todo = { id: string; text: string };
function TodoList({ todos }: { todos: Todo[] }) {
return (
<List
items={todos}
keyOf={(t) => t.id}
renderItem={(t) => <span>{t.text}</span>}
/>
);
}핵심은 function List<T>(props: ListProps<T>)처럼 함수 키워드 뒤에 제네릭 파라미터를 적는 것입니다. 호출하는 쪽에서는 items의 타입으로부터 T가 자동 추론됩니다.
참고:
.tsx파일에서 화살표 함수 + 제네릭은<T>가 JSX 태그로 잡혀 모호해질 수 있습니다. 그래서 제네릭 컴포넌트는 거의 항상function선언 형태를 씁니다.
제네릭 컴포넌트 + 제약(extends) #
T가 어떤 모양이어야 한다는 제약을 두면 컴포넌트 본문에서 그 필드를 직접 쓸 수 있습니다.
type WithId = { id: string };
type ListProps<T extends WithId> = {
items: readonly T[];
renderItem: (item: T) => React.ReactNode;
};
function List<T extends WithId>({ items, renderItem }: ListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>{renderItem(item)}</li> // item.id 사용 가능
))}
</ul>
);
}
// 사용처는 keyOf 안 써도 됨
<List items={todos} renderItem={(t) => <span>{t.text}</span>} />이 방식은 호출이 짧아져서 좋지만, 이제 List가 받는 데이터가 반드시 id: string을 가져야 하는 제약이 생깁니다. 두 패턴 모두 쓸 곳이 있고, 유연성이 더 중요하면 keyOf 함수를, 안전성과 짧은 호출이 중요하면 제약이 적합합니다.
다형 컴포넌트 — as prop으로 태그 바꾸기
#
같은 디자인이지만 어떤 곳에서는 <button>, 어떤 곳에서는 <a>, 어떤 곳에서는 <Link>로 렌더되는 컴포넌트를 종종 만듭니다. 이를 다형(polymorphic) 컴포넌트 라고 부르고, 타입스크립트로 제대로 잡으려면 손이 좀 갑니다.
가장 단순한 형태부터 보겠습니다.
import type { ElementType, ComponentPropsWithoutRef } from 'react';
type BoxProps<E extends ElementType> = {
as?: E;
children?: React.ReactNode;
} & Omit<ComponentPropsWithoutRef<E>, 'as' | 'children'>;
function Box<E extends ElementType = 'div'>({
as,
children,
...rest
}: BoxProps<E>) {
const Tag = as ?? 'div';
return <Tag {...rest}>{children}</Tag>;
}
// 사용처
<Box>기본 div</Box>
<Box as="a" href="/about">링크처럼</Box>
<Box as="button" onClick={() => {}}>버튼처럼</Box>작동 원리를 한 줄씩 풀면:
E extends ElementType— E는 HTML 태그 이름('div','a', …)이거나 컴포넌트 타입.ComponentPropsWithoutRef<E>— 그 태그/컴포넌트의 모든 props를 가져옴.Omit<..., 'as' | 'children'>— 우리가 따로 정의한 prop과 겹치지 않게 빼냄.- 디폴트 제네릭
E = 'div'—<Box>만 적었을 때 타입이 div로 잡히게.
이 패턴 하나로 as="a" 일 때는 href 자동완성, as="button" 일 때는 onClick 자동완성이 모두 정확하게 동작합니다.
다형 컴포넌트는 정말 필요할 때만 #
이 패턴은 강력하지만 타입이 빠르게 복잡해지고, 에디터의 자동완성이 무거워질 수 있습니다. 디자인 시스템 라이브러리 입장에서는 가치가 크지만, 일반 앱 코드라면 as 대신 Button/LinkButton 두 컴포넌트를 따로 만드는 쪽이 읽기 좋을 때가 많습니다. 트레이드오프를 의식하고 쓰세요.
제네릭 hook — 살짝만 #
같은 제네릭 패턴이 hook에도 적용됩니다. 자주 쓰는 예가 “API 응답을 들고 있는 hook” 인데, 이건 다음 글(#6 fetch와 API 응답 타이핑)에서 본격적으로 다루겠습니다. 미리 모양만 보면:
function useResource<T>(url: string): { data: T | null; loading: boolean } {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(url)
.then((r) => r.json() as Promise<T>)
.then((d) => {
setData(d);
setLoading(false);
});
}, [url]);
return { data, loading };
}
// 사용처
const { data } = useResource<User>('/api/me');
// data: User | null
이 코드의 **r.json() as Promise<T>**가 사실 가장 위험한 부분입니다. 서버가 진짜 T 모양을 줬는지 검증한 적이 없기 때문입니다. 다음 글에서 zod로 이 부분을 안전하게 메우는 방법을 다루겠습니다.
마무리 #
이번 글에서는 다음을 정리했습니다.
- Context 시작값은
null+ 헬퍼 패턴이 실무 기본값 - State와 dispatch를 두 Context로 나누면 리렌더가 줄어듦
- 제네릭 컴포넌트는
function List<T>(...)형태. 화살표 함수는 JSX와 충돌 - 호출 단축이 필요하면
T extends WithId같은 제약을 둠 - 다형 컴포넌트는
asprop +ComponentPropsWithoutRef<E>조합. 강력하지만 비용 있음
다음 글(#6 fetch와 API 응답 타이핑)에서는 시리즈 마지막으로, 외부 API에서 온 데이터를 어떻게 타입스크립트 안에서 안전하게 다룰지 — 제네릭 fetcher와 zod 런타임 검증까지 묶어 정리하겠습니다.