Overriding component structure

Overriding component structure (컴포넌트 구조 오버라이딩)

Material UI 컴포넌트의 기본 DOM 구조를 오버라이드하는 방법을 배워봐요.

출처: 문서

본문

Material UI 컴포넌트는 가능한 가장 넓은 범위의 사용 사례에 맞게 설계되었지만, 가끔은 컴포넌트의 구조가 DOM에서 어떻게 렌더링되는지 바꿔야 할 때가 있어요.

이를 이해하려면 API 설계가 시간이 지나면서 어떻게 진화해 왔는지 조금 알아 두는 게 도움이 되고, 컴포넌트 자체에 대한 정확한 멘탈 모델(mental model)을 갖는 것도 중요해요.

배경 (Context)

Material UI v6 이전에는 라이브러리의 대부분 컴포넌트 구조를 오버라이드하는 것이 불가능했어요. 일부 컴포넌트에는 특정 슬롯에 props를 전달할 수 있게 해주는 *Props props가 있었지만, 이 패턴이 일관되게 적용되지는 않았어요.

v6에서 그 props들은 slots와 slotProps props로 대체(deprecated)되었어요. 이 props들은 컴포넌트 구조를 더 세밀하게 제어할 수 있게 해주고, 라이브러리 전체에서 API를 더 일관되게 만들어 줘요.

멘탈 모델

컴포넌트의 구조는 그 컴포넌트의 슬롯(slots) 을 채우는 요소들에 의해 결정돼요. 슬롯은 대부분 HTML 태그로 채워지지만, React 컴포넌트로 채워질 수도 있어요.

모든 컴포넌트에는 DOM 트리에서 기본 노드를 정의하는 root 슬롯이 있고, 더 복잡한 컴포넌트에는 그들이 나타내는 요소 이름을 딴 추가적인 내부 슬롯들도 있어요.

:::info 컴포넌트에서 사용할 수 있는 슬롯을 보려면 해당 컴포넌트 API 문서의 slots 섹션을 참고하세요. :::

모든 비-유틸리티(non-utility) Material UI 컴포넌트는 렌더링되는 HTML 구조를 오버라이드하는 두 가지 props를 받아요:

  • component — root 슬롯을 오버라이드할 때
  • slots — (있는 경우) 내부 슬롯뿐만 아니라 root까지 교체할 때

추가로, slotProps를 사용해 내부 슬롯에 커스텀 props를 전달할 수 있어요.

루트 슬롯 (The root slot)

root 슬롯은 컴포넌트의 최외곽 요소를 나타내요. 적절한 HTML 요소를 가진 스타일드 컴포넌트로 채워져요.

예를 들어, Button's root 슬롯은 <button> 요소예요. 이 컴포넌트는 root 슬롯 만 가지고 있어요. 더 복잡한 컴포넌트는 추가적인 내부 슬롯을 가질 수 있어요.

component prop

component prop을 사용해 컴포넌트의 root 슬롯을 오버라이드해요. 아래 데모는 Button의 <button> 태그를 <a>로 교체해서 링크 버튼을 만드는 방법을 보여줘요:

import Button from '@mui/material/Button';

export default function OverridingRootSlot() {
  return (
    <Button component="a" href="https://mui.com/about/" target="_blank">
      About us
    </Button>
  );
}

:::info href, target, rel props는 <a> 태그에만 해당돼요. component prop을 사용할 때는 삽입하려는 요소에 맞는 적절한 속성들을 꼭 추가해 주세요. :::

내부 슬롯 (Interior slots)

복잡한 컴포넌트는 root 외에도 하나 이상의 내부 슬롯으로 구성돼요. 이 슬롯들은 흔히(항상은 아니지만) root 안에 중첩돼 있어요.

예를 들어, Autocomplete는 root <div>로 구성되고, 그 안에 input, startDecorator, endDecorator, clearIndicator, popupIndicator 등이 나타내는 요소 이름을 딴 여러 내부 슬롯이 들어 있어요.

slots prop

slots prop을 사용해 컴포넌트의 내부 슬롯을 교체해요. 아래 예시는 Autocomplete 컴포넌트에서 popper 슬롯을 교체해 팝업 기능을 제거하는 방법을 보여줘요:

import Box from '@mui/material/Box';
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';

interface PopperComponentProps {
  anchorEl?: any;
  disablePortal?: boolean;
  open: boolean;
}

