헤드리스 컴포넌트와 Context API로 Form Stepper UI 구현하기

4/11/2024
 
 

👀 들어가기 전

💡
- React을 위한 stepper 라이브러리가 있습니다. 그러나 서비스에 제한적으로 사용되어 직접 구현하고자 하는 경우에 참고할 수 있습니다. - 초반 헤드리스 컴포넌트 패턴의 내용은 ‘(번역)헤드리스 컴포넌트:리액트 UI를 합성하기 위한 패턴’을 참고하여 요약한 것입니다. - 후반 Form Stepper부터 직접 구현하며 작성한 내용입니다.

헤드리스 컴포넌트 패턴

: 기능 즉, 두뇌를 담당하는 비시각적인 로직과 상태 관리를 추출해서 관리하는 패턴
  • 계산과 UI를 분리하는 패턴
    • 리액트 훅으로 구현함
    • UI를 규정하지 않고, 로직/상태관리만 책임 ( = 두뇌, 기능성 제공)
    • 겉모습은 각 개발자에게 맡김 ( = 시각적 표현은 강요하지 않음)
  • 도메인 모델 (data) ↔ Hooks (state login) ↔ JSX (view)
  • 로직과 프레젠테이션을 분리하는 패턴
 

예시 - Dropdown

  • 구성요소 : Trigger (button) + Dropdown Menu (menu item으로 구성)
  • 로직 (= select 컴포넌트의 핵심 기능들, 열기/닫기 상태, 선택된 항목, 강조 표시된 요소, 목록 선택시 화살표 아래 키 누르기 등에 대한 react ) ⇒ 자식 함수/ 컴포넌트에 제공
    • 열기/닫기 state = isOpen, setIsOpen
    • 항목 선택 state = seletedItem, setSelectedItem
    • 접근성 지원 관련 로직 (키보드 탐색 )
    • ⇒ 특정 시각적 표현에 얽매이지 않고 핵심 동작을 유지함
 

분리되기 전 Dropdown

