Javascript

React의 Render Props 패턴을 활용한 컴포넌트 디자인

드리프트2 2024. 11. 29. 21:31

React의 Render Props 패턴을 활용한 컴포넌트 디자인

React 컴포넌트 디자인 패턴 중 하나인 Render Props 패턴에 대해 알아보겠습니다.

 

공통 부분을 자식 컴포넌트에서 횡단적으로 사용하는 기법으로 소개되고 있지만, 익숙하지 않은 분들이 많을 것 같습니다.

 

헤드리스 UI 라이브러리인 React Aria를 접하면서 Render Props가 활용되고 있는 것을 보고, 이를 기반으로 정리하였습니다.

 

Render Props 란

Render Props로 구현된 컴포넌트는 React Element를 반환하는 함수를 prop으로 가집니다.

const ComponentWithRenderProp = ({ render }) => {
  return render();
};

// 호출 부분
<ComponentWithRenderProp render={() => <div>Render Prop</div>} />

 

실제로는 단순한 함수이므로, 인자를 전달하거나, prop을 새로 생성하는 것이 아니라, children을 사용할 수도 있습니다.

export const ComponentWithRenderProp = ({ children }) => {
  const id = useId();
  return children(id);
};

// 호출 부분
<ComponentWithRenderProp>
  {(id) => <div>{`id: ${id}`}</div>}
</ComponentWithRenderProp>

 

React Aria에서의 Render Props

 

다음으로 버튼을 예로 들어, React Aria의 코드를 살펴보겠습니다.

 

React Aria는 헤드리스 UI이므로, 버튼을 만드는 데 필요한 "행동"을 제공해 줍니다.

 

버튼이라고 하면, 호버나 포커스가 걸리는 경우 "모양"을 바꾸는 것을 생각할 수 있는데, Render Props는 이러한 상태에 접근하는 수단으로 제공되고 있으며, 모양은 상태를 사용하여 자유롭게 변경할 수 있습니다.

 

React Aria 컴포넌트는 기본적으로 스타일을 포함하지 않습니다.

 

TypeScript의 타입 정의를 보면 다음과 같이 되어 있으며, ButtonRenderProps로 정의된 상태를 Render prop인 children의 인자로 받을 수 있음을 알 수 있습니다.

// 버튼의 Props: `RenderProps<ButtonRenderProps>`를 상속하고 있음
export interface ButtonProps extends Omit<AriaButtonProps, 'children' | 'href' | 'target' | 'rel' | 'elementType'>, HoverEvents, SlotProps, RenderProps<ButtonRenderProps> {
    form?: string;
    // ...
}

// Render Props: `children`의 타입 정의를 덮어써서, 제네릭 타입 (T)를 받고, ReactNode를 반환하는 함수로 함
interface RenderProps<T> extends StyleRenderProps<T> {
    children?: ReactNode | ((values: T & {
        defaultChildren: ReactNode | undefined;
    }) => ReactNode);
}

// 버튼의 Render Props: 버튼의 상태가 정의되어 있음
export interface ButtonRenderProps {
    isHovered: boolean;
    isPressed: boolean;
    // ...
}

 

버튼 사용해 보기

 

타입 정의에서 사용법을 읽어낼 수 있으므로, 호버 시 표시 텍스트가 바뀌는 버튼을 만들어 보겠습니다.

HoverButton.tsx
import type { ComponentPropsWithRef } from "react";
import { Button } from "react-aria-components";

type Props = ComponentPropsWithRef<typeof Button>;

export const HoverButton = (props: Props) => (
  <Button {...props}>
    {({ isHovered }) => (isHovered ? "호버 중 🐁" : "호버해 주세요")}
  </Button>
);

 

범용적인 컴포넌트를 제공하는 경우, 유스케이스가 무한정 존재하게 됩니다.

 

따라서, 공통 부분이 되는 행동에는 Render Props를 통해 접근할 수 있는 것은 좋은 설계라고 생각했습니다.

 

체크박스 그룹 만들어 보기

마지막으로 Render Props를 사용하여, 체크박스 그룹을 만들어 보겠습니다.

CheckboxGroup.tsx
import { type ReactNode, useState } from "react";

type CheckboxGroupRenderProps = {
  selectedValues: string[];
  setValue: (value: string[]) => void;
};

type CheckboxGroupProps = {
  children: ReactNode | ((values: CheckboxGroupRenderProps) => ReactNode);
};

export const CheckboxGroup = ({ children }: CheckboxGroupProps) => {
  const [selectedValues, setSelectedValues] = useState<string[]>([]);

  // `children`이 Render Prop일 경우, 상태와 세터 함수를 인자로 전달
  if (typeof children === "function") {
    return children({ selectedValues, setValue: setSelectedValues });
  }

  return children;
};

type CheckboxProps = CheckboxGroupRenderProps & {
  label: string;
  value: string;
};

export const Checkbox = ({
  selectedValues,
  setValue,
  label,
  value,
}: CheckboxProps) => {
  const handleClick = () => {
    const isSelected = selectedValues.includes(value);
    if (isSelected) {
      setValue(selectedValues.filter((v) => v !== value));
    } else {
      setValue([...selectedValues, value]);
    }
  };

  return (
    <label>
      <span>{label}</span>
      <input
        type="checkbox"
        checked={selectedValues.includes(value)}
        onClick={handleClick}
      />
    </label>
  );
};
Home.tsx
import { Checkbox, CheckboxGroup } from "@/components/checkbox-group";

export default function Home() {
  return (
    <CheckboxGroup>
      {({ selectedValues, setValue }) => (
        <>
          <Checkbox
            label="🍎"
            value="apple"
            selectedValues={selectedValues}
            setValue={setValue}
          />
          <Checkbox
            label="🍌"
            value="banana"
            selectedValues={selectedValues}
            setValue={setValue}
          />
          <Checkbox
            label="🍇"
            value="grape"
            selectedValues={selectedValues}
            setValue={setValue}
          />
          <div>
            <div>선택된 값:</div>
            <ul>
              {selectedValues.map((value) => (
                <li key={value}>{value}</li>
              ))}
            </ul>
          </div>
        </>
      )}
    </CheckboxGroup>
  );
}

 

결론

 

이번에 Render Props 패턴을 학습했습니다.

 

React Aria뿐만 아니라, 라이브러리의 API로 자주 보이는 것 같으므로, 메커니즘을 이해하고 활용할 수 있도록 하고 싶습니다.