← Dev Log

Render props 패턴을 활용하여 Headless 컴포넌트 구현하기

·6 min read

오늘 한 일

  • GDSC Gachon 1기 core member 면접
  • gloddy 개발
    • 모임 개설 페이지 리팩토링
    • 캘린더 구현

gloddy

render props 패턴 활용하기 (ItemList)

GroupingCard, ArticleItem 등 이런 것들을 리스트로 보여줄 때마다 이렇게 작성하고 있었다.

<Flex direction="column">
  {groupMembers.map((member, index) => (
    <Fragment key={member.userId}>
      <MemberItem member={member} />
      {groupMembers.length - 1 !== index && <Divider />}
    </Fragment>
  ))}
</Flex>

groupMember가 아니더라도 Article, Grouping 등 다른 데이터가 들어오는 경우가 많았고, groupMembers.length - 1 !== index 이것도 불편해서 ItemList 컴포넌트로 추상화해서 사용하도록 했다.

ItemList 컴포넌트 구현

컴포넌트 명은 최대한 도메인에 종속되어있지 않은 이름으로 지었다. 어떤 데이터든지 리스트로 보여줄 수 있기 때문이다.

// ItemList.tsx
interface ItemListProps<T> extends Omit<ComponentProps<typeof Flex>, 'children'> {
  data: T[]
  renderItem: (data: T) => JSX.Element
  direction?: 'row' | 'column'
  hasDivider?: boolean
}

export default function ItemList<T>({
  data,
  renderItem,
  direction = 'column',
  hasDivider = true,
  ...props
}: ItemListProps<T>) {
  return (
    <Flex direction={direction} {...props}>
      {data.map((item, index) => {
        return (
          <Fragment key={index}>
            {renderItem(item)}
            {hasDivider && index !== data.length - 1 && <Divider />}
          </Fragment>
        )
      })}
    </Flex>
  )
}

사용 예시

이제 ItemList 컴포넌트를 사용하면 이렇게 작성할 수 있다.

<ItemList data={groupMembers} renderItem={(member) => <MemberItem member={member} />} />

한줄 컷!

진짜 Headless한 컴포넌트인가?

사실 지금 우리의 상황에서는 Divider UI가 고정이라 ItemList 컴포넌트에 직접 넣어주었기 때문에 완전한 Headless 컴포넌트는 아니라고 생각한다.

더 확장성을 높이고 싶다면 Divider 컴포넌트 또한 render props 패턴을 활용해서 구현하면 될 것 같다!

모임 개설 페이지 리팩토링

모임 개설 페이지에서 디자인이 변경되어 수정했다.

원래는 모임 일시를 클릭하면 바텀시트가 올라와서 입력할 수 있는 디자인이었는데, 바텀시트 대신 페이지로 바뀌었다.

이렇게 바뀌면서 로직도 많이 변경하게 되었다. 이전에 만들어두었던 useFunnel 훅을 사용해서 구현하면 form state들도 사라지지 않으면서 깔끔하게 구현할 수 있었다.

1. 페이지 컴포넌트

// page.tsx
export default function CreateGroupPage() {
  return (
    <CreateGroupContextProvider>
      <ModalProvider>
        <CreateFunnel />
      </ModalProvider>
    </CreateGroupContextProvider>
  )
}

2. Funnel 컴포넌트

헤더는 currentStep에 따라 다르게 보여주도록 구현했다.

그리고 useFunnel 훅을 사용하여 각 스텝에 해당하는 컴포넌트를 보여주도록 구현했다.

// CreateFunnel.tsx
export default function CreateFunnel() {
  const { Funnel, currentStep, setStep, prevStep } = useFunnel(['main', 'meetDate'])

  const handleSubmit: SubmitHandler<CreateGroupContextValue> = (data) => {
    // ...
  }

  return (
    <Funnel>
      <CreateHeader currentStep={currentStep} />
      <Funnel.Step name="main">
        <MainStep onSelectMeetDate={() => setStep('meetDate')} onSubmit={handleSubmit} />
      </Funnel.Step>
      <Funnel.Step name="meetDate">
        <MeetDateStep onDone={prevStep} />
      </Funnel.Step>
    </Funnel>
  )
}

3. 스텝 컴포넌트

각 스텝들은 섹션 별로 컴포넌트를 분리해서 추상화 수준을 맞추었다.

