렌더 프롭과 헤드리스 컴포넌트
지금까지 살펴본 모든 컴포넌트는 눈에 띄는 것을 렌더링했습니다. **헤드리스 컴포넌트**는 스타일이 입혀진 UI를 갖지 않습니다. 순수하게 동작을 제공하기 위해 존재하며, 전달받은 자식을 그대로 렌더링합니다. 이것이 추상적으로 들릴 수 있지만, 같은 동작이 다른 모양으로 나타나는 위젯이 얼마나 많은지 보면 이해가 됩니다. 즐겨찾기 별, 드롭다운 메뉴, 다크 모드 스위치, '더보기' 섹션은 모두 boolean 값과 토글 함수로 이루어져 있습니다. 헤드리스 Toggle 컴포넌트는 이 로직을 한 번만 작성하고, 각 위젯은 이를 중심으로 마크업을 구성합니다.
이 장에서는 복합 컴포넌트로 Toggle을 만들고 context를 통해 조율하는 과정을 따라갑니다. 그리고 이 과정에서 마주치는 한계를 극복하기 위해 두 번째 아이디어, 즉 컴포넌트의 내부 상태를 렌더링하는 쪽에 노출하는 패턴인 렌더 프롭을 소개합니다.
헤드리스 Toggle 패밀리
Toggle은 boolean 값을 소유하고, 이를 toggle 함수와 함께 스코프된 ToggleContext 제공자를 통해 제공합니다. 나머지 패밀리 멤버들은 이 context를 소비하며, 각 조각은 Toggle의 프로퍼티로 연결됩니다 (Toggle.Button = ToggleButton). 이는 복합 컴포넌트의 점 표기법입니다:
<Toggle>
<Toggle.Button>
<Star />
</Toggle.Button>
<Toggle.On>토글이 켜져 있습니다</Toggle.On>
<Toggle.Off>토글이 꺼져 있습니다</Toggle.Off>
</Toggle>Toggle.Button은 자식을<button type="button">안에 렌더링하고,onClick에서 상태를 뒤집습니다. 자식은 자신의 클릭 핸들러가 필요하지 않습니다. 안쪽의 무엇이든 클릭되면 래퍼까지 버블링되고, 이는 React의 합성 이벤트 시스템을 통해 버튼 자체의 리스너가 아닌 이벤트 버블링이 작동합니다. 클릭 가능한<div>도 마우스 사용자에게는 같은 방식으로 작동하지만, 키보드 활성화와 스크린 리더 공지를 잃게 됩니다. 이는 접근성에서 다룹니다.Toggle.On은 상태가 참일 때 자식을 렌더링하고, 그 외에는 null을 렌더링합니다.Toggle.Off는 반대입니다.
별 아이콘, 메뉴 마크업, 라벨 모두는 호출자로부터 옵니다. 이것이 시각적으로 완전히 다른 위젯들에서 컴포넌트를 재사용 가능하게 만드는 요소입니다. 같은 Toggle을 별과 메뉴 둘 다에 사용하고, 메뉴는 자신의 조각들로 Toggle의 조각들을 내부적으로 감싸서, <Menu>를 사용하는 사람이 내부 메커니즘을 볼 수 없도록 합니다.
외부가 청취하도록 하기
실제 별 버튼은 단순히 다시 그려지는 것이 아니라 누군가 클릭했다는 것을 서버에 알립니다. 따라서 헤드리스 컴포넌트는 자신의 이벤트 리스너 프롭이 필요합니다. 이는 자식과 구성 장에서 다룬 자신의 컴포넌트의 onClick과 같은 모양입니다. Toggle은 onToggle 함수를 받아 상태가 변경될 때마다 effect에서 실행합니다:
function Toggle({ children, onToggle = () => {} }) {
const [on, setOn] = useState(false)
const toggle = () => setOn(prevOn => !prevOn)
const firstRender = useRef(true)
useEffect(() => {
if (firstRender.current) {
firstRender.current = false
} else {
onToggle()
}
}, [on])
// 제공자와 자식은 아래에서 렌더링됩니다
}거기 숨어 있는 작은 기법 두 가지가 있습니다. ref는 effect의 첫 실행을 방지합니다. effect는 초기 렌더링 이후에도 실행되는데, 아무도 수행하지 않은 "토글"을 알리는 것은 버그입니다. ref는 상태처럼 리렌더링을 트리거하지 않으면서 이것이 여전히 첫 렌더링인지를 추적합니다. 그리고 = () => {} 기본값은 아무것도 하지 않는 함수이므로, 이벤트를 신경 쓰지 않는 호출자도 onToggle()을 호출할 때 컴포넌트가 깨지지 않습니다.
의존성 배열은 on만 포함합니다. exhaustive-deps가 요청하는 onToggle을 추가하면 호출자가 새로운 인라인 함수를 전달할 때마다 effect가 다시 실행되므로, 이것이 린트 규칙과 의도가 일치하지 않는 곳 중 하나입니다. 가드에 있는 한 가지 주름도 개발 중에 나타납니다. StrictMode는 마운트 시 각 effect를 두 번 실행하는데, 이는 첫 번째 실행에서 ref를 소비하고 두 번째에서 콜백이 실행되도록 합니다. 프로덕션 빌드는 이렇게 하지 않으며, 견고한 해결책은 첫 렌더링인지 여부보다는 변경 자체에서 동작을 유도하는 것입니다. 즉, on을 이전 값과 비교합니다.
렌더 프롭: 내부를 외부로 넘기기
이제 패턴이 한계에 부딪힙니다. 배경색에 CSS transition이 있는 상자를 스타일링하고, 채워진 버전을 Toggle.On 내부에, 비워진 버전을 Toggle.Off 내부에 렌더링하면 transition이 재생되지 않습니다. 이유는 Toggle.On과 Toggle.Off가 자식을 마운트하고 언마운트하기 때문입니다. React는 클래스를 변경하는 것이 아니라 한 요소를 제거하고 다른 요소를 삽입하는 것입니다. 그리고 CSS는 존재하지 않게 된 요소에 transition을 줄 수 없습니다. 호출자가 필요한 것은 on 상태 자체이므로, 한 영속적인 요소가 자신의 클래스 이름을 변경할 수 있습니다. 문제는 상태가 Toggle 내부에 갇혀 있다는 것입니다.
탈출 방법은 JavaScript에서 익숙한 방법입니다. 콜백은 제어를 역전시킵니다. addEventListener("click", callback)을 호출할 때 함수를 공급하지만, 브라우저가 호출하고 브라우저가 어떤 것을 받을지 결정합니다 (이벤트 객체). 컴포넌트는 같은 거래를 제안할 수 있습니다. 함수를 전달하면 컴포넌트가 그 함수를 자신의 내부 상태와 함께 호출하고 함수가 반환하는 것을 렌더링합니다:
function ToggleDisplay({ children }) {
const { on } = useContext(ToggleContext)
return children(on)
}
Toggle.Display = ToggleDisplay<Toggle.Display>
{on => <div className={`box ${on ? 'filled' : ''}`} />}
</Toggle.Display>이번에 Toggle.Display의 자식은 요소가 아닙니다. 함수입니다. Toggle.Display가 호출하고, on을 전달하며, 다시 받은 JSX를 반환합니다. 이제 div는 매번 토글에서도 존재하는 한 요소이고, 클래스만 변경되므로 transition이 재생됩니다. 이것이 렌더 프롭 패턴입니다. 값이 함수인 프롭으로, 컴포넌트가 무엇을 렌더링할지 알기 위해 호출합니다. 일부 API는 render라는 문자 그대로의 프롭 이름을 사용했으며, 이것이 패턴 이름의 유래입니다. 함수를 children으로 전달하는 형태가 널리 퍼졌습니다.
그리고 실제 참/거짓 값이 필요할 때, 컴포넌트에 함수를 자식으로 전달합니다. 컴포넌트가 그 함수를 값과 함께 호출하고, 함수가 반환하는 모든 것이 페이지에 나타납니다.
다음: 실전 커스텀 훅. 같은 로직이 함수로 옮겨가고 컴포넌트는 사라집니다.

