useContext가 모두를 깨우는 이유
useContext는 부분 구독을 모르거든요. 그래서 selector가 따로 필요해요.
폼 페이지를 짜다 보면 react-hook-form의 <FormProvider> 한 줄에 의지하게 돼요. Sidebar 에 글자 하나 더 적었을 뿐인데 Preview 영역이 깜빡일 때가 있거든요. useContext 는 부분 구독을 모르기 때문에, value 가 바뀌면 그 Context 를 보는 모든 자손이 같이 다시 그려져요. 이 자리에서 selector 패턴이 필요해져요.
FormProvider 한 줄에서 시작하는 함정
가벼운 예로 시작할게요. 좌측 Sidebar 에는 제목 input 과 색상 picker 가 있고, 우측 Preview 에는 그 값으로 만든 카드가 떠 있어요. 폼 값은 공통이라 Context 로 묶어 내려보내는 게 자연스러워요.
<FormProvider {...form}>
<Layout>
<Sidebar />
<Preview />
</Layout>
</FormProvider>여기서 Sidebar 의 input 글자가 한 칸 늘었다고 해봐요. 폼 value 가 바뀌고, Provider 의 value 객체 참조도 새로 만들어져요. 그러면 useFormContext() 로 그 Context 를 구독하던 Preview 도 같이 리렌더돼요. Preview 안에 SVG 텍스처나 무거운 차트가 있으면 입력할 때마다 한 박자 끊기는 게 그대로 느껴져요.
여기서 보통 두 갈래 길이 보여요. 하나는 Context 를 잘게 쪼개는 길, 다른 하나는 selector 를 도입하는 길이에요. 쪼개기는 Context를 여러 개로 쪼개야 하는 이유에서 다뤘던 그 패턴인데, 폼처럼 값이 한 덩어리로 묶여 있는 경우엔 쪼개기만으로 안 풀리는 자리가 많아요. 그래서 selector 가 등장해요.
useContext가 모두를 깨우는 이유
왜 모든 자손이 다시 그려질까요. React 공식 문서가 정의 자체를 명확히 적어둬요.
"React automatically re-renders all the children that use a particular context starting from the provider that receives a different value. The previous and the next values are compared with the Object.is comparison." - React docs
Provider 가 받은 value 를 React 가 Object.is 로 이전 값과 비교해요. 다르면 그 Context 를 useContext 로 보는 자손 전부를 다시 그려요. 자손 컴포넌트 안에서 "어, 나는 그 값 안 쓰는데" 하고 거부할 방법이 없어요.
React.memo 로 감싸면 되지 않을까 싶지만, 같은 문서가 다음 줄을 못 박아둬요.
"Skipping re-renders with memo does not prevent the children receiving fresh context values." - React docs
memo 는 부모가 새 props 를 줄 때 리렌더를 건너뛰게 해주는 게 일이에요. Context 값이 바뀌어서 일어나는 리렌더는 props 와 무관하게 일어나거든요. 그래서 memo 로는 못 막아요.
해법은 두 가지예요. 첫째는 Provider value 를 useMemo 로 감싸 참조를 유지하는 거예요. 매 렌더마다 새 객체를 만들면 Object.is 가 늘 false 라 모든 구독자가 깨어나요. 둘째는 value 가 진짜로 바뀌는 게 맞을 때 그중 필요한 부분만 보는 컴포넌트만 깨우는 거예요. 두 번째가 selector 패턴이에요. 리액트는 언제 다시 그리나요에서 리렌더 트리거들을 정리했는데, Context 발 리렌더는 그중에서도 "거부할 수 없는" 쪽이에요.
selector가 하는 일
selector 는 한 줄로 줄이면 "store 에서 필요한 값을 뽑는 함수" 예요. 이 함수가 매 변경마다 실행되고, 이전 결과와 새 결과를 비교해서 다를 때만 그 컴포넌트가 리렌더돼요. react-redux 의 useSelector 가 가장 친숙한 예고, 같은 발상이 Zustand 에도 들어가 있어요.
// react-redux
const userName = useSelector((state) => state.user.name);
// zustand
const userName = useUserStore((state) => state.user.name);뽑은 값이 직전 값과 같으면 리렌더가 일어나지 않아요. name 만 보는 컴포넌트는 email 이 바뀌어도 가만히 있어요. 이게 부분 구독이에요. subscribe 한 줄에 숨은 옵저버 패턴에서 봤듯, selector 는 새 개념이 아니라 store 가 알림을 뿌릴 때 듣는 쪽이 필요한 값만 골라 듣는 옵저버의 변형이에요.
여기서 흔한 함정 하나만 짚을게요. selector 가 매번 새 객체를 반환하면 비교가 늘 false 라 무한 리렌더가 시작돼요.
// 매번 새 배열, 무한 리렌더
const users = useStore((s) => s.users.filter((u) => u.active));
// 얕은 비교로 동일성 판단
const users = useStore(useShallow((s) => s.users.filter((u) => u.active)));"A useSelector call returning the entire root state is almost always a mistake, as it means the component will rerender whenever anything in state changes." - react-redux docs
selector 의 본질은 "뽑아내기" 예요. 통째로 가져오면 selector 를 안 쓴 거랑 같아요.
selector를 구현하는 네 갈래 길
selector 패턴을 도입할 때 손에 잡히는 옵션은 보통 네 가지예요. 각각 다른 트레이드오프가 있어요.
use-context-selector
Daishi Kato 가 만든 라이브러리예요. React Context 와 가장 가까워요. 기존 useContext(MyContext) 자리를 useContextSelector(MyContext, selector) 로 바꾸기만 하면 돼요.
import { createContext, useContextSelector } from "use-context-selector";
const FormCtx = createContext(initialForm);
function Preview() {
const title = useContextSelector(FormCtx, (v) => v.title);
return <Card title={title} />;
}흥미로운 디테일이 하나 있어요. 이 라이브러리 README 가 "단순히 리렌더만 피하고 싶다면 Zustand 또는 useSyncExternalStore 를 직접 쓰는 걸 권한다" 고 적어둬요. 라이브러리 자체는 본래 Concurrent React 호환을 위한 Context API 에뮬레이션이 목적이거든요. 그러니 Context 형태를 꼭 유지해야 하는 이유가 있을 때 1순위예요.
Zustand
Zustand 는 처음부터 selector 가 기본 API 예요.
import { create } from "zustand";
import { useShallow } from "zustand/react/shallow";
const useFormStore = create((set) => ({
title: "",
color: "#000",
setTitle: (v) => set({ title: v }),
}));
function Preview() {
const title = useFormStore((s) => s.title);
return <Card title={title} />;
}Provider 가 아예 없어요. store 가 컴포넌트 트리 밖에 살아서 import 만 하면 어디서든 구독해요. 객체나 배열을 selector 가 반환할 때만 useShallow 로 감싸면 돼요.
Jotai
Jotai 는 selector 를 별도 개념으로 두지 않아요. atom 자체가 selector 의 단위예요.
import { atom, useAtomValue } from "jotai";
const titleAtom = atom("");
const colorAtom = atom("#000");
function Preview() {
const title = useAtomValue(titleAtom);
return <Card title={title} />;
}titleAtom 만 구독하니까 colorAtom 이 바뀌어도 Preview 는 가만히 있어요. derived atom 으로 합성도 자유로워요. selector 라는 별도 함수를 안 쓰는 게 매력이고, 동시에 작은 atom 을 많이 만들어야 한다는 게 부담이에요.
useSyncExternalStore 직접 구현
라이브러리 없이 React 표준 훅만으로도 selector 를 짤 수 있어요. React 18 에 들어온 useSyncExternalStore 가 그 자리에 있어요.
function useFormSelector(selector) {
return useSyncExternalStore(
formStore.subscribe,
() => selector(formStore.getState()),
);
}getSnapshot 의 반환값이 Object.is 로 다를 때만 React 가 리렌더해요. selector 가 부분 구독이 되는 원리가 여기서 드러나요. Zustand 도 내부에서 같은 훅을 써요. 다만 직접 쓰려면 getSnapshot 이 객체를 새로 만들지 않게 메모이즈 하는 일까지 직접 해야 해서, 실전에서는 라이브러리에 맡기는 쪽이 편해요. 이 훅 자체의 함정과 동작은 Zustand 밑에 깔린 훅의 정체에서 자세히 다뤘어요.
Sidebar 와 Preview, 실제로는 이렇게
도입에서 시작한 시나리오로 돌아가서 같은 화면을 두 갈래로 만들어볼게요. selector 도구 없이 useContext 만 쓰는 길과, react-hook-form 의 useWatch 로 selector 를 쓰는 길이에요. 같은 결과를 다른 모양으로 보여줄 뿐, 메시지는 하나예요. 필요한 컴포넌트만 깨우는 거.
Context 를 쪼개서 여러 Provider 로
부분 구독은 selector 만의 답이 아니에요. Context 를 도메인별로 쪼개두면 useContext 만으로도 부분 구독 효과가 나요. Preview 가 TitleCtx 만 보면 ColorCtx 가 바뀌어도 가만히 있거든요.
const TitleCtx = createContext("");
const ColorCtx = createContext("#000");
const SetTitleCtx = createContext<(v: string) => void>(() => {});
const SetColorCtx = createContext<(v: string) => void>(() => {});
function FormProviders({ children }) {
const [title, setTitle] = useState("");
const [color, setColor] = useState("#000");
return (
<TitleCtx.Provider value={title}>
<ColorCtx.Provider value={color}>
<SetTitleCtx.Provider value={setTitle}>
<SetColorCtx.Provider value={setColor}>{children}</SetColorCtx.Provider>
</SetTitleCtx.Provider>
</ColorCtx.Provider>
</TitleCtx.Provider>
);
}
function Preview() {
const title = useContext(TitleCtx); // color 가 바뀌어도 가만
return <Card title={title} />;
}장점은 의존성이 코드에 그대로 드러난다는 거예요. Preview 가 어떤 값을 보는지 import 줄과 useContext 호출만 봐도 알 수 있어요. 단점은 필드가 늘면 Provider 가 그만큼 늘고 중첩이 깊어진다는 거고요. 폼이 5~10 필드만 넘어가도 답답해져요.
한 Context 에 selector 를 얹기
같은 화면을 useWatch 로 풀면 Provider 는 하나로 충분해요.
import { FormProvider, useForm, useFormContext, useWatch } from "react-hook-form";
function App() {
const form = useForm({ defaultValues: { title: "", color: "#000" } });
return (
<FormProvider {...form}>
<Layout>
<Sidebar />
<Preview />
</Layout>
</FormProvider>
);
}
function Sidebar() {
const { register } = useFormContext();
return (
<>
<input {...register("title")} />
<input type="color" {...register("color")} />
</>
);
}
function Preview() {
const title = useWatch({ name: "title" });
return <Card title={title} />;
}Provider 한 줄로 폼 상태가 깊은 자손까지 흘러가요. Preview 는 useWatch({ name: "title" }) 로 title 필드만 구독해서, color 가 바뀌면 Sidebar 의 color input 만 다시 그려지고 Preview 는 가만히 있어요. useFormContext().watch() 와 다르게 호출한 컴포넌트 레벨로 리렌더가 격리돼요. selector 패턴이 도메인 라이브러리 안에 흡수된 모양이에요.
react-hook-form 외 다른 selector 도구도 모양만 살짝 다를 뿐 같은 자리에 들어가요. use-context-selector 면 useContextSelector(FormCtx, v => v.title), Zustand 면 useFormStore(s => s.title) 이에요. store 의 위치 (Context 안 vs 트리 밖) 와 setter 호출 방식만 차이가 있고, 부분 구독이라는 핵심은 같아요.
어느 길을 고를까
폼 필드가 적고 도메인이 깔끔하게 갈리면 길 1 (Context 쪼개기) 이 가장 가벼워요. 필드가 늘거나 동적이거나, 이미 react-hook-form 같은 도메인 라이브러리를 쓰고 있다면 길 2 (selector) 가 자연스럽고요.
selector 도구 자체의 선택을 짧게요. Context 형태를 유지해야 하면 use-context-selector, 새 store 가 필요하면 Zustand, 작은 단위 합성이 우선이면 Jotai, 외부 시스템과 직접 동기화하면 useSyncExternalStore 직접 구현이에요. Zustand 와 Jotai 가 어디서 갈리는지는 따로 한 글로 풀었어요.
React 공식 문서가 Context 챕터 시작에서 던지는 말로 닫을게요. "props 가 몇 단계 깊게 전달된다고 해서 그 정보를 context 에 넣어야 한다는 뜻은 아니에요." Context 를 너무 쉽게 꺼내지 말고, 정말 깊은 트리에 값을 흘려보내야 할 때만 꺼내요. 그리고 그 자리에 도착했다면, useContext 한 줄로 끝내지 말고 selector 한 줄을 더 떠올리세요.
참고 자료
- React - useContext
Context value 비교가 Object.is 로 일어나고 memo 로 못 막는다는 caveat
- React - Passing Data Deeply with Context
Context 를 쓰기 전 props 와 composition 을 먼저 검토하라는 공식 권고
- React - useSyncExternalStore
selector 의 기반 훅. getSnapshot 동일성 비교가 부분 구독의 핵심
- use-context-selector
Context selector 의 userland 구현과 라이브러리 자체의 한계 설명
- React RFC #119 - Context Selectors
selector API 표준화 제안과 그 이후 React 팀이 잡은 방향
- Zustand
selector 기본 API 와 useShallow 의 객체 반환 함정 해법
- Jotai
atom 단위 구독이 selector 자체를 우회하는 방식
- React Redux - Hooks
useSelector 동일성 비교 규칙과 root state 통째 구독의 위험
- react-hook-form - useWatch
FormProvider 안에서 특정 필드만 isolated 리렌더로 구독