목차
20 장

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” 같은 경우에 적합합니다.

기본값이 있는 Context
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로 두고, 사용처에서 한 번 검사하는 헬퍼를 만들어 둡니다. 이 패턴이 실무에서 가장 많이 쓰입니다.

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만 쓰는 컴포넌트가 상태가 바뀐다고 같이 리렌더되는 걸 막을 수 있습니다.

state Context + dispatch Context
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 위에서 더 명확히 구현한 모양입니다.

제네릭 컴포넌트 — 어떤 데이터든 받아내는 컴포넌트 #

리스트, 셀렉트, 테이블 같은 컴포넌트는 “어떤 데이터든 받아 렌더해 줘"가 자연스러운 요구입니다. 함수에 제네릭을 쓰는 것처럼 컴포넌트에도 제네릭을 쓸 수 있습니다.

제네릭 List 컴포넌트
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가 어떤 모양이어야 한다는 제약을 두면 컴포넌트 본문에서 그 필드를 직접 쓸 수 있습니다.

제약 — id 필드를 강제
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>

작동 원리를 한 줄씩 풀면:

  1. E extends ElementType — E는 HTML 태그 이름 ('div', 'a', …)이거나 컴포넌트 타입
  2. ComponentPropsWithoutRef<E> — 그 태그 / 컴포넌트의 모든 props를 가져옴
  3. Omit<..., 'as' | 'children'> — 우리가 따로 정의한 prop과 겹치지 않게 빼냄
  4. 디폴트 제네릭 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으로 받으면 됩니다.

제네릭 + ref-as-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:

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:

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)이 왜 위험한지 직접 비교해 볼 수 있습니다.

연습문제 #

  1. AuthContext를 state Context와 dispatch Context로 분리해 보세요. user는 state Context에, login / logout은 dispatch Context에 두고 각각 useAuthState, useAuthDispatch 헬퍼를 만듭니다. LoginForm 컴포넌트에 console.log('rendered')를 심어 보면, dispatch만 쓰는 자식이 user 변경에 리렌더되지 않는 것을 확인할 수 있습니다.
  2. 제네릭 Select 컴포넌트를 본 챕터의 ref-as-prop 패턴으로 만들고, string 배열을 받는 케이스와 { code: string; name: string } 배열을 받는 케이스 두 가지를 부모에서 호출해 보세요. getValue / getLabel 시그니처가 두 호출에서 자동으로 추론되는 것을 확인합니다.
  3. 다형 컴포넌트와 두 컴포넌트 분리의 트레이드오프 비교. 위 Box처럼 다형으로 만든 버튼과, Button / LinkButton 두 컴포넌트로 분리한 버전을 각각 만들어 호출 코드를 비교해 보세요. 자동완성 속도, 호출 코드의 명확함, 에러 메시지 가독성 세 측면에서 어느 쪽이 더 좋게 느껴지는지 짧게 적어 봅니다.

한 줄 요약: Context 시작값은 null + 헬퍼 패턴이 실무 기본값. state와 dispatch를 두 Context로 나누면 리렌더가 줄어든다. 제네릭 컴포넌트는 function List<T>(...) 형태 (화살표 함수는 JSX와 충돌). 호출 단축이 필요하면 T extends WithId 같은 제약. 다형 컴포넌트는 as prop + 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가 사라지는 새 모델의 토대도 함께 짚습니다.

X