본문 바로가기

FrontEnd

React의 cloneElement API, 기존 엘리먼트를 기반으로 새로운 엘리먼트 생성하는 방법 알아보기

반응형

React Beta 공식문서를 통해 학습한 cloneElement API에 대해 정리합니다.

cloneElement

cloneElement를 사용하면 다른 엘리먼트를 기반으로 새로운 React 엘리먼트를 만들 수 있습니다.

const clonedElement = cloneElement(element, props, ...children)
특정 엘리먼트를 기반으로, 전혀 다른 prop과 children을 이용해 새로운 엘리먼트를 만듭니다.
import { cloneElement } from 'react';

// ...
const clonedElement = cloneElement(
  <Row title="Cabbage">
    Hello
  </Row>,
  { isHighlighted: true },
  'Goodbye'
);

console.log(clonedElement); // <Row title="Cabbage">Goodbye</Row>

Parameters

  • element : 엘리먼트 인수는 유효한 React 엘리먼트여야 합니다.
    • 예를 들어 <Something />과 같은 JSX 노드일 수 있습니다.
    • 해당 노드는 createElement 혹은 다른 cloneElement의 호출 결과일 수 있습니다.
  • props : props 인수는 객체이거나 null이어야 합니다.
    • null을 전달하면 복제된 요소는 원래 element.props를 모두 유지합니다.
      • null이 아닌 객체를 전달하면, 복사된 요소는 기존 요소의 element.props의 값보다 인수 props의 값을 "선호"합니다.
    • 나머지 prop은 원래 element.props에서 채워집니다.
    • props.key 또는 props.ref를 인자로 전달하면 원래 항목을 대체합니다.
  • (선택 사항): ..children: 0개 이상의 자식 노드입니다.
    • React 엘리먼트, 문자열, 숫자, portals, 빈 노드(null, undefined, true 및 false) 및 React 노드 배열을 포함한 모든 React 노드가 될 수 있습니다.
    • ...children 인수를 전달하지 않으면 원래 element.props.children이 보존됩니다.

Returns

cloneElement는 몇 가지 속성이 있는 React 요소 객체를 반환합니다.
  • type : 리액트 요소의 타입(element.type)
  • props : 리액트 요소의 props(element.props), 당신이 전달한 prop과 기존 prop의 얕은 병합 결과입니다.
  • ref : 복사 대상 리액트 요소의 element.ref, props.ref로 덮어 쓸 수 있습니다.
  • key : 복사 대상 리액트 요소의 element.key, props.key로 덮어 쓸 수 있습니다.

일반적으로 컴포넌트를 사용해 요소를 반환하거나 다른 요소의 자식으로 만듭니다.
요소의 속성을 읽을 수 있지만 요소가 생성된 후에는 불투명한 객체처럼 생각하는게 좋습니다.

  • 내부를 들여다보기보단 렌더링에만 쓰는게 좋습니다.

주의사항(Caveats)

  • 요소를 복제해도 원래 요소는 수정되지 않습니다.
  • cloneElement(element, null, child1, child2, child3)와 같이 자식이 모두 정적으로 알려진 경우에만 cloneElement에 여러 인수로 자식을 전달해야 합니다.
    • 자식 갯수가 동적이면 전체 배열을 세 번째 인수인 cloneElement(element, null, listItems)로 전달합니다
    • 이렇게 하면 React가 동적 배열의 누락된 키에 대해 경고합니다.(warn you about missing keys)
  • 정적 목록의 경우 재정렬되지 않기 때문에 key가 필요하지 않습니다.
  • cloneElement를 사용하면 데이터 흐름을 추적하기가 더 어려워지므로 다른 대안(alternatives)을 사용해 보세요

 

사용 사례

요소의 props 덮어쓰기

import { cloneElement } from 'react';

// ...
const clonedElement = cloneElement(
  <Row title="Cabbage" />, // prop을 덮어 쓸 요소
  { isHighlighted: true } // 덮어 쓸 prop
);
복제 결과 요소는 <Row title="Cabbage" isHighlighted={true} />입니다.
위와 같은 사용 사례가 쓸모 있는 경우를 살펴보갰습니다.

next 버튼을 클릭해 선택한 행을 변경하는 리스트 컴포넌트를 생각해봅시다.
List 컴포넌트는 선택한 행을 다르게 렌더링해야 하므로 수신한 모든 <Row> 자식을 복사합니다.
isHighlighted: true 또는 isHighlighted: false prop을 추가합니다.

