컴파운드 컴포넌트 패턴으로 <Select/> 만들기

aeong98·2022년 8월 17일
31
post-thumbnail

👉 이전에 개발했던 Select (일명 드롭다운) 에 컴파운드 컴포넌트 (Compound Component, 합성 컴파운드) 디지안패턴을 적용해 코드를 개선했던 경험을 정리한 글입니다.

1. 🚨 처음에 개발했던 Select 컴포넌트의 문제점

기존 Select 컴포넌트 사용할 때 props 넘겨주는 모습입니다. 딱봐도 문제점이 많아보입니다.

기존의 Select 컴포넌트의 인터페이스는, 로직과 뷰에 대한 상태가 전혀 분리되지 않은 인터페이스가 아주 복잡하고, 확장하기에도 어려운 구조였습니다. 제가 생각한 기존 Select 컴포넌트의 문제점은 다음과 같습니다.

  • values, selectedId, setSelectedId 와 같이 선택을 위한 상태값이 추상화되지 못하고 모두 노출되어 있습니다.
  • input, button 과 같이 용도를 직관적으로 파악하기 힘든 props 네이밍이 사용되고 있습니다. (원래 의도는 Select 컴포넌트를 구성하는 하위 컴포넌트의 스타일을 지정하는 용도입니다.)
  • 뷰에 대한 상태값, 로직에 대한 상태값이 구분되지 않고 혼재되어있습니다.
  • Select 컴포넌트를 구성하는 모든 상태값을 부모컴포넌트에서 내려주고 있습니다 (props drilling)
  • 그리고 일단, props 가 너무 많습니다..

무엇보다 Select 내부의 Option 리스트를 렌더링하기 위해 option list(드롭다운 리스트 데이터) 상태값을 나타내는 values 를 중복으로 두번 넘겨줘야 한다는 점에서 좋지 않은 컴포넌트 설계방법이라고 생각했습니다.

2. 🤔 Select 컴포넌트를 개발하면서 고민했던 점?


모든 props를 Select 컴포넌트에 때려박는 방식으로, 기능은 충분히 구현할 수 있었습니다. 하지만 추후에 다중 선택과 같은 새로운 기능이 추가되거나 trigger button 의 스타일이 바꼈을 때 등의 변경사항에 유연하게 대응하지 못하고 디버깅이 어렵다는 문제점이 있었습니다. 따라서, Select 컴포넌트를 구현할 때 고려해야할 부분과 컴포넌트의 기능적인 요구사항에 대해 다시 정의를 내려보았습니다.

이번에 또 다른 시행착오를 줄이기 위해 디자인 시스템에서 정의하는 Select 를 구현하는 방법을 소개하는 블로그 글인 https://so-so.dev/react/make-select/ 을 많이 참고했습니다.

구현 고민

  • 사용하기 쉽고 편리한 API 를 제공하는 컴포넌트를 만들 수 있을까?
  • UI 와 기능의 측면에서 확장 가능한 컴포넌트를 만들 수 있을까?
  • 필요한 props 만 넘겨주는 컴포넌트를 개발할 수 있을까? (props drilling 을 막을 수 있는 방법?)
  • Native Element 의 <select>, <option> 태그와 유사한 인터페이스로 구현할 수 있을까?

요구사항

  • 검색기능이 있을 수도 없을 수 도 있음.
  • option 을 disabled 시킬 수 있어야함.
  • 리스트가 열러 있는 상태일 때 선택된 옵션에 포커스 되어 있어야 한다.
    • Option 컴포넌트의 name 에 따라, 각 요소가 선택되었는지 여부를 판단한다.
  • 초기 default 값을 지정할 수 있어야 한다.

결론부터 말하자면 합성 컴포넌트(Compound Component) 패턴을 도입해서 제가 원하는 형태의 Select 컴포넌트와 인터페이스를 구현할 수 있었습니다. 완성된 컴포넌트를 사용할 때의 모습은 아래와 같습니다.

이전에 비해 사용하기도 쉬워지고 코드가 짧아진 모습입니다.
(인터페이스 설계는 ant design 의 props 를 참고해서 개발했습니다. )

3. ✅ 해결 : 합성 컴포넌트(Compound Component)


컴파운드 컴포넌트 패턴이란?

