Skip to content
This page has been auto-translated and may contain errors.View in English

렌더 프롭과 헤드리스 컴포넌트

지금까지 살펴본 모든 컴포넌트는 눈에 띄는 것을 렌더링했습니다. **헤드리스 컴포넌트**는 스타일이 입혀진 UI를 갖지 않습니다. 순수하게 동작을 제공하기 위해 존재하며, 전달받은 자식을 그대로 렌더링합니다. 이것이 추상적으로 들릴 수 있지만, 같은 동작이 다른 모양으로 나타나는 위젯이 얼마나 많은지 보면 이해가 됩니다. 즐겨찾기 별, 드롭다운 메뉴, 다크 모드 스위치, '더보기' 섹션은 모두 boolean 값과 토글 함수로 이루어져 있습니다. 헤드리스 Toggle 컴포넌트는 이 로직을 한 번만 작성하고, 각 위젯은 이를 중심으로 마크업을 구성합니다.

이 장에서는 복합 컴포넌트Toggle을 만들고 context를 통해 조율하는 과정을 따라갑니다. 그리고 이 과정에서 마주치는 한계를 극복하기 위해 두 번째 아이디어, 즉 컴포넌트의 내부 상태를 렌더링하는 쪽에 노출하는 패턴인 렌더 프롭을 소개합니다.

헤드리스 Toggle 패밀리

Toggle은 boolean 값을 소유하고, 이를 toggle 함수와 함께 스코프된 ToggleContext 제공자를 통해 제공합니다. 나머지 패밀리 멤버들은 이 context를 소비하며, 각 조각은 Toggle의 프로퍼티로 연결됩니다 (Toggle.Button = ToggleButton). 이는 복합 컴포넌트의 점 표기법입니다:

jsx
<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과 같은 모양입니다. ToggleonToggle 함수를 받아 상태가 변경될 때마다 effect에서 실행합니다:

jsx
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.OnToggle.Off가 자식을 마운트하고 언마운트하기 때문입니다. React는 클래스를 변경하는 것이 아니라 한 요소를 제거하고 다른 요소를 삽입하는 것입니다. 그리고 CSS는 존재하지 않게 된 요소에 transition을 줄 수 없습니다. 호출자가 필요한 것은 on 상태 자체이므로, 한 영속적인 요소가 자신의 클래스 이름을 변경할 수 있습니다. 문제는 상태가 Toggle 내부에 갇혀 있다는 것입니다.

탈출 방법은 JavaScript에서 익숙한 방법입니다. 콜백은 제어를 역전시킵니다. addEventListener("click", callback)을 호출할 때 함수를 공급하지만, 브라우저가 호출하고 브라우저가 어떤 것을 받을지 결정합니다 (이벤트 객체). 컴포넌트는 같은 거래를 제안할 수 있습니다. 함수를 전달하면 컴포넌트가 그 함수를 자신의 내부 상태와 함께 호출하고 함수가 반환하는 것을 렌더링합니다:

jsx
function ToggleDisplay({ children }) {
  const { on } = useContext(ToggleContext)
  return children(on)
}

Toggle.Display = ToggleDisplay
jsx
<Toggle.Display>
  {on => <div className={`box ${on ? 'filled' : ''}`} />}
</Toggle.Display>

이번에 Toggle.Display의 자식은 요소가 아닙니다. 함수입니다. Toggle.Display가 호출하고, on을 전달하며, 다시 받은 JSX를 반환합니다. 이제 div는 매번 토글에서도 존재하는 한 요소이고, 클래스만 변경되므로 transition이 재생됩니다. 이것이 렌더 프롭 패턴입니다. 값이 함수인 프롭으로, 컴포넌트가 무엇을 렌더링할지 알기 위해 호출합니다. 일부 API는 render라는 문자 그대로의 프롭 이름을 사용했으며, 이것이 패턴 이름의 유래입니다. 함수를 children으로 전달하는 형태가 널리 퍼졌습니다.

