컴포지션

컴포지션 (Composition)

Material UI는 컴포지션을 최대한 쉽게 사용할 수 있도록 설계되었어요. 컴포넌트를 감싸고(wrapping), component prop으로 루트 요소를 바꾸고, ref를 다루는 방법을 함께 살펴볼게요.

출처: 문서

본문

컴포넌트 감싸기 (Wrapping components)

최대한의 유연성과 성능을 제공하려면, Material UI는 컴포넌트가 받는 자식 요소의 성질을 알아야 해요. 이 문제를 해결하기 위해 필요할 때 일부 컴포넌트에 muiName 정적(static) 프로퍼티를 태그합니다.

하지만 컴포넌트를 향상시키기 위해 감싸야 할 수도 있는데, 이는 muiName 솔루션과 충돌할 수 있어요. 컴포넌트를 감싼다면, 그 컴포넌트에 이 정적 프로퍼티가 설정되어 있는지 확인해보세요.

이런 문제가 생기면, 감싸는 컴포넌트에 감싸인 컴포넌트가 쓰는 것과 동일한 태그를 사용해야 해요. 또한 부모 컴포넌트가 감싸진 컴포넌트의 props를 제어해야 할 수 있으므로, props를 전달(forward)해야 해요.

예시를 함께 볼게요:

const WrappedIcon = (props) => <Icon {...props} />;
WrappedIcon.muiName = Icon.muiName;
import IconButton from '@mui/material/IconButton';
import Icon, { IconProps } from '@mui/material/Icon';

function WrappedIcon(props: IconProps) {
  return <Icon {...props} />;
}
WrappedIcon.muiName = 'Icon';

export default function Composition() {
  return (
    <div>
      <IconButton>
        <Icon>alarm</Icon>
      </IconButton>
      <IconButton>
        <WrappedIcon>alarm</WrappedIcon>
      </IconButton>
    </div>
  );
}

슬롯 props 전달하기 (Forwarding slot props)

사용자 지정 props와 슬롯 props를 병합하려면 mergeSlotProps 유틸리티 함수를 사용해요. 인자가 함수라면 병합하기 전에 먼저 실행(resolve)되고, 첫 번째 인자의 결과가 두 번째 인자를 덮어써요.

두 인자 사이에서 특별하게 병합되는 프로퍼티들은 아래와 같아요:

  • className: 값을 서로 덮어쓰는 대신 이어 붙입니다(concatenate).

    아래 스니펫에서 custom-tooltip-popper 클래스가 Tooltip의 popper 슬롯에 적용돼요.

    import Tooltip, { TooltipProps } from '@mui/material/Tooltip';
    import { mergeSlotProps } from '@mui/material/utils';
    
    export const CustomTooltip = (props: TooltipProps) => {
      const { children, title, sx: sxProps } = props;
    
      return (
        <Tooltip
          {...props}
          title={<Box sx={{ p: 4 }}>{title}</Box>}
          slotProps={{
            ...props.slotProps,
            popper: mergeSlotProps(props.slotProps?.popper, {
              className: 'custom-tooltip-popper',
              disablePortal: true,
              placement: 'top',
            }),
          }}
        >
          {children}
        </Tooltip>
      );
    };
    

    아래처럼 Custom Tooltip의 slotProps prop으로 또 다른 className을 추가하면, 렌더링되는 popper 슬롯에 둘 다 들어가게 돼요:

    <CustomTooltip slotProps={{ popper: { className: 'foo' } }} />
    

    원래 예시의 popper 슬롯에는 이제 다른 클래스들에 더해 두 클래스가 모두 적용돼요: "[…] custom-tooltip-popper foo".

  • style: 객체가 서로를 대체하는 대신 얕게 병합(shallow merge)돼요. 첫 번째 인자의 스타일 키가 더 높은 우선순위를 가져요.

  • sx: 값들이 배열로 이어 붙여져요.

  • ^on[A-Z] 이벤트 핸들러: 두 인자 사이에서 이 함수들이 컴포즈(compose)돼요.

    mergeSlotProps(props.slotProps?.popper, {
      onClick: (event) => {}, // composed with the `slotProps?.popper?.onClick`
      createPopper: (popperOptions) => {}, // overridden by the `slotProps?.popper?.createPopper`
    });
    

component prop

Material UI는 component라는 prop을 통해 렌더링될 루트 요소를 바꿀 수 있게 해줘요.

예를 들어 기본적으로 List 컴포넌트는 <ul> 요소를 렌더링해요. 이는 component prop에 React 컴포넌트를 전달해서 바꿀 수 있어요. 아래 예시는 List 컴포넌트를 <menu> 요소를 루트로 사용해 렌더링해요:

<List component="menu">
  <ListItem>
    <ListItemButton>
      <ListItemText primary="Trash" />
    </ListItemButton>
  </ListItem>
  <ListItem>
    <ListItemButton>
      <ListItemText primary="Spam" />
    </ListItemButton>
  </ListItem>
</List>

이 패턴은 매우 강력해서 큰 유연성을 제공하고, 라우팅이나 폼 라이브러리 같은 다른 라이브러리와 상호 운용할 수도 있어요.

다른 React 컴포넌트 전달하기