합성컴포넌트 패턴은 하나의 컴포넌트를 여러 가지 집합체로 분리한 뒤, 분리된 각 컴포넌트를 사용하는 쪽에서 조합해 사용하는 컴포넌트 패턴을 의미합니다.

예시

아래는 숫자를 세는 버튼인 라는 버튼에 합성 컴포넌트를 하기 이전과, 적용 이후의 모습입니다.

하나의 부모컴포넌트에 모든 props 를 집어넣고 하위 UI 컴포넌트로 향해 내려가는 대신, 각 요소들의 관심사에 맞는 props 를 각각 넘겨주기 때문에, API 복잡성이 감소한 모습입니다

html 의 <select> 태그는 이미 이런 합성 컴포넌트의 모습을 갖추고 있습니다. 제가 직접 개발한 <Select/> 컴포넌트도 비슷한 인터페이스를 가져갈 수 있도록 개발해보았습니다.

<select>
  <option value="1">Option 1</option>
  <option value="2">Option 2</option>
</select>

4. 📝 구현 방법


아래서부터 첨부된 코드는 핵심적인 부분만 표시한 Scratch 코드입니다.

1. Select 컴포넌트 폴더 구조

우선 Select 컴포넌트 폴더 구조는 아래와 같이 설계했습니다.

  • index.tsx : 각 하위 컴포넌트를 병합해 하나의 <Select/> 컴포넌트로 묶어 export
  • hooks : 각 하위 컴포넌트의 세부 구현 및 로직을 담당하는 커스텀훅을 모아두는 폴더
  • ui : view를 담당하는 하위 컴포넌트들을 모아두는 폴더
  • style : scss 파일을 모아두는 폴더
Select
├── index.tsx
├── hooks
│   ├── SelectContext.tsx
│   ├── index.ts
│   ├── useOption.tsx
│   ├── useOutsideAlerter.tsx
│   ├── useSearch.tsx
│   └── useTriggerButton.tsx
├── style
│   └── select.module.scss
└── ui
    ├── Option.tsx
    ├── OptionList.tsx
    ├── SearchField.tsx
    ├── SelectMain.tsx
    └── TriggerButton.tsx

2. ContextAPI , useContext, useReducer 를 사용한 상태값 설계

2-1) createContext : 컨텍스트 생성

내부 상태를 공유하기위해서는 ContextAPI 를 사용했습니다. 각 컴포넌트들이 내부적으로 공유할 상태값을 담고 있는 context를 생성하고, 이를 하위 컴포넌트에서 접근할 수 있도록 useSelect 커스텀 훅 (useContext) 커스텀훅을 작성했습니다.

context 로 공유하는 데이터

  • 리스트 데이터
  • 선택된 데이터
  • 검색 결과
  • 초기디폴드값
  • 리스트 open 여부
import React, { createContext } from "react";

export const SelectContext = createContext({
  리스트데이터: [] as any,
  선택된데이터: undefined as any,
  검색결과리스트: [] as any,
  set리스트데이터: (e: any) => {},
  set선택된데이터: (e: any) => {},
  set검색결과리스트: (e: any) => {},
  초기디폴드값: undefined as any,
  setIsOpen: (e: boolean) => {},
});

export const useSelect = () => {
  const context = React.useContext(SelectContext);

  if (context === undefined) {
    throw new Error("useSelect must be used within a <Select />");
  }
  return context;
};

2-2) 컨텍스트 프로바이더 컴포넌트 개발

이후로 공유할 컨텍스트를 메인 컴포넌트에 주입해줄 수 있는 컨텍스트 프로바이더 컴포넌트를 개발하고, 최상위 메인 컴포넌트를 감싸줍니다.

export const SelectProvider =({...props})=>{

	return(
	 <SelectContext.Provider value={{...values}}>
		{children}
	 </SelectContext.Provider>
	)
}

// SelectMain.tsx (최상위 메인 컴포넌트)
import { SelectProvider } from "../hooks/SelectContext";

export const SelectMain = ({...props})=>{

	return (
		<SelectProvider {...values}>
				... 하위 컴포넌트들 
				{children}
    </SelectProvider>
	)
}

2-3) 리스트데이터 업데이트를 위한 reducer 생성, 리스트데이터 업데이트를 위한 dispatch 액션 등록

