# 합성 컴포넌트 패턴에서 컨텍스트 Api

## 합성 컴포넌트

합성 컴포넌트 패턴은 UI를 작성할 때 여러 개로 분리된 컴포넌트를 조합하여 컴포넌트를 개발하는 패턴이다. 컴포넌트의 조합을 활용함으로써 다양한 상황에서 컴포넌트를 도입할 수 있다.

해당 패턴으로 개발한 헤더 컴포넌트의 예시이다. 컴포넌트는 왼쪽, 가운데, 오른쪽 총 세 가지 구역으로 나뉘어져 있다.

`Children.toArray` 메소드로 하위 컴포넌트를 배열 타입으로 변환한 다음 각 컴포넌트의 타입을 비교함으로써 헤더 내 어디에 배치시켜야 하는지 필터링할 수 있다.

Header 컴포넌트는 HeaderContainer 컴포넌트임과 동시에 `Object.assign` 메소드로 타 컴포넌트들도 속성으로 추가할 수 있다.

```tsx
import { Children, PropsWithChildren } from "react";

interface Props extends PropsWithChildren {
  className?: string;
}

const HeaderCenter = (props: Props) => {
  const { children } = props;

  return children;
};

const HeaderLeft = (props: Props) => {
  const { children } = props;

  return children;
};

const HeaderRight = (props: Props) => {
  const { children } = props;

  return children;
};

export interface HeaderContainerProps extends PropsWithChildren {
  className?: string;
}

const HeaderContainer = (props: HeaderContainerProps) => {
  const components = Children.toArray(props.children);
  const lefts = components.filter(
    (comp) => (comp as JSX.Element).type === (<HeaderLeft />).type
  );
  const centers = components.filter(
    (comp) => (comp as JSX.Element).type === (<HeaderCenter />).type
  );
  const rights = components.filter(
    (comp) => (comp as JSX.Element).type === (<HeaderRight />).type
  );

  return (
    <header className="w-full flex items-center justify-between gap-x-1">
      <div className="left">{lefts}</div>
      <div className="center">{centers}</div>
      <div className="right">{rights}</div>
    </header>
  );
};

const Header = Object.assign(HeaderContainer, {
  Left: HeaderLeft,
  Center: HeaderCenter,
  Right: HeaderRight,
});
```

작성한 헤더 컴포넌트는 다음과 같이 불러올 수 있다.

앞서 작성한, `HeaderLeft, HeaderCenter, HeaderRight` 컴포넌트는 `Header.Left, Header.Center, Header.Right` 로 호출할 수 있다.

합성 컴포넌트로써 다음과 같은 요구사항에 대응할 수 있다.

* 만약 좌측에 있는 아이콘을 바꿔야 한다면 컴포넌트 내부를 수정하지 않고 아이콘 컴포넌트만 바꿔주면 된다.
    
* 좌측에 아이콘과 텍스트를 같이 표시해야 한다면 텍스트 표시용 `Header.Left` 를 추가로 호출할 수 있다.
    

```tsx
import Header from "./Header";
import LeftIcon from "./LeftIcon.svg";

const App = () => {
  return (
    <Header>
      <Header.Left>
        <LeftIcon />
      </Header.Left>
      <Header.Left>
        <h3>정보</h3>
      </Header.Left>
      <Header.Center>
        <h2>프로젝트 제목</h2>
      </Header.Center>
      <Header.Right>
        <button>업로드</button>
      </Header.Right>
    </Header>
  );
};
```

여러 개의 컴포넌트로 구성되는 합성 컴포넌트에서는 상태 관리를 위해 리액트 컨텍스트 API를 도입할 수 있다. 컨텍스트 API는 Props 없이 상태를 공유할 수 있게 도와준다.

컨텍스트 API로 작업한 리스트 컴포넌트이다. 선택된 텍스트와 텍스트를 클릭했을 때의 콜백 함수를 컨텍스트를 통해 타 컴포넌트로 전달할 수 있다.

`ListItem` 컴포넌트는 텍스트만을 Props으로 받아서 표시하는 역할을 한다. 동시에 컨텍스트로부터 콜백 함수를 받아서 사용자가 아이템을 클릭했을 때 콜백 함수를 호출할 수 있다.

```tsx
import {
  Children,
  createContext,
  MouseEvent,
  PropsWithChildren,
  useContext,
  useState,
} from "react";

export interface ListContextType {
  text?: string;
  onItemClick?: (event: MouseEvent, text: string) => unknown;
}

const ListContext = createContext<ListContextType>(null!);

const ListHeader = (props: PropsWithChildren) => {
  return <div className="flex items-center">{props.children}</div>;
};

const ListSelected = () => {
  const { text } = useContext(ListContext);

  return (
    <div className="w-full flex items-center justify-center">
      <h2>{text}</h2>
    </div>
  );
};

export interface ListItemProps {
  text?: string;
}

const ListItem = (props: ListItemProps) => {
  const { onItemClick } = useContext(ListContext);
  const { text } = props;
  return (
    <button
      className="w-full flex items-center justify-center"
      onClick={(event) => {
        if (text) {
          onItemClick?.(event, text);
        }
      }}
    >
      <p>{text}</p>
    </button>
  );
};

interface ListContainerProps extends PropsWithChildren {
  onItemClick?: (event: MouseEvent, text: string) => unknown;
}

const ListContainer = (props: ListContainerProps) => {
  const { onItemClick } = props;
  const [selected, setSelected] = useState<string>();

  const components = Children.toArray(props.children);
  const header = components.find(
    (comp) => (comp as JSX.Element).type === (<ListHeader />).type
  );
  const text = components.find(
    (comp) => (comp as JSX.Element).type === (<ListSelected />).type
  );
  const items = components.filter(
    (comp) => (comp as JSX.Element).type === (<ListItem />).type
  );
  return (
    <ListContext.Provider
      value={{
        text: selected,
        onItemClick: (event, text) => {
          setSelected(text);
          onItemClick?.(event, text);
        },
      }}
    >
      <div className="flex flex-col w-full">
        {header}
        {text}
        <div className="flex flex-col w-full gap-y-1">{items}</div>
      </div>
    </ListContext.Provider>
  );
};

const List = Object.assign(ListContainer, {
  Header: ListHeader,
  Selected: ListSelected,
  Item: ListItem,
});

export default List;
```

## 시행착오

합성 컴포넌트를 어떻게 작성할지 학습하기 위해 예제 코드를 읽어봤을 때는 왜 컨텍스트를 한 번만 선언하는지 이해하지 못했던 적이 있었다.

앞서 작성한 리스트 컴포넌트는 여러 컴포넌트에서 재사용할 수 있는데 컨텍스트가 하나만 선언되어있으면 컨텍스트의 값이 계속 덮어씌워지지 않을까 우려했던 적이 있었다.

리액트 공식문서를 보고 무엇을 잘못 알고 있었는지 알 수 있었다. 컨텍스트의 값을 제공하는 역할은 컨텍스트가 아니라 컨텍스트의 프로바이더 컴포넌트(Provider)이고, 프로바이더의 value 속성으로 값을 하위 컴포넌트로 전달할 수 있다.

하위 컴포넌트에서 호출되는 `useContext`는 UI 트리에서 가장 가까운 프로바이더의 값을 사용하게 된다. UI 트리에 프로바이더가 없으면 `createContext` 함수에 전달된 기본값을 사용하게 된다.