component prop에는 다른 어떤 React 컴포넌트든 전달할 수 있어요. 예를 들어 react-router의 Link 컴포넌트를 전달할 수 있죠:

import { Link } from 'react-router';
import Button from '@mui/material/Button';

function Demo() {
  return (
    <Button component={Link} to="/react-router">
      React router link
    </Button>
  );
}

TypeScript와 함께 사용하기

component prop을 사용하려면 props의 타입을 타입 인자와 함께 사용해야 해요. 그렇지 않으면 component prop이 존재하지 않게 돼요.

아래 예시는 TypographyProps를 사용하지만, OverrideProps로 정의된 props를 가진 어떤 컴포넌트에서든 동일하게 동작해요.

import { TypographyProps } from '@mui/material/Typography';

function CustomComponent(props: TypographyProps<'a', { component: 'a' }>) {
  /* ... */
}
// ...
<CustomComponent component="a" />;

이제 CustomComponent는 'a'로 설정해야 하는 component prop과 함께 사용할 수 있어요. 또한 CustomComponent는 <a> HTML 요소의 모든 props를 가지게 되죠. Typography 컴포넌트의 다른 props도 CustomComponent의 props에 그대로 존재해요.

Button과 react-router를 함께 쓰는 코드 예시는 이 데모들에서 찾을 수 있어요.

제네릭 (Generic)

React.ElementType을 상속하는 어떤 React 컴포넌트든 받아들이는 제네릭 커스텀 컴포넌트도 만들 수 있어요. 내장 컴포넌트도 포함해서요.

function GenericCustomComponent<C extends React.ElementType>(
  props: TypographyProps<C, { component?: C }>,
) {
  /* ... */
}

GenericCustomComponent를 component prop과 함께 사용하면, 전달된 컴포넌트가 요구하는 모든 props도 함께 가져야 해요.

function ThirdPartyComponent({ prop1 }: { prop1: string }) {
  /* ... */
}
// ...
<GenericCustomComponent component={ThirdPartyComponent} prop1="some value" />;

ThirdPartyComponent가 prop1을 요구하므로, GenericCustomComponent에서도 prop1이 필수가 돼요.

모든 컴포넌트가 전달하는 모든 컴포넌트 타입을 완전히 지원하는 것은 아니에요. TypeScript에서 component prop을 거부하는 컴포넌트를 발견하면 이슈를 열어주세요. component props를 제네릭으로 만들어 이 문제를 해결하려는 노력이 진행 중이에요.

ref 사용 시 주의사항 (Caveat with refs)

이 절에서는 커스텀 컴포넌트를 children 또는 component prop으로 사용할 때의 주의사항을 다룰게요.

일부 컴포넌트는 DOM 노드에 접근해야 해요. 예전에는 ReactDOM.findDOMNode로 가능했죠. 이 함수는 ref와 ref forwarding을 권장하면서 deprecated 되었어요. 하지만 ref를 줄 수 있는 컴포넌트 타입은 다음뿐이에요:

Material UI와 함께 사용할 때 위 타입 중 하나를 쓰지 않으면, 콘솔에서 React의 경고를 보게 될 거예요. 대략 이렇게요:

:::warning Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()? :::

감싸인 컴포넌트가 ref를 가질 수 없다면 lazy와 memo 컴포넌트에서도 여전히 이 경고가 나온다는 점을 기억하세요. 어떤 경우에는 디버깅을 돕기 위해 추가 경고가 발생하기도 해요. 이렇게요:

:::warning Invalid prop component supplied to ComponentName. Expected an element type that can hold a ref. :::

가장 흔한 두 가지 사용 사례만 다뤘어요. 더 자세한 내용은 공식 React 문서의 이 절을 참고하세요.

-const MyButton = () => <div role="button" />;
+const MyButton = React.forwardRef((props, ref) =>
+  <div role="button" {...props} ref={ref} />);

 <Button component={MyButton} />;
-const SomeContent = props => <div {...props}>Hello, World!</div>;
+const SomeContent = React.forwardRef((props, ref) =>
+  <div {...props} ref={ref}>Hello, World!</div>);

 <Tooltip title="Hello again."><SomeContent /></Tooltip>;

사용 중인 Material UI 컴포넌트에 이 요구사항이 있는지 확인하려면 해당 컴포넌트의 props API 문서를 확인해보세요. ref를 전달(forward)해야 한다면 그 설명이 이 절로 연결될 거예요.

StrictMode 사용 시 주의사항 (Caveat with StrictMode)

위에서 설명한 경우에 클래스 컴포넌트를 사용하면 React.StrictMode에서 여전히 경고가 표시돼요. ReactDOM.findDOMNode가 하위 호환성을 위해 내부적으로 사용되기 때문이에요. 클래스 컴포넌트에서 React.forwardRef와 별도의 prop을 사용해 ref를 DOM 컴포넌트로 전달할 수 있어요. 이렇게 하면 ReactDOM.findDOMNode의 deprecation 관련 경고가 더 이상 발생하지 않아요.

 class Component extends React.Component {
   render() {
-    const { props } = this;
+    const { forwardedRef, ...props } = this.props;
     return <div {...props} ref={forwardedRef} />;
   }
 }

-export default Component;
+export default React.forwardRef((props, ref) => <Component {...props} forwardedRef={ref} />);

더 알아보기 (Learn more)