// MainStep.tsx
interface MainStepProps {
  onSelectMeetDate: () => void
  onSubmit: SubmitHandler<CreateGroupContextValue>
}

export default function MainStep({ onSelectMeetDate, onSubmit }: MainStepProps) {
  const { handleSubmit } = useCreateGroupContext()

  return (
    <>
      <UploadSection />
      <InputSection />
      <Divider thickness="thick" />
      <SettingSection onSelectMeetDate={onSelectMeetDate} />
      <Spacing size={60} />
      <ButtonGroup>
        <Button onClick={handleSubmit(onSubmit)}>완료</Button>
      </ButtonGroup>
    </>
  )
}
// MeetDateStep.tsx
interface MeetDateStepProps {
  onDone: () => void
}

export default function MeetDateStep({ onDone }: MeetDateStepProps) {
  return (
    <>
      <CalendarSection />
      <Divider thickness="thick" />
      <TimeSection type="from" />
      <TimeSection type="to" />
      <Spacing size={60} />
      <ButtonGroup>
        <Button onClick={onDone}>완료</Button>
      </ButtonGroup>
    </>
  )
}

캘린더 구현

react-datepicker 라이브러리를 사용해서 캘린더를 구현했다.

다행히 기존에 캘린더를 구현한 코드가 있어서 스타일만 변경했다. 스타일 주는 게 어려울 줄 알았는데 생각보다 금방 구현했다 ㅎㅎ

// Calendar.tsx
interface CalendarProps {
  dateValue: Date | null
  setDateValue: (date: Date) => void
}

export default function Calendar({ dateValue, setDateValue }: CalendarProps) {
  return (
    <DatePicker
      locale={ko}
      dateFormat="yyyy-MM-dd"
      formatWeekDay={(nameOfDay) => nameOfDay.substring(0, 1)}
      minDate={new Date()}
      selected={dateValue}
      onChange={(date: Date) => setDateValue(date)}
      inline
      renderCustomHeader={({ date, decreaseMonth, increaseMonth }) => (
        <Flex align="center">
          <p className="text-subtitle-1">
            {getYear(date)}{getMonth(date)}          </p>
          <Spacing size={0} className="grow" />
          <IconButton size="medium" onClick={decreaseMonth}>
            <Image
              src="/icons/24/navigate_before.svg"
              alt="navigate_before"
              width={24}
              height={24}
            />
          </IconButton>
          <IconButton size="medium" onClick={increaseMonth}>
            <Image src="/icons/24/navigate_next.svg" alt="navigate_next" width={24} height={24} />
          </IconButton>
        </Flex>
      )}
    />
  )
}
react-datepicker 스타일 넣기
.react-datepicker {
  @apply w-full !border-none;
}

.react-datepicker__month-container {
  @apply w-full;
}

.react-datepicker .react-datepicker__header {
  @apply flex flex-col gap-16 border-none bg-inherit pb-28 pt-0;
}

.react-datepicker .react-datepicker__month {
  @apply m-0 flex flex-col gap-20;
}

.react-datepicker .react-datepicker__day--outside-month {
  @apply invisible cursor-default;
}

.react-datepicker .react-datepicker__header .react-datepicker__day-names {
  @apply flex justify-between;
}

.react-datepicker .react-datepicker__day-name {
  @apply m-0 flex h-32 w-32 items-center justify-center text-subtitle-3 text-sign-tertiary;
}

.react-datepicker .react-datepicker__month .react-datepicker__week {
  @apply flex justify-between;
}

.react-datepicker .react-datepicker__day {
  @apply flex h-32 w-32 items-center justify-center text-paragraph-1 text-sign-secondary;
}

.react-datepicker .react-datepicker__day:hover {
  @apply flex h-32 w-32 items-center justify-center rounded-full bg-primary-light text-white;
}

.react-datepicker .react-datepicker__day--selected,
.react-datepicker .react-datepicker__day--keyboard-selected {
  @apply flex h-32 w-32 items-center justify-center rounded-full bg-primary text-white;
}

.react-datepicker .react-datepicker__day--selected:hover {
  @apply flex h-32 w-32 items-center justify-center rounded-full bg-primary-light text-white;
}

.react-datepicker .react-datepicker__triangle {
  @apply hidden;
}

내일 할 일

  • gloddy 개발