저는 option 컴포넌트를 통해 들어오는 리스트 데이터 업데이트를 위해 useReducer 를 사용했습니다. reducer 를 통해, 상위 컴포넌트에서 리스트 데이터를 props 로 넘겨받지 않아도, 하위 컴포넌트인 <Option/>에서 반대방향으로 리스트데이터를 받아 내부적으로 상태를 공유할 수 있도록 설계해봤습니다.

import React, { useReducer } from "react";

function reducer(state: any, action: any) {
  switch (action.type) {
    case "ADD":
      return [...state, action.value];
    default:
      return state;
  }
}

export const SelectProvider =({...props})=>{
  const [state, dispatch] = useReducer(reducer, []);

	return(
	 <SelectContext.Provider value={{
		...values
    리스트데이터 : state,
		set리스트데이터: (value: any) => {
            dispatch({
              type: "ADD",
              value: value,
            });
     }
   }}>
		{children}
	 </SelectContext.Provider>
	)
}

3. 하위 컴포넌트 개발

다음으로 Select 의 각 부분을 구성하는 하위 컴포넌를 개발합니다. 이때, 로직을 담당하는 부분은 커스텀 훅으로, 를 담당하는 부분은 UI 컴포넌트로 개발해 코드의 가독성을 높였습니다.

예를 들어, Select 하위 컴포넌트 중 검색기능을 담당하는 SearchField 컴포넌트는 아래와 같이 작성했습니다. UI 에 변경사항을 일으키는 로직은 모두 useSearch 커스텀 훅에서 담당하고, SearchField 컴포넌트는 변경된 상태값에 따라 화면을 다시 그리는 역할만을 수행합니다.

import React, { useState, useEffect } from "react";
import { useSelect } from "./SelectContext";

// hooks/useSearch.ts - 커스텀 훅 (로직 담당)
export function useSearch() {
  const [입력값, set입력값] = useState("");

  const { 리스트데이터, set검색결과리스트 } = useSelect();

  useEffect(() => {
    if (입력값.length === 0) {
      set검색결과리스트(undefined);
      return;
    }

    set검색결과리스트(입력값);
  }, [입력값]);

  // 검색 값에 따라, 검색 결과를 context 에 업데이트 시켜주는 함수 
  const handleSearchResult = (입력값: string) => {
    const result = 리스트데이터.filter((value: any) =>
      리스트데이터.name.toLowerCase().includes(입력값.toLowerCase())
    );

    if (result) set검색결과리스트(result);
  };

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    set입력값(e.target.value);
  };

  return {
    입력값,
    onChange,
  };
}

// ui/SearchField.tsx - 하위 컴포넌트 (뷰 담당) 
export function SearchField() {
  const { 입력값, onChange } = useSearch();

  return (
    <>
      <input
        id="select-search"
        value={입력값}
        onChange={onChange}
        placeholder="placeholder"
      ></input>
    </>
  );
}

4. 병합

위와 같이 개발된 각각의 하위 컴포넌트를 <Select/>이라는 하나의 객체로 묶어줍니다. 이렇게 컴포넌트를 묶어서 export 를 해주면 <Select>, <Select.Option> 과 같이 메인컴포넌트와 서브 컴포넌트를 직관적으로 파악할 수 있어 가독성에 도움을 줄 수 있다고 합니다 .(출처 : 카카오 FE 기술블로그)

// index.tsx

import { SelectMain } from "./ui/SelectMain";
import { Option } from "./ui/Option";
import { TriggerButton } from "./ui/TriggerButton";
import { SearchField } from "./ui/SearchField";
import { OptionList } from "./ui/OptionList";

export const Select = Object.assign(SelectMain, {
  Option: Option,
  Trigger: TriggerButton,
  Search: SearchField,
  List: OptionList,
});

5. 결과물

완성된 Select 컴포넌트는 아래와 같은 인터페이스로 사용할 수 있게됐습니다. 처음에 목표로 했던 html 의 <select> <option> 태그와 유사한 형태를 띄고 있고, props 를 관심사가 일치하는 각각의 컴포넌트에 넘겨줄 수 있어, 새로운 기능(props)가 추가되어도 안정적인 구조를 가져갈 수 있습니다.

마무리

작성중..

참고

profile
프린이탈출하자

0개의 댓글