const Dropdown = ({ items }: DropdownProps) => { // ... 이전 상태 변수 ... const [selectedIndex, setSelectedIndex] = useState<number>(-1); const handleKeyDown = (e: React.KeyboardEvent) => { switch ( e.key // ... 케이스 구문 ... // ... Enter, Space, ArrowDown and ArrowUp 키에 대한 핸들링 ... ) { } }; return ( <div className="dropdown" onKeyDown={handleKeyDown}> {/* ... JSX의 나머지 부분 ... */} </div>); };

useDropdown 커스텀 훅 만들어 분리하기

  • 상태값, 키보드 이벤트 처리 로직으로 구성
    • 렌더링 로직 : isOpen, toggleDropdown
    • 키보드 이벤트 로직 : handleKeydown
    • 상태 관리 로직: selectedItem, setSelectedItem
    • 접근성 지원 로직 : selectedIndex, setSelectedIndex
    • const useDropdown = (items: Item[]) => { // ... 상태 변수 ... // 헬퍼 함수는 UI에 대한 일부 aria 속성을 반환할 수 있습니다. const getAriaAttributes = () => ({ role: 'combobox', 'aria-expanded': isOpen, 'aria-activedescendant': selectedItem ? selectedItem.text : undefined, }); const handleKeyDown = (e: React.KeyboardEvent) => { // ... switch 구문 ... }; const toggleDropdown = () => setIsOpen(isOpen => !isOpen); return { isOpen, toggleDropdown, handleKeyDown, selectedItem, setSelectedItem, selectedIndex, }; };
      const Dropdown = ({ items }: DropdownProps) => { const { isOpen, selectedItem, selectedIndex, toggleDropdown, handleKeyDown, setSelectedItem, } = useDropdown(items); return ( <div className="dropdown" onKeyDown={handleKeyDown}> <Trigger onClick={toggleDropdown} label={selectedItem ? selectedItem.text : 'Select an item...'} /> {isOpen && ( <DropdownMenu items={items} onItemClick={setSelectedItem} selectedIndex={selectedIndex} /> )} </div> ); };
       

      Context API를 활용한 선언적 헤드리스 컴포넌트

      import { HeadlessDropdown as Dropdown } from './HeadlessDropdown'; const HeadlessDropdownUsage = ({ items }: { items: Item[] }) => { return ( <Dropdown items={items}> <Dropdown.Trigger as={CustomTrigger}>Select an option</Dropdown.Trigger> <Dropdown.List as={CustomList}> {items.map((item, index) => ( <Dropdown.Option index={index} key={index} item={item} as={CustomListItem} /> ))} </Dropdown.List> </Dropdown> ); };
      // Context 생성 type DropdownContextType<T> = { isOpen: boolean; toggleDropdown: () => void; selectedIndex: number; selectedItem: T | null; updateSelectedItem: (item: T) => void; getAriaAttributes: () => any; dropdownRef: RefObject<HTMLElement>; }; function createDropdownContext<T>() { return createContext<DropdownContextType<T> | null>(null); } const DropdownContext = createDropdownContext(); export const useDropdownContext = () => { const context = useContext(DropdownContext); if (!context) { throw new Error('컴포넌트는 <Dropdown/> 내에서 사용해야 합니다.'); } return context; };
      // 컨텍스트 프로바이더 const HeadlessDropdown = <T extends { text: string }>({ children, items, }: { children: React.ReactNode; items: T[]; }) => { const { //... 훅의 모든 상태와 상태 설정 함수 } = useDropdown(items); return ( <DropdownContext.Provider value={{ isOpen, toggleDropdown, selectedIndex, selectedItem, updateSelectedItem, }} > <div ref={dropdownRef as RefObject<HTMLDivElement>} {...getAriaAttributes()} > {children} </div> </DropdownContext.Provider> ); };
      // 자식 컴포넌트 정의 HeadlessDropdown.Trigger = function Trigger({ as: Component = 'button', ...props }) { const { toggleDropdown } = useDropdownContext(); return <Component tabIndex={0} onClick={toggleDropdown} {...props} />; }; HeadlessDropdown.List = function List({ as: Component = 'ul', ...props }) { const { isOpen } = useDropdownContext(); return isOpen ? <Component {...props} role="listbox" tabIndex={0} /> : null; }; HeadlessDropdown.Option = function Option({ as: Component = 'li', index, item, ...props }) { const { updateSelectedItem, selectedIndex } = useDropdownContext(); return ( <Component role="option" aria-selected={index === selectedIndex} key={index} onClick={() => updateSelectedItem(item)} {...props} > {item.text} </Component> ); };
       
       

Form Stepper UI

Stepper UI는 어떤 요소들로 구성되어 있을까요?

  • Step Label : 어떤(몇 번째) 스텝인지 알려주는 label
  • Skip Button: 해당 스텝을 스킵할 수 있는 button
    • 그러나 사용할 서비스에서는 수강 예약을 위해 필수 정보들을 전부 입력해야 합니다. 그래서 skip은 제공하지 않습니다
  • Content: 해당 스텝에 어떤 내용을 출력할지, 입력 받을지 본문에 해당되는 콘텐츠
  • Back/Next Button: 스텝 간 이동을 지원하는 button
    • 필수 정보들을 유효하게 입력해야만 활성화되도록 구현합니다.

Stepper에서 필요한 로직/상태에는 무엇일까요?

1. step 로직

  • [steps, setSteps] : active(mount)된 step을 사용자에게 제공하기 위해 스텝의 순서, activation 여부, validation에 필요한 데이터 flag, 제공할 컴포넌트를 함께 관리합니다.
    • const [steps, setSteps] = useState<Step[]>([ { order: 0, is_active: true, data_flag: 'student_name', component: <StepStudent />, }, { order: 1, is_active: false, data_flag: 'reservation_date', component: <StepClassTime />, }, { order: 2, is_active: false, data_flag: 'work_type', component: <StepWorkType />, }, ]);
    • [주의] data_flag의 경우 optional로 구현해야, validation이 필요없는 혹은 skip이 가능한 Stepper에서도 활용할 수 있습니다. → 그럼에도 서비스 전체에서 Stepper가 모두 validation 을 활용해야 하므로 필수 프로퍼티로 구현한 상태입니다.
    • f(n) handleBack : 이전 스텝으로 순서를 변경합니다
    • f(n) handleNext: 다음 스텝으로 순서를 변경합니다
      • //[steps, setSteps]를 활용하여 순서를 handle하는 함수 const handleNext = () => { const prevOrder = steps.find((step) => step.isMount)!.order; if (prevOrder === END) return; const nowOrder = prevOrder + 1; const newSteps = steps.map((step) => { if (step.order === prevOrder || step.order === nowOrder) { return { ...step, isMount: !step.isMount }; } return step; }); setSteps(newSteps); }; const handlePrev = () => { const prevOrder = steps.find((step) => step.isMount)!.order; if (prevOrder === START) { router.back(); } else { const nowOrder = prevOrder - 1; const newSteps = steps.map((step) => { if (step.order === prevOrder || step.order === nowOrder) { return { ...step, isMount: !step.isMount }; } return step; }); setSteps(newSteps); } };

2. validation 로직 (optional)

  • 다음 스텝으로 넘어가기 위해서 사용자가 일정 조건을 만족해야 할 경우 validation 로직을 추가해야 합니다.
    • [reservation, setReservation] : 수강 예약에 필요한 필드를 각 스텝마다 채우며 reservation 엔티티를 관리합니다
      • // useReservationCreate.ts //수강생, 예약일시, 수업 유형 등 type WORK_TYPE = 'throw' | 'hand'; export type Reservation = { student_id?: number; student_name?: string; reservation_date?: string; reservation_class_time_id?: string; work_type?: WORK_TYPE; }; const useReservationCreate = () => { const [reservation, setReservation] = useState<Reservation>(); const fill = (reservationProperty: Partial<Reservation>) => { const updated = reservation ? { ...reservation, ...reservationProperty } : reservationProperty; setReservation(updated); }; return { data: reservation, fill }; };
    • f(n) fillReservation : reservation 데이터 중 일부를 입력 받고 reservation 데이터가 유효할 때까지 프로퍼티를 채워나갑니다.
    • 활용 👀
      • 각 스텝에서 요구하는 필드를 유효하게 입력하면 Next 버튼을 활성화하는 데 사용합니다.
        • const reservation = useContext(ReservationCreateContext) {steps.map((step, idx) => step.isMount && ( <Button key={idx} className='w-full' size='large' disabled={!reservation?.data?.hasOwnProperty(step.data)} onClick={handleNext} > {step.order === STEP_END ? '등록' : '다음'} </Button> ) )}
           

✔︎ 완성!

notion image
notion image
notion image
notion image
©JIYOUNG CHOI, All rights reserved