export default function List({ children }) {
  const [selectedIndex, setSelectedIndex] = useState(0);
  return (
    <div className="List">
      {Children.map(children, (child, index) =>
        cloneElement(child, {
          isHighlighted: index === selectedIndex 
        })
      )}
List가 children으로 전달받은 원본 JSX가 다음과 같다고 가정해 보겠습니다.
<List>
  <Row title="Cabbage" />
  <Row title="Garlic" />
  <Row title="Apple" />
</List>
자식을 복제함으로써 List는 내부의 모든 Row에 추가 정보를 전달할 수 있습니다. 결과는 다음과 같습니다.
<List>
  <Row
    title="Cabbage"
    isHighlighted={true} 
  />
  <Row
    title="Garlic"
    isHighlighted={false} 
  />
  <Row
    title="Apple"
    isHighlighted={false} 
  />
</List>
요약하면 List는 받은 <Row /> 요소를 복제하고 추가 prop을 추가했습니다.

대안

cloneElement와 Children API는 데이터 흐름 추적을 어렵게 만들기 때문에, 대안을 사용하는 것이 좋습니다.

render props로 데이터 전달하기

cloneElement를 사용하는 대신 renderItem과 같은 render prop을 사용할 수 있습니다.
ListrenderItem을 prop으로 받습니다.
List는 모든 item 배열의 데이터에 대해 renderItem을 호출하고 isHighlighted를 인수로 전달합니다.

export default function List({ items, renderItem }) {
  const [selectedIndex, setSelectedIndex] = useState(0);
  return (
    <div className="List">
      {items.map((item, index) => {
        const isHighlighted = index === selectedIndex;
        return renderItem(item, isHighlighted);
      })}

renderItem prop은 컴포넌트를 렌더링하는 방법(how)에 관한 prop이기 때문에 “render prop”이라고 합니다.
예를 들어 주어진 isHighlighted 값으로 <Row>를 렌더링하는 renderItem 구현을 전달할 수 있습니다.

<List
  items={products}
  renderItem={(product, isHighlighted) =>
    <Row
      key={product.id}
      title={product.title}
      isHighlighted={isHighlighted}
    />
  }
/>
최종 결과는 cloneElement와 동일합니다.
<List>
  <Row
    title="Cabbage"
    isHighlighted={true} 
  />
  <Row
    title="Garlic"
    isHighlighted={false} 
  />
  <Row
    title="Apple"
    isHighlighted={false} 
  />
</List>
하지만 isHighlighted 값의 출처를 명확하게 추적할 수 있습니다.
이 패턴은 더 명시적이므로 cloneElement보다 선호됩니다.
일반적으로 renderProp는 컴포넌트 트리 상단에서 사용할 경우 컴포넌트 합성을 힘들게 하며,
중첩할 경우 코드 보기가 힘들어져서, 컴파운드 컴포넌트 패턴을 선호합니다.

context로 데이터 전달하기

cloneElement 사용의 다른 대안은 데이터를 context로 넘기는 것입니다.(pass data through context.)

예를 들어 다음과 같이 createContext를 호출하여 HighlightContext를 정의할 수 있습니다.
export const HighlightContext = createContext(false);
List 컴포넌트는 렌더링하는 모든 아이템을 HighlightContext Provider로 감쌀 수 있습니다.
주 : 컨텍스트가 List 컴포넌트에 하나가 아니라 여러개가 됩니다.
그리고 그 컨텍스트에 값이 selectedIndex가 있을 수도 있고 없을 수도 있습니다.
export default function List({ items, renderItem }) {
  const [selectedIndex, setSelectedIndex] = useState(0);
  return (
    <div className="List">
      {items.map((item, index) => {
        const isHighlighted = index === selectedIndex;
        return (
          <HighlightContext.Provider key={item.id} value={isHighlighted}>
            {renderItem(item)}
          </HighlightContext.Provider>
        );
      })}

이 접근 방식을 사용하면 Row는 isHighlighted prop이 전혀 필요 없습니다.
대신 컨텍스트에서 해당 값을 가져옵니다.

export default function Row({ title }) {
  const isHighlighted = useContext(HighlightContext);
  // ...
이렇게 하면 호출 컴포넌트(List)가 isHighlighted를 <Row>에 전달하는 것을 신경 쓸 필요가 없습니다.
<List
  items={products}
  renderItem={product =>
    <Row title={product.title} />
  }
/>

대신 List와 Row는 컨텍스트를 통해 강조 표시 논리를 조정합니다.(컨텍스트를 통핸 로직 캡슐화)
해당 패턴은 컴포넌트 내에 상태 관리 로직을 캡슐화하고 싶을 때 좋습니다.

커스텀 훅으로 로직 추출하기

"비시각적" 로직(ui와 무관한)을 자신의 Hook으로 추출하고 Hook에서 반환된 정보를 사용하여 무엇을 렌더링할지 결정하는 것입니다.
예를 들어 다음과 같이 useList 커스텀 훅을 작성할 수 있습니다.

import { useState } from 'react';

export default function useList(items) {
  const [selectedIndex, setSelectedIndex] = useState(0);

  function onNext() {
    setSelectedIndex(i =>
      (i + 1) % items.length
    );
  }

  const selected = items[selectedIndex];
  return [selected, onNext];
}
그러면 다음과 같이 사용할 수 있습니다.
export default function App() {
  const [selected, onNext] = useList(products);
  return (
    <div className="List">
      {products.map(product =>
        <Row
          key={product.id}
          title={product.title}
          isHighlighted={selected === product}
        />
      )}
      <hr />
      <button onClick={onNext}>
        Next
      </button>
    </div>
  );
}

데이터 흐름이 명시적이며,
상태는 모든 컴포넌트에서 사용할 수 있는 useList 커스텀 훅 내부에 있습니다.

해당 패턴은 여러 컴포넌트에서 특정 반복되는 로직을 재사용할 때 사용하면 좋습니다.

 

반응형