전체 글 보기

Render Props 패턴으로 로직과 UI 분리하기

profile icon

React의 Render Props 패턴이란 어떤 것이고 언제, 어떻게 사용하면 좋을까? Hooks을 사용하는 것과는 어떤 차이점이 있을까? 예제와 함께 알아보자.

#React#디자인패턴
마지막 수정일:

React에서는 HOC, Custom Hook 등 컴포넌트와 데이터, 로직을 재사용하기 위한 여러가지 기법이 있다. 이번에는 그 중에서 render props 패턴이란걸 알아보자.

아주 간단하게, Render Props는 컴포넌트의 prop으로 JSX를 반환하는 함수를 전달하여, 무엇을 렌더링할지를 부모(외부)에게 위임하는 패턴이다. 간단한 예시로 보면 다음과 같다. (참고로 props이름이 꼭 render일 필요는 없다.)

ts
// (1) 껍데기를 제공하는 컴포넌트
const Box = ({ render }) => {
  return (
    <div style={{ border: '2px solid blue', padding: '20px', borderRadius: '8px' }}>
      {render()}
    </div>
  );
};

// (2) 사용부: "나는 텍스트를 넣을래"
<Box render={() => <span>간단한 텍스트입니다.</span>} />

// (3) 사용부: "나는 이미지를 넣을래"
<Box render={() => <img src="logo.png" alt="로고" />} />

이 처럼 함수를 JSX를 반환하는 props로 받아 호출하는 패턴이다. 너무 간단해서 아직까지는 이 패턴이 왜 유용한지 어떻게 쓸 수 있는지 잘 모르겠다.

조금 더 발전 시켜보자. 예를 들어 자식 컴포넌트에서 특정 데이터를 관리하고 있지만, 부모에 따라서 그 데이터를 다르게 보여줄 필요가 있을 수 있다.

ts
// (1) 데이터를 가지고 있는 컴포넌트
const UserProvider = ({ render }) => {
  const user = { name: "Sanghyeon", role: "Developer" };

  // 가진 데이터를 render 함수에 실어서 보냄
  return <div>{render(user)}</div>;
};

// (2) 사용부 A: 이름만 크게 보고 싶을 때
<UserProvider render={(user) => <h1>{user.name}</h1>} />

// (3) 사용부 B: 역할과 이름을 같이 보고 싶을 때
<UserProvider render={(user) => <p>{user.name} ({user.role})</p>} />

이러한 방식으로 우리는 컴포넌트가 가진 데이터를 외부(부모)에서 자유롭게 다루며 렌더링할 수 있다.

위 예시에서는 render 라는 이름의 prop을 사용했지만, children prop을 활용하는 것이 더 일반적이며, 이 방식을 사용하면 일반적인 컴포넌트처럼 자식을 감싸는 형태가 되어 가독성이 좋아진다.

ts
// (1) render prop 대신 children을 함수로 실행
const UserProvider = ({ children }) => {
  const user = { name: "Sanghyeon", role: "Developer" };

  return <div>{children(user)}</div>;
};

// (2) 사용부: 훨씬 직관적인 구조가 된다.
<UserProvider>
  {(user) => (
    <div>
      <h1>{user.name}</h1>
      <p>{user.role}</p>
    </div>
  )}
</UserProvider>

하지만 지금의 코드도, hook으로 충분히 대체할 수 있으며 단순히 데이터만 다루게 된다면 hook을 사용하는게 더 깔끔하고 단순할 수 있다.

ts
// hook으로 전환한 코드
const useUser = () => {
  const user = { name: "Sanghyeon", role: "Developer" };
  return user;
};

// (1) 사용부 A: 이름만 크게
function LargeName() {
  const user = useUser();
  return <h1>{user.name}</h1>;
}

// (2) 사용부 B: 역할과 이름
function NameAndRole() {
  const user = useUser();
  return <p>{user.name} ({user.role})</p>;
}

요즘의 리액트 개발에서는 많은 Render Props 패턴이 Hook으로 대체되었다. 하지만 Hook이 해결하기 어려운 영역이 있다. 바로 "로직에 특정 DOM 구조가 결합되어야 할 때" 이다.

무한스크롤을 해보자. 무한스크롤을 구현하기 위한 다양한 방법이 있겠지만, IntersectionObserver와 스크롤의 끝을 감지하는 더미 컴포넌트를 이용한 방법으로 구현한다고 해보자.

ts
function PostListContainer() {
  const { items, fetchNextPage, hasMore, isLoading } = useInfiniteScrollHook();
  const observerRef = useRef(null); // 사용자가 직접 관리

  return (
    <div>
      <PostList items={items} />

      // (1) 불편함: 데이터(isLoading)에 따른 UI 처리를 여기서 매번 직접 해야 함
      {isLoading && <MyCustomSpinner />}

      // (2) 불편함: 로직을 위한 '더미 div'를 잊지 말고 직접 넣어야 함
      {hasMore && <div ref={observerRef} style={{ height: '20px' }} />}
    </div>
  );
}

Hook은 로직만 제공할 뿐, ref와 DOM연결은 개발자가 직접 다시 해야한다. 이러한 로직이 반복된다면, 꽤 불편한 작업이 될 것이다.

Render Props 방식으로 수정
ts
// (1) Render Props 컴포넌트
function InfiniteScroll({ onLoadMore, hasMore, children }) {
  const [isLoading, setIsLoading] = useState(false);
  const observerRef = useRef(null);

  useEffect(() => {
    if (!hasMore || isLoading) return;

    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        setIsLoading(true);
        onLoadMore().finally(() => setIsLoading(false));
      }
    });

    if (observerRef.current) observer.observe(observerRef.current);
    return () => observer.disconnect();
  }, [hasMore, onLoadMore, isLoading]);

  return (
    <div>
      // 자식 함수에 현재 로딩 상태를 넘겨준다
      {children(isLoading)}

      // 필수 더미 요소는 컴포넌트가 책임진다
      {hasMore && <div ref={observerRef} style={{ height: '20px' }} />}
    </div>
  );
}

// (2) 사용부: 내부 상태인 isLoading을 받아서 UI를 결정한다
<InfiniteScroll hasMore={hasMore} onLoadMore={fetchNextPage}>
  {(isLoading) => (
    <>
      <PostList items={items} />
      // 로딩 바를 리스트 바로 밑에 둘지, 우측 상단에 작게 띄울지 부모가 결정한다
      {isLoading && <MyCustomSpinner />}
    </>
  )}
</InfiniteScroll>

Render Props 패턴을 이용하면 로직과 필수 DOM 구조를 한 세트로 제공할 수 있다. 사용자는 observerRef를 신경쓸 필요가 없어지고, isLoading 을 children prop으로 넘겨서 외부에서 어떻게 표시할지 컨트롤할 수 있게 되었다.

장점
단점

단순히 데이터나 로직을 재활용하는 용으로는 Hook으로도 충분하다. 하지만 로직과 DOM 구조까지 캡슐화 하고 싶다면 Render Props 패턴을 한 번 사용해보자.

그럼에도 아직 어떻게 활용할지 모르겠다면 Render Props 패턴을 활용한 라이브러리 모음이 있으니 여기서 영감을 받아보자!


참조
전체 글 보기