타입스크립트에서의 Context API
리액트 프로젝트를 하다보면 Context API를 사용하여 외부에서 의존성을 주입해야 하는 경우가 종종 있다. 이때 주입하는 의존성은 크게 값, mutate, 그 외 로직의 세 가지로 분류할 수 있을 것 같다. 자바스크립트에서라면 이 세 경우를 굳이 나눠서 생각할 이유가 없다. createContext로 생성한 context의 provider value props에는 무슨 값이던 넣을 수 있고, 그 아래 노드에서는 무슨 값이든 빼서 쓸 수 있다. 물론 이렇게 쓰면 런타임 에러가 발생하고, 그래서 보통은 커스텀 훅으로 감싸게 되지만, 할 수 있다는 게 중요하다.
타입스크립트에서 Context API를 사용할 때는 몇 가지 제약 사항이 뒤따른다. 반드시 default value를 제공해야 하는데, 이는 provider가 없을 경우 createContext에 제공한 default value를 사용하기 때문이며, 동시에 해당 값을 통해 value로 받을 수 있는 타입을 추론하기 때문이다. default value로 타입을 추론하지만, 제네릭 타입을 제공함으로써 value를 조금 더 확장하여 사용할 수도 있다.
createSafeContext
우선 나는 상태를 Context API로 내려보내지 않는다. 널리 알려진 것처럼 리랜더링 문제가 심각하며, 때문에 Jotai∙Zustand∙Caro-Kann 같은 전역 상태 관리 도구를 사용하는 편을 더 권장한다. 따라서 값을 주입할 때는 보통 상태를 제외한 기본형과 함수를 제외한 참조형이 그 대상이 된다.
Context API를 사용할 때 아래와 같이 default value가 충실하게 들어있는 경우라면 별도의 context provider를 사용하지 않아도 문제가 되지 않는다. useContext가 default value를 가져오기 때문이다.
type Book = {
isbn13: string;
title: string;
author: string;
description: string;
cover: string;
price: number;
}
export const BookObjcetContext = createContext<Book>({
isbn13: "9788937460586",
title: "싯다르타",
author: "헤르만 헤세",
description: "헤르만 헤세의 1922년 작품으로 싯다르타(부처)의 생애를 소설화 했다. 동서양의 세계관,종교관을 자기 체험 속에 융화시킨 작품으로, 내면으로의 길을 지향하는 작가의 영혼이 투영되어 있다.",
cover: "https://image.aladin.co.kr/product/32/95/cover200/8937460580_3.jpg",
price: 8000,
})
그렇지만 실제로 Context API를 통해 내려주려는 값은 createContext를 호출하는 시점에는 알 수 없는 '동적인 값'인 경우가 대다수이다. 따라서 나는 팩토리 패턴을 응용하여 컨텍스트를 생성하는 createSafeContext 함수를 사용한다. 이를 통해 안전한 컨텍스트를 찍어낼 수 있다.
// createSafeContext.tsx
type CreateContextResult<T> = {
Provider: React.FC<{ value: T; children: ReactNode }>;
useContext: (conponentName: string) => T;
};
export function createSafeContext<T>(): CreateContextResult<T> {
const Context = createContext<T | undefined>(undefined);
const useSafeContext = (componentName: string) => {
const context = useContext(Context);
if (context === undefined) {
throw new Error(`${componentName} component must be used within a Provider`);
}
return context;
};
const Provider: React.FC<{ value: T; children: ReactNode }> = ({ value, children }) => {
return <Context.Provider value={value}>{children}</Context.Provider>;
};
return { Provider, useContext: useSafeContext };
}
// BookContext.tsx
export const {
Provider: BookProvider,
useContext: useBookContext
} = createSafeContext<Book>()
export const { Provider: BookProvider, useContext: useBookContext } = createSafeContext<Book>()
// Page.tsx
// 컴포넌트에서 Book은 서버 응답의 결과라고 가정함
export default async function Page() {
return (
<BookProvider value={Book}>
<BookTitle />
</BookProvider>
)
}
// BookTitle.tsx
function BookTitle() {
const { title } = useBookContext()
return <>{title}</>
}
useBookContext의 파라미터로 componentName을 받는 이유는 예외를 던지는 주체가 useSafeContext이기 때문에, 콜스택이 복잡한 경우 한 번에 문제가 되는 컴포넌트를 알기 어렵기 때문이다.
mutate
react-query를 사용하면 Context API로 값을 내려보낼 일이 잘 없는데, 그냥 필요한 곳에서 쿼리를 호출하면 되기 때문이다. 이러한 '필요한 곳에서 쿼리를 수행한다'는 철학은 app router의 fetch에서도 동일하게 구현되어있다.
나의 경우에는 useMutation이 리턴하는 객체의 mutate 메소드를 내려보낼 때 Context API를 가장 자주 사용한다. 가령 게시글 form의 경우 작성과 수정에 있어서 동일한 형태를 가지고 있다. 따라서 CreateForm과 PatchForm을 만드는 것이 아닌, 공통 컴포넌트에 Context API로 서로 다른 mutate 메소드를 주입하는 방식을 사용할 수 있다.
그런데 외부에서 주입하고자 하는 mutate 메소드가 서로 다른 props를 요구한다면, 아래의 코드의 1번과 2번 중 어떤 식으로 타입을 지정해야 할까?
interface CommonReportProps {
title: string;
content: string;
tags: string[];
}
interface CreateReportProps extends CommonReportProps {
isbn13: string
}
interface PatchReportProps extends Partial<CommonReportProps> {
reportId: string
}
// 1번
const first = createSafeContext<((props: CreateReportProps | PatchReportProps) => void)>()
// 2번
const second = createSafeContext<((props: CreateReportProps) => void) | ((props: PatchReportProps) => void)>()
잠깐 생각해보면 쉬운 문제인데, value로 여러 종류의 mutate를 받아야 하는 거지 하나의 mutate에 props를 다양하게 받을 수 있는 상황이 아니므로, 답은 2번이다.
그리고 useContext로 받아온 value의 타입을 살펴봐도 재미있는 점을 발견할 수 있다. useContext가 리턴하는 value는 두 개 함수의 유니언 타입으로 되어있지만, 호출한 value의 타입은 props의 인터섹션으로 되어있다. 이는 공통 컴포넌트 내에서는 어떤 mutate가 내려올 지 모르기에, 일단 필요한 값은 죄다 준비해두어야 하기 때문이다.
// const value: ((props: CreateReportProps) => void) | ((props: PatchReportProps) => void)
const value = useMutateContext("Test")
// const value: (props: CreateReportProps & PatchReportProps) => void
value()
그 외 로직
mutate를 제외한 함수를 Context API로 주입하면 props가 인터섹션이 되는 것과는 다르게 리턴 타입은 ─ 당연하게도 ─ 유니언 타입으로 잘 추론된다. 따라서 공통 컴포넌트 내에서 Context API로 받아온 value의 리턴 값을 활용하고자 한다면, 조건문 등을 사용해서 적절히 처리해야 한다. 때문에 나 같은 경우는 Context API로 주입하는 함수들의 리턴 타입을 가능한 통일시키려 노력하는 편이다.
블로그의 정보
Ayden's journal
Beard Weard Ayden