최근 TIFY 개발을 하면서, 14개의 취향 설문 조사를 담당하여 개발해야 할 일이 있었다.
14개 종류의 취향 설문 조사 안에서도 6~7개의 질문이 있고, 그 질문은 객관식/주관식/다중선택 방식으로 종류가 나뉘어 어떻게 개발해야 하나 머리가 정말 아팠다.
머리를 끙끙 싸매고 있었는데, slash23 컨퍼런스
를 들으면서
아, useFunnel, 프로젝트에 적용해봐야겠다! 라는 생각이 들었고,
React에서 사용할 수 있도록 커스텀화해서 사용해보고자 하였다. 먼저 이 패턴을 적용하기 위해서는 어떤 동작 원리를 가지고 있는지 분석해 보아야 하는 법.... 꼼꼼히 살펴보자.
전체 코드 보기 !
/** @tossdocs-ignore */
import { assert } from '@toss/assert';
import { Children, isValidElement, ReactElement, ReactNode, useEffect } from 'react';
import { NonEmptyArray } from './models';
export interface FunnelProps<Steps extends NonEmptyArray<string>> {
steps: Steps;
step: Steps[number];
children: Array<ReactElement<StepProps<Steps>>> | ReactElement<StepProps<Steps>>;
}
export const Funnel = <Steps extends NonEmptyArray<string>>({ steps, step, children }: FunnelProps<Steps>) => {
const validChildren = Children.toArray(children)
.filter(isValidElement)
.filter(i => steps.includes((i.props as Partial<StepProps<Steps>>).name ?? '')) as Array<
ReactElement<StepProps<Steps>>
>;
const targetStep = validChildren.find(child => child.props.name === step);
assert(targetStep != null, `${step} 스텝 컴포넌트를 찾지 못했습니다.`);
return <>{targetStep}</>;
};
export interface StepProps<Steps extends NonEmptyArray<string>> {
name: Steps[number];
onEnter?: () => void;
children: ReactNode;
}
export const Step = <Steps extends NonEmptyArray<string>>({ onEnter, children }: StepProps<Steps>) => {
useEffect(() => {
onEnter?.();
}, [onEnter]);
return <>{children}</>;
};
일단 Funnel
의 Props가 어떻게 구성되어 있는지 확인해보자.
export interface FunnelProps<Steps extends NonEmptyArray<string>> {
steps: Steps;
step: Steps[number];
children: Array<ReactElement<StepProps<Steps>>> | ReactElement<StepProps<Steps>>;
}
Steps
라는 제네릭 타입이 NonEmptyArray<string>>
타입을 상속받고 있다. 그럼 NonEmptyArray<string>
은 어떤 타입인지 알아보자.
/** @tossdocs-ignore */
export type NonEmptyArray<T> = readonly [T, ...T[]];
제네릭 타입으로 받은 것에 대한 배열 타입이다. readonly
를 지정하여 수정이 불가능하다. 지금까지 어떤 배열에 대한 타입을 작성할 때, 문자열 배열을 작성하고 as const
로만 지정해서 해당 배열의 요소들만 가진 const 타입으로 작성하였는데, 이렇게 타입을 작성할수도 있겠구나!
그럼 steps
의 타입에 대해 확인했으니, children
으로는 어떤 친구들이 와야 하는지 알아보자.
children: Array<ReactElement<StepProps<Steps>>> | ReactElement<StepProps<Steps>>;
오... 복잡한데? 일단 ReactElement
의 내부가 어떻게 구현되어 있는지 뜯어봐야 할 듯 하다.
interface ReactElement<P = any, T extends string | JSXElementConstructor<any> = string | JSXElementConstructor<any>> {
type: T;
props: P;
key: Key | null;
}
ReactElement
의 pros를 지정해주는 제네릭 타입이군. 그럼 Funnel
이라는 컴포넌트의 자식이 가져야 하는 props는, StepProps<Steps>
타입을 가져야 한다는 것이다.
export interface StepProps<Steps extends NonEmptyArray<string>> {
name: Steps[number];
onEnter?: () => void;
children: ReactNode;
}
오키, 그럼 Funnel
컴포넌트의 자식들은 string 타입의 수정 불가능한 배열에 대한 요소, 즉 step을 name으로 가지고 있어야 하고, onEnter
라는 함수를 선택적으로 가질 수 있는 것이다. 대략적으로 예시코드를 작성해보자면,
<Funnel>
<FunnelChildren name='1번퍼널' onEnter={handleEnter}>
<GetUserName/>
</FunnelChildren>
</Funnel>
이런 느낌!
그럼, FunnelProps
를 알기 쉽게 풀어서 정리해보자. NonEmptyArray
는 string
타입을 제네릭으로 받으니,
steps
: 수정이 불가능한 문자열 타입의 배열step
: steps
배열의 요소 (문자열)children
: ReactElement의 배열 (단,name
이라는 props를 가지고 있어야 함)export const Funnel = <Steps extends NonEmptyArray<string>>({ steps, step, children }: FunnelProps<Steps>) => {
const validChildren = Children.toArray(children)
.filter(isValidElement)
.filter(i => steps.includes((i.props as Partial<StepProps<Steps>>).name ?? '')) as Array<
ReactElement<StepProps<Steps>>
>;
...
}
반가운 얼굴의 등장이다. 이번에 데브코스에서 과제를 할 때 isVaildElement
React API를 이용하여 퍼널과 비슷하게 구현해봤었는데 이 경험 덕에 이 부분 분석을 빠르게 진행할 수 있었던 것 같다.
먼저, Funnel
컴포넌트가 가지고 있는 자식 요소들을 배열로 만든 다음에, React Element
인지 확인하는 것이다.
import { isValidElement, createElement } from 'react';
// ✅ React elements
console.log(isValidElement(<p />)); // true
console.log(isValidElement(createElement('p'))); // true
// ❌ Not React elements
console.log(isValidElement(25)); // false
console.log(isValidElement('Hello')); // false
console.log(isValidElement({ age: 42 })); // false
JSX 태그를 이용하여 만들어진 것과 createElement
를 통해 만들어진 객체만 React element로 간주하는 모습니다.
위에서 FunnelProps
의 자식 요소들이 ReactElement
이어야 하므로, 타입에 대한 검증을 꼼꼼히 한번 더 진행하는 모습이다. 라이브러리 코드니까 children
타입에 대해 꼼꼼히 검증하는 것이 아닐까.
다음으로 한번 더 filter
함수를 이용하여 검증을 해 주는데, 자식 컴포넌트의 props를 꺼내 와 name
을 확인하고, steps
배열에 존재하는지 확인한다. 이 때 props
타입 처리하는 데 있어서 왜 이렇게 코드를 작성했을까 신기해서 그 이유를 생각해보았따.
(i.props as StepProps<Steps>).name ?? ''
이렇게 작성했을 것 같다.
이렇게 작성하면 분명히, name
이 정의되지 않은 컴포넌트가 들어갔을 때
어 ?? i.props에 name이라는 속성이 존재하지 않아!!!!!!
하면서 에러를 name is undefined~~
라고 내뱉지 않았을까 싶다.
하지만,
(i.props as Partial<StepProps<Steps>>).name ?? '')
이렇게 Partial
을 이용함으로써 name
이 undefined
로 반환될 수 있음을 정의해준 것 같다. 👏
자, 이제 valideChildren
을 다시 StepProps라는 타입의 props를 가지는 children의 배열로 타입 검증을 완료했다.
남은 로직을 살펴보자.
export const Funnel = <Steps extends NonEmptyArray<string>>({ steps, step, children }: FunnelProps<Steps>) => {
...
const targetStep = validChildren.find(child => child.props.name === step);
assert(targetStep != null, `${step} 스텝 컴포넌트를 찾지 못했습니다.`);
return <>{targetStep}</>;
}
현재 Funnel
의 props로 들어오는 step
과 일치하는 name props를 가진 자식 요소를 찾아서 반환해준다. 만약, null
을 반환한다면 해당 컴포넌트를 찾지 못했다는 에러 문구를 반환한다.
이렇게 Funnel 컴포넌트가 어떻게 작동하는지 그 원리를 살펴보았다!
다음 글에는 useFunnel
커스텀 훅이 어떻게 동작하는지 그 동작 원리를 깊이 탐구해보자.
글 재미있게 봤습니다.