공유가 필요한 상태를 어느 높이에 둘 것이냐, 그 첫 번째 정석 기법이 상태 끌어올리기(lifting state up, 상태를 자식에서 공통 부모로 옮기는 것)다. 이름은 거창하지만 상황은 아주 흔하다. 나란히 놓인 두 컴포넌트가 같은 값을 봐야 하는데, 지금 그 값은 한쪽 자식이 혼자 쥐고 있어서 다른 쪽이 볼 수가 없는 경우다. 검색창과 그 밑의 결과 목록이 대표적이다. 검색창에 친 글자를 결과 목록이 알아야 걸러 보여줄 텐데, 이 둘은 형제라 서로에게 값을 직접 건넬 방법이 없다. 이 막힘을 뚫는 게 끌어올리기다. 발상은 단순하다. 값을 옆으로 못 보내겠으면, 둘 다 값을 받을 수 있는 위치, 즉 공통 부모로 값을 올려버리는 것이다. 상태가 있어야 할 자리를 자식이 아니라 부모로 바로잡는 작업이라고 봐도 된다.
형제 컴포넌트는 서로 직접 대화하지 못한다. 리액트에서 값은 부모에서 자식으로, 위에서 아래로만 흐른다. 옆으로 흐르는 통로는 없다. 그래서 검색창이 자기 안에 검색어를 state로 들고 있으면, 그 값은 검색창 안에 갇힌다.
function SearchInput() {
const [query, setQuery] = useState("");
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
function ProductList() {
// query를 여기서 알 방법이 없다
}
ProductList는 옆에 있는 SearchInput의 query를 들여다볼 수단이 없다. 형제의 상태에 손을 뻗는 문법 같은 건 리액트에 없기 때문이다. 값이 엉뚱한 층에, 그것도 필요한 컴포넌트의 손이 닿지 않는 곳에 놓여 있는 게 문제의 핵심이다.
답은 공통 부모로 상태를 올리는 것이다. 두 형제가 같이 봐야 할 값이라면, 그 값을 둘의 공통 부모로 옮겨 부모가 소유하게 만든다. 검색어를 검색창에서 빼내 부모인 ProductSearch로 올린다.
function ProductSearch() {
const [query, setQuery] = useState("");
return (
<div>
<SearchInput value={query} onChange={setQuery} />
<ProductList query={query} />
</div>
);
}
이제 query는 부모가 쥐고 있다. 부모는 이 값을 양쪽 자식에게 props로 내려보낸다. SearchInput에는 값 자체(value)와 값을 바꿀 함수(onChange)를 함께 주고, ProductList에는 걸러낼 기준으로 쓸 query를 준다. 상태를 위로 한 층 올렸을 뿐인데, 아까 갇혀 있던 값이 두 자식 모두의 손에 닿게 됐다.
자식은 값을 받아 쓰고, 바꿀 땐 부모에게 알린다. 끌어올리기의 진짜 묘미는 자식 쪽 코드에 있다. 검색창은 이제 자기 state가 없다. 값은 부모가 준 value를 그대로 화면에 물리고, 사용자가 글자를 치면 자기가 값을 바꾸는 대신 부모가 준 onChange를 불러 "이렇게 바꿔달라"고 부탁한다.
function SearchInput({ value, onChange }) {
return (
<input value={value} onChange={(e) => onChange(e.target.value)} />
);
}
글자를 치면 onChange가 불리고, 그 실체는 부모의 setQuery다. 부모의 query가 바뀌면 ProductSearch가 다시 그려지고, 새 query가 다시 두 자식에게 내려간다. 자식은 값을 소유하지 않고 부모에게 위임한 채 받아 쓰기만 하는데, 이렇게 값을 props로 받고 변경을 부모에게 넘기는 방식을 제어 컴포넌트(controlled component, 값을 부모가 통제하는 컴포넌트)라 부른다. 결과 목록 쪽은 더 단순하다.
function ProductList({ query }) {
const filtered = products.filter((p) => p.name.includes(query));
return (
<ul>
{filtered.map((p) => <li key={p.id}>{p.name}</li>)}
</ul>
);
}
여기서 filtered는 상태가 아니라 query로 그때그때 계산해 낸 값이다. 걸러진 목록을 따로 state에 저장하지 않는다. query가 바뀌어 다시 그려질 때마다 최신 검색어로 새로 걸러내니, 목록이 검색어와 어긋날 일이 없다.
이렇게 하면 값이 한 곳에만 산다. 끌어올리기가 주는 가장 큰 이득이 이거다. query라는 값의 원본이 부모 딱 한 군데에만 있다. 검색창이 보는 값과 결과 목록이 거르는 기준이 같은 출처에서 나오니, 둘이 서로 다른 값을 볼 방법이 원천적으로 없다. 이걸 단일 진실 공급원(single source of truth, 하나의 값에 대한 원본은 오직 한 곳)이라 한다. 만약 검색창도 자기 query를 갖고 결과 목록도 자기 query를 따로 가졌다면, 둘을 매번 맞춰줘야 하고 한쪽만 갱신되는 순간 화면이 거짓말을 하기 시작한다. 원본을 하나로 모으면 그 어긋남이 통째로 사라진다.
절차로 외워두면 어디에나 그대로 쓴다. 상황이 달라져도 끌어올리기의 손동작은 늘 같은 세 단계다. 하나, 두 컴포넌트가 공유할 상태를 찾아 그것을 둘의 공통 부모로 옮긴다. 자식에 있던 useState를 잘라내 부모에 붙이는 것이다. 둘, 부모가 그 값을 props로 자식들에게 내려보낸다. 값을 읽기만 할 자식에게는 값을, 값을 바꿀 자식에게는 값과 바꿀 함수를 함께 준다. 셋, 값을 바꿔야 하는 자식은 자기가 직접 바꾸지 않고, 부모가 준 함수를 불러 변경을 위로 올려보낸다. 값은 내려가고 변경 요청은 올라온다. 이 두 방향의 흐름만 머릿속에 그려지면, 검색창이든 폼의 여러 입력칸이든 탭 목록과 본문이든 전부 같은 틀로 풀린다. 문제마다 새로 고민할 게 아니라 이 세 단계에 대입하기만 하면 된다.
다만 너무 높이 올리지는 마라. 끌어올리기를 배우면 이번엔 겁이 나서 모든 상태를 앱 꼭대기로 밀어 올리는 반대편 함정에 빠지기 쉽다. 상태는 그 값을 쓰는 컴포넌트들의 가장 가까운 공통 조상까지만 올린다. 검색창과 결과 목록만 query를 쓴다면 딱 그 둘의 부모인 ProductSearch까지만 올리면 되고, 그보다 위로 올릴 이유가 없다. 필요 이상으로 높이 올리면 그 값이 바뀔 때마다 실제로는 상관없는 넓은 영역까지 죄다 다시 그려지고, 부모 컴포넌트는 자기와 무관한 상태로 뚱뚱해진다. 딱 필요한 높이. 값이 공유돼야 하는 두 지점을 찾고, 그 둘이 만나는 가장 낮은 지점까지만 올린다. 나는 초창기에 이 균형을 못 잡아서, 앱 최상단 컴포넌트에 온갖 상태를 쌓아두고는 왜 이렇게 느리냐며 애꿎은 렌더 최적화만 뒤졌다. 알고 보니 상태를 필요 이상으로 높이 올려둔 게 원인이었고, 각 상태를 그걸 실제로 쓰는 컴포넌트 근처로 내려주는 것만으로 태반이 해결됐다. 끌어올리기는 무조건 위로 올리는 기술이 아니라, 값을 딱 맞는 높이에 놓는 기술이다. 이 감각이 서면 형제 간 데이터 공유는 더 이상 막막한 문제가 아니게 된다.