렌더 프롭은 컴포넌트와 호출자 사이의 상태 공유를 해결합니다. 커스텀 훅은 순수 함수 사이의 상태 공유를 해결하며, 훅이 나온 후 생태계는 대부분의 렌더 프롭 API를 옮겼습니다. useToggle() 훅은 추가 컴포넌트, 트리의 중첩, 함수 자식 간접화 없이 ontoggle을 반환합니다. 이것이 한때 <Downshift> 스타일 렌더 프롭을 배포하던 라이브러리들이 이제 useSelect 스타일 훅을 배포하는 이유입니다.

렌더 프롭은 여전히 제공자가 컴포넌트여야 하는 곳에서 제 역할을 합니다. 노출된 값이 특정 렌더링된 요소와 연결되어 있을 때 (측정, 위치 지정), 또는 라이브러리가 경계 컴포넌트 내부에 무엇이 렌더링되는지를 제한하고 싶을 때, 즉 렌더 오류를 아래에서 잡거나 로딩 상태를 유지하고 그 대신 무엇을 보여줄지를 결정합니다. 패턴을 인식하고, 컴포넌트 경계 자체가 중요할 때 사용하며, 그렇지 않을 때는 훅을 선호합니다.

Toggle.Display의 구조적 참고사항입니다. 렌더 중에 children(on)을 호출한다는 것은 Toggle.Displayon을 토글할 때마다 그 함수를 다시 호출하고 반환 값을 다시 렌더링한다는 뜻입니다. 이것이 목적이자 비용입니다. 렌더 프롭은 구독이고, 함수 본문 안의 모든 것은 구독하는 상태와 함께 리렌더링됩니다. 함수 본문을 작게 유지하고, 그 값에 의존하지 않는 무거운 부분 트리는 외부로 밀어냅니다.

Juno얼굴 없는 동작 헤드리스 컴포넌트는 자신의 것을 보여주지 않으면서 무언가를 하는 컴포넌트입니다. 켜짐과 꺼짐을 추적하는 Toggle이 있고, 당신이 무엇을 감싸는지는 당신이 공급합니다. 그 도우미들은 상태가 켜졌을 때나 꺼졌을 때 당신의 콘텐츠를 렌더링합니다.

그리고 실제 참/거짓 값이 필요할 때, 컴포넌트에 함수를 자식으로 전달합니다. 컴포넌트가 그 함수를 값과 함께 호출하고, 함수가 반환하는 모든 것이 페이지에 나타납니다.

Juno얼굴 없는 동작 스코프된 context 위에 복합 패밀리로 헤드리스 컴포넌트를 만듭니다. 상태를 소유하는 부모, 래핑된 클릭 핸들러를 통해 뒤집는 Button 조각, 자식을 조건부로 렌더링하는 On과 Off 조각이 있습니다. onToggle 같은 이벤트 프롭으로 변경을 노출하고, 아무것도 하지 않는 함수로 기본값을 설정하며, 첫 렌더링 ref 가드와 함께 effect에서 실행합니다.

호출자가 원시 상태가 필요할 때, children을 함수로 상태와 함께 호출하는 Display 조각을 제공합니다. 이것이 렌더 프롭 패턴입니다.

Juno얼굴 없는 동작 헤드리스 컴포넌트는 동작을 마크업으로부터 분리하므로 한 로직이 시각적으로 관련 없는 위젯들을 제공합니다. On과 Off 같은 조건부 렌더 조각들은 자식을 리마운트하므로, 연속성, CSS transition을 포함해서 필요한 무엇이든 상태 노출을 통해, 렌더 프롭을 통해 필요합니다.

렌더 프롭을 JSX를 사용한 제어의 역전으로, 리렌더링 비용이 있는 구독으로 취급하고, 같은 일에는 컴포넌트 경계 자체가 의미를 갖지 않으면 커스텀 훅을 선호합니다.

다음: 실전 커스텀 훅. 같은 로직이 함수로 옮겨가고 컴포넌트는 사라집니다.