Context와 제네릭 컴포넌트
Context 타입 안전성 패턴(null + wrapper 훅), state·dispatch 분리, 제네릭 컴포넌트, 그리고 다형 컴포넌트의 as prop까지 한 번에 다룹니다.
19장까지로 컴포넌트 안쪽의 가장 흔한 타이핑은 정리됐습니다. 본 챕터는 한 단계 위에서, **여러 컴포넌트가 공유하는 값(Context)과 여러 형태의 데이터를 받아내는 컴포넌트(제네릭)**를 살펴봅니다.
12장 (useContext)의 JavaScript 패턴을 TypeScript 위에 다시 올리고, 거기에 제네릭 컴포넌트와 다형 컴포넌트(as prop)까지 더합니다. React 19의 ref-as-prop 모델과의 관계도 함께 짚습니다.
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가 핵심입니다. TypeScript가 그 뒤로는 null가능성을 제외해 줍니다.
이 책은 이 패턴을 기본값으로 씁니다. 32장 (인증과 세션)에서 useAuth를 만들 때도 같은 모양이 됩니다.
3) 캐스팅으로 시작하기 — 권장하지 않음 #
createContext<User>({} as User)처럼 거짓 시작값을 캐스팅하는 패턴을 종종 봅니다. 코드는 짧지만, 사용 시점에 Provider가 없으면 빈 객체가 그대로 새어 나가 런타임 버그로 이어집니다. 거의 항상 패턴 2가 더 안전합니다.
State + Dispatch를 함께 흘릴 때 — 두 Context로 나누기 #
Context로 상태와 변경 함수를 함께 내려보낼 때, 상태와 dispatch를 두 Context로 나누는 게 리렌더 비용을 줄여 줍니다. dispatch만 쓰는 컴포넌트가 상태가 바뀐다고 같이 리렌더되는 걸 막을 수 있습니다.
import { createContext, useContext, useReducer } from 'react';
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로 빈번한 상태를 흘리고 있다면 한 번쯤 고려해 볼 패턴입니다. 12장의 value 분리 패턴을 TypeScript 위에서 더 명확히 구현한 모양입니다.
제네릭 컴포넌트 — 어떤 데이터든 받아내는 컴포넌트 #
리스트, 셀렉트, 테이블 같은 컴포넌트는 “어떤 데이터든 받아 렌더해 줘"가 자연스러운 요구입니다. 함수에 제네릭을 쓰는 것처럼 컴포넌트에도 제네릭을 쓸 수 있습니다.
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) 컴포넌트라고 부르고, TypeScript로 제대로 잡으려면 손이 좀 갑니다.
17장에서 짚은 discriminated union의 한 응용입니다.
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 두 컴포넌트를 따로 만드는 쪽이 읽기 좋을 때가 많습니다. 트레이드오프를 의식하고 쓰세요.
ref를 prop으로 받는 제네릭 컴포넌트 — React 19 #
18장에서 짚은 ref-as-prop 모델은 제네릭 컴포넌트와도 자연스럽게 어울립니다. React 19 이전에는 forwardRef와 제네릭을 함께 쓰는 게 까다로웠는데, 이제는 그냥 prop으로 받으면 됩니다.
import type { Ref } from 'react';
type SelectProps<T> = {
ref?: Ref<HTMLSelectElement>;
options: readonly T[];
getValue: (item: T) => string;
getLabel: (item: T) => string;
};
function Select<T>({ ref, options, getValue, getLabel }: SelectProps<T>) {
return (
<select ref={ref}>
{options.map((opt) => (
<option key={getValue(opt)} value={getValue(opt)}>
{getLabel(opt)}
</option>
))}
</select>
);
}
// 사용처
type Country = { code: string; name: string };
const countries: Country[] = [
{ code: 'kr', name: '대한민국' },
{ code: 'us', name: 'United States' },
];
function CountryPicker() {
const ref = useRef<HTMLSelectElement>(null);
return (
<Select
ref={ref}
options={countries}
getValue={(c) => c.code}
getLabel={(c) => c.name}
/>
);
}옛 forwardRef와 제네릭을 함께 쓰는 모델 (제네릭이 잘 추론 안 되는 함정이 있던 영역)이 본 모델에서는 깔끔하게 풀립니다. 28장 (React 19 신규 기능 정리)에서 이 변화를 한 번 더 확인합니다.
제네릭 훅 — 살짝만 #
같은 제네릭 패턴이 훅에도 적용됩니다. 자주 쓰는 예가 “API 응답을 들고 있는 훅"인데, 다음 21장 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 모양을 줬는지 검증한 적이 없기 때문입니다. 21장에서 zod로 이 부분을 안전하게 메우는 방법을 보겠습니다.
직접 해보기 #
작은 인증 Context를 TypeScript로 만들어 봅니다.
src/AuthContext.tsx:
import { createContext, useContext, useState, useCallback } from 'react';
import type { ReactNode } from 'react';
type User = {
id: string;
name: string;
};
type AuthContextValue = {
user: User | null;
login: (name: string) => void;
logout: () => void;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const login = useCallback((name: string) => {
setUser({ id: crypto.randomUUID(), name });
}, []);
const logout = useCallback(() => setUser(null), []);
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const v = useContext(AuthContext);
if (v === null) {
throw new Error('useAuth는 AuthProvider 안에서만 호출하세요');
}
return v;
}src/App.tsx:
import { AuthProvider, useAuth } from './AuthContext';
function LoginForm() {
const { user, login, logout } = useAuth();
if (user) {
return (
<div>
<p>안녕하세요, {user.name}님!</p>
<button onClick={logout}>로그아웃</button>
</div>
);
}
return (
<form onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const name = formData.get('name');
if (typeof name === 'string' && name.length > 0) login(name);
}}>
<input name="name" placeholder="이름" />
<button type="submit">로그인</button>
</form>
);
}
function App() {
return (
<AuthProvider>
<LoginForm />
</AuthProvider>
);
}
export default App;저장하고 동작을 확인해 보세요. 그 뒤 <AuthProvider>를 빼고 <LoginForm />만 두면 어떤 일이 일어나는지 확인합니다. useAuth 안의 throw가 발화되어 콘솔에 명확한 에러가 표시됩니다. 캐스팅으로 시작하는 패턴 ({} as AuthContextValue)이 왜 위험한지 직접 비교해 볼 수 있습니다.
연습문제 #
- 위
AuthContext를 state Context와 dispatch Context로 분리해 보세요.user는 state Context에,login/logout은 dispatch Context에 두고 각각useAuthState,useAuthDispatch헬퍼를 만듭니다.LoginForm컴포넌트에console.log('rendered')를 심어 보면, dispatch만 쓰는 자식이 user 변경에 리렌더되지 않는 것을 확인할 수 있습니다. - 제네릭
Select컴포넌트를 본 챕터의 ref-as-prop 패턴으로 만들고,string배열을 받는 케이스와{ code: string; name: string }배열을 받는 케이스 두 가지를 부모에서 호출해 보세요.getValue/getLabel시그니처가 두 호출에서 자동으로 추론되는 것을 확인합니다. - 다형 컴포넌트와 두 컴포넌트 분리의 트레이드오프 비교. 위
Box처럼 다형으로 만든 버튼과,Button/LinkButton두 컴포넌트로 분리한 버전을 각각 만들어 호출 코드를 비교해 보세요. 자동완성 속도, 호출 코드의 명확함, 에러 메시지 가독성 세 측면에서 어느 쪽이 더 좋게 느껴지는지 짧게 적어 봅니다.
한 줄 요약: Context 시작값은
null+ 헬퍼 패턴이 실무 기본값. state와 dispatch를 두 Context로 나누면 리렌더가 줄어든다. 제네릭 컴포넌트는function List<T>(...)형태 (화살표 함수는 JSX와 충돌). 호출 단축이 필요하면T extends WithId같은 제약. 다형 컴포넌트는asprop +ComponentPropsWithoutRef<E>조합. 강력하지만 비용이 있으니 정말 필요할 때만. React 19의 ref-as-prop 모델은 제네릭 컴포넌트와 자연스럽게 어울린다.
다음 챕터 #
다음 21장 fetch와 API 응답 타이핑에서는 본 챕터에서 잠깐 본 useResource의 위험한 캐스팅(r.json() as Promise<T>)을 zod로 안전하게 메우는 방법을 다룹니다. 그리고 4부(모던 Next.js)의 Server Components 환경에서 fetch가 가지는 의미, 즉 클라이언트 useEffect + fetch가 사라지는 새 모델의 토대도 함께 짚습니다.