function PopperComponent(props: PopperComponentProps) {
  const { disablePortal, anchorEl, open, ...other } = props;
  return <div {...other} />;
}

export default function OverridingInternalSlot() {
  return (
    <Box
      sx={{ display: 'flex', flexDirection: 'column', width: 320, minHeight: 220 }}
    >
      <Autocomplete
        open
        options={['🆘 Need help', '✨ Improvement', '🚀 New feature', '🐛 Bug fix']}
        renderInput={(params) => <TextField {...params} />}
        slots={{
          popper: PopperComponent,
        }}
      />
    </Box>
  );
}

slotProps prop

slotProps prop은 컴포넌트 내 모든 슬롯에 대한 props를 담은 객체예요. 컴포넌트의 내부 슬롯에 전달할 추가적인 커스텀 props를 정의하는 데 사용할 수 있어요.

예를 들어, 아래 코드 스니펫은 Autocomplete 컴포넌트의 popper 슬롯에 커스텀 data-testid를 추가하는 방법을 보여줘요:

<Autocomplete slotProps={{ popper: { 'data-testid': 'my-popper' } }} />

각 슬롯 prop은 컴포넌트의 ownerState를 받아 해당 슬롯의 props를 반환하는 콜백일 수도 있어요. 슬롯 props가 컴포넌트의 props나 내부 상태에 의존해야 할 때 이 방법을 사용해요.

<Popover
  open={open}
  slotProps={{
    paper: (ownerState) => ({
      elevation: ownerState.open ? 8 : 0,
    }),
  }}
/>

기본 컴포넌트에 놓인 모든 추가 props는 (마치 slotProps.root에 놓인 것처럼) root 슬롯으로도 전파돼요. 이 두 예시는 동등해요:

<Badge id="badge1">
<Badge slotProps={{ root: { id: 'badge1' } }}>

:::warning slotProps.root와 추가 props가 같은 키에 다른 값을 가진다면, slotProps.root의 props가 우선해요. 이것은 classes나 style prop에는 적용되지 않아요 — 그것들은 병합(merge)돼요. :::

타입 안전성 (Type safety)

slotProps prop은 커스텀 slots prop에 기반해 동적으로 타입이 지정되지 않아요. 그래서 커스텀 슬롯이 기본 슬롯과 다른 타입이라면, TypeScript 오류를 피하기 위해 타입을 캐스팅하고 satisfies(TypeScript 4.9에서 사용 가능)를 사용해 커스텀 슬롯의 타입 안전성을 보장해야 해요.

아래 예시는 Next.js Image 컴포넌트를 사용해 Avatar 컴포넌트의 img 슬롯을 커스터마이즈하는 방법을 보여줘요:

import Image, { ImageProps } from 'next/image';
import Avatar, { AvatarProps } from '@mui/material/Avatar';

<Avatar
  slots={{
    img: Image,
  }}
  slotProps={
    {
      img: {
        src: 'https://example.com/image.jpg',
        alt: 'Image',
        width: 40,
        height: 40,
        blurDataURL: 'data:image/png;base64',
      } satisfies ImageProps,
    } as AvatarProps['slotProps']
  }
/>;

모범 사례 (Best practices)

슬롯의 스타일을 유지하면서 요소만 오버라이드해야 할 때는 component 또는 slotProps.{slot}.component prop을 사용해요.

슬롯의 스타일과 기능을 커스텀 컴포넌트로 교체해야 할 때는 slots prop을 사용해요.

component로 오버라이드하면 해당 요소의 속성을 root에 직접 적용할 수 있어요. 예를 들어 Button의 root를 <li> 태그로 오버라이드하면, <li> 속성인 value를 컴포넌트에 직접 추가할 수 있어요. slots.root로 같은 작업을 한다면, TypeScript 오류를 피하기 위해 이 속성을 slotProps.root 객체에 배치해야 해요.

더 복잡한 컴포넌트의 슬롯을 오버라이드할 때는 렌더링되는 DOM 구조에 주의해야 해요. 기본 구조에서 너무 많이 벗어나면 시맨틱하고 접근 가능한 HTML의 규칙을 쉽게 깨뜨릴 수 있어요 — 예를 들어 의도치 않게 인라인 요소 안에 블록 레벨 요소를 중첩하는 경우처럼요.

더 알아보기 (Learn more)