NextBlog

우리 팀만의 useFunnel 만들기

이전 포스트와 연결됩니다.

목차

 

useFunnel 목표

두 버전을 고려하여 다음과 같은 방식으로 useFunnel을 사용할 수 있게 만들 예정입니다.
만들기 위해 두 버전의 라이브러리 소스코드를 참고하여 제작했습니다.
 
먼저 타입 지정은 각 stepstep별 context의 타입을 지정합니다.
export type ShareCourseData = z.infer<typeof ShareCourseListSchema>; export type TTripInfo = ICourseInfo & { roomId: string; tripId: string; } export type TShareCourseContext = { 여행상세: TTripInfo, 코스목록?: ShareCourseData, // 빈 객체 공유?: Record<string, never>, }
 
그리고 useFunnel에서는 generic 타입으로 각 step의 context에 대한 타입을 정의하고, options를useFunnel에 전달합니다.
 
이는 최신 버전과 같이 options를 지정합니다.
이때 useFunnel은 FunnelsetStep함수, 현재 저장된 상태를 확인할 수 있는 context를 반환합니다.
이때 init의 context는 정의된 타입과 일치해야 합니다. 아닐 경우 에러로 사용자에게 알려줘야 합니다.
const [Funnel,setStep,context] = useFunnel<TShareCourseContext>({ steps:["여행상세","코스목록","공유"], init:{ step:"여행상세", context:courseInfo, //TTripInfo type }, stepQueryKey:"step", }); {/* 에러일 경우 */} const [Funnel,setStep,context] = useFunnel<TShareCourseContext>({ steps:["여행상세","코스목록","공유"], init:{ step:"여행상세", context:{"a":"test"}, //error! }, stepQueryKey:"step", });
 
그리고 각 퍼널 추상화를 다음과 같이 만들고자 합니다.
 
Funnel.Step에서 각 스텝 별 이름을 지정합니다.
<Funnel> <Funnel.Step name="여행상세"> <Detail courseInfo={courseInfo} onNext={()=>setStep<"여행상세">("코스목록",courseInfo)} /> </Funnel.Step> <Funnel.Step name="코스목록"> <List onNext={(places)=>setStep<"코스목록">("공유",places)} context={context} /> </Funnel.Step> <Funnel.Step name="공유"> <Share context={context}/> </Funnel.Step> </Funnel>
 
setStep에서는 현재 step을 generic type에 정의해주고, 넘어가고자 하는 step과 상태를 전달합니다.
이때 setStep내에서 전달하고자 하는 상태는 정의된 타입과 일치해야 합니다.
만약 다음과 같이 다른 상태가 들어온다면 에러가 발생해야 합니다.
<Funnel.Step name="코스목록"> <List onNext={(places)=>setStep<"코스목록">("공유",places)} context={context} /> </Funnel.Step> {/* 에러일 경우 */} <Funnel.Step name="코스목록"> <List onNext={(places)=>setStep<"코스목록">("공유",{"hello":"funnel"})} //error! context={context} /> </Funnel.Step>
 

useFunnel 타입 정의

이제 타입을 지정하고자 합니다. 타입은 두 버전의 useFunnel 라이브러리를 참고하였습니다.
 
options에서 사용할 타입을 지정하겠습니다. 먼저 특정 funnel에서의 state 타입을 지정합니다.
type NonEmptyArray<T> = [T, ...T[]]; //steps type은 비어있지 않은 string 배열 export type StepsType<T extends string> = Readonly<NonEmptyArray<T>>;
export type AnyContext = Record<string,any>; //step이름이랑 Context 매핑 //전체 context 타입 //{ // "step1":{"name":"hello"} //} export type AnyStepContextMap = Record<string,AnyContext>; //step 에서의 context 상태 //특정 funnel state 타입 // { // step:"step1", // context:{ // message:"hello" // } // } export interface FunnelState<TName extends string, TContext = never>{ step:TName; context: TContext; } export type AnyFunnelState = FunnelState<string,AnyContext>
그리고 이를 활용하여 FunnelStateByContextMap을 만들어 generic type으로 funnel의 step, context 타입을 받아 FunnelState<…> | FunnelState<…> 와 같은 유니온 타입으로 만듭니다.
//AnyStepContext의 key로 FunnelState 매핑 //FunnelState 유니온 타입으로 변경 export type FunnelStateByContextMap<TStepContextMap extends AnyStepContextMap> = { [key in keyof TStepContextMap & string]: FunnelState<key, TStepContextMap[key]>; }[keyof TStepContextMap & string];
이를 활용하여 options의 타입을 다음과 같이 지정합니다.
export interface useFunnelOption<TStepContextMap extends AnyStepContextMap>{ steps: StepsType<keyof TStepContextMap & string>, init:FunnelStateByContextMap<TStepContextMap>, stepQueryKey: string; }
이렇게 하면 다음과 같은 option 지정이 가능합니다.
const [Funnel,setStep,context] = useFunnel<TShareCourseContext>({ steps:["여행상세","코스목록","공유"], init:{ step:"여행상세", context:courseInfo, }, stepQueryKey:"step", }); {/* 에러일 경우 */} const [Funnel,setStep,context] = useFunnel<TShareCourseContext>({ steps:["여행상세","코스목록","공유"], init:{ step:"여행상세", context:{"a":"test"}, //error! }, stepQueryKey:"step", });
 

useFunnel 구현

이제 useFunnel을 정의된 타입을 가지고 구현해보겠습니다.
 
useFunnel 내부에 queryString을 확인하며 현재 step 상태를 확인합니다.
export const useFunnel = <TStepContextMap extends AnyStepContextMap>( options: useFunnelOption<TStepContextMap> ):readonly [ FunnelComponent<typeof options.steps>, setStepFn<TStepContextMap>, TStepContextMap ]=>{ const stepQueryKey = options?.stepQueryKey ?? "step"; const [searchParams, setSearchParams] = useSearchParams(); const currentStep = (searchParams.get(stepQueryKey) ?? options?.init.step) as keyof TStepContextMap & string;
다음으로 useFunnel 내부에서 상태를 관리하기 위해 contextMap을 선언하여 내부에서 step에 대한 상태를 선언합니다.
 
이때 초기 상태 값이 존재할 경우 initial 값으로 넣어줍니다.
//init 파라미터로 초기 상태를 설정 const [contextMap, setContextMap] = useState<TStepContextMap>(() => { const initialState = {} as TStepContextMap; initialState[options.init.step] = options.init.context ?? ({} as TStepContextMap[typeof options.init.step]); return initialState; });
그리고 FunnelComponent로 queryString에서 받고 있는 step 값으로 해당되는 퍼널을 렌더링 합니다.
 
이때 typescript는 런타임 에러를 잡을 수 없으므로 step == null 일 경우 에러를 내보냅니다.
//<Funnel> 컴포넌트 반환 //StepProps를 할당한 Step assign const FunnelComponent = useMemo( () => Object.assign( function RouteFunnel(props: RouteFunnelProps<typeof options.steps>) { const step = searchParams.get(stepQueryKey) ?? options?.init.step; //런타임 에러 방지 if (step == null) { throw new Error( `표시할 스텝을 step 쿼리 파라미터에 지정해주세요.` ); } return <Funnel<StepsType<keyof TStepContextMap & string>> steps={options.steps} step={step} {...props} />; },{ Step } ), [searchParams] );
 
다음으로 setStep 함수의 타입을 지정하겠습니다. 이는 위처럼 generic type으로 nextStep과 지정될 context의 타입을 제한합니다.
export type setStepFn<TStepContextMap extends AnyStepContextMap> = <TCurrentStep extends keyof TStepContextMap>( nextStep: keyof TStepContextMap, context: TStepContextMap[TCurrentStep] ) => void;
다음과 같이 react project이므로 queryString를 설정하며 현재 step을 관리하였습니다.
 
또한 setContextMap 함수로 useFunnel 내부에서 context를 관리하였습니다.
이때도 지정된 type에 맞춰 입력되어야 합니다.
//setStep 함수로 url 파라미터를 변경한다. const setStep = useCallback(<TCurrentStep extends keyof TStepContextMap>( nextStep: keyof TStepContextMap, context: TStepContextMap[TCurrentStep] ) => { setSearchParams((prev) => { prev.set(stepQueryKey, nextStep as string); return prev; }); setContextMap(prev => ({ ...prev, [currentStep]: context })); }, [setSearchParams, stepQueryKey, currentStep])
 
Funnel component는 step에 맞는 퍼널을 찾아 렌더링하는 역할을 합니다.
 
기존에는 밑과 같이 해당되는 이름을 가진 타겟 컴포넌트만 렌더링 하였지만,
const Funnel = ({ children }: FunnelProps) => { const targetStep = children.find((childStep) => childStep.props.name === step); return <>{targetStep}</>; };
이번 useFunnel에서는 유효한 children만 추출 한 후, 해당되는 이름을 가진 퍼널을 렌더링 해 줍니다.
또한 애니메이션을 추가하여 퍼널 렌더링 시 애니메이션을 보여줍니다.
//Funnel 컴포넌트는 여러 단계의 Step 컴포넌트 중 현재 활성화된 스텝을 렌더링한다. //find를 통해 Step 중 현재 Step을 찾아 렌더링 export const Funnel = <Steps extends StepsType<string>>({ steps,step,children }: FunnelProps<Steps>) => { //children 중 유효한 children만 추출 //후 children의 props(StepsProps) 중 name이 steps에 포함되어 있는 경우만 추출 const validChildren = Children.toArray(children) .filter(isValidElement) .filter(i => steps.includes((i.props as Partial<StepProps<Steps>>).name ?? '')) as Array< ReactElement<StepProps<Steps>> >; //validChildren 중 step과 일치하는 컴포넌트 찾기 const targetStep = validChildren.find(child => child.props.name === step); if(!targetStep) throw new Error(`${step} 스텝이 존재하지 않습니다.`); return ( <AnimatePresence mode="wait"> <motion.div key={step} initial={{ y: 100, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: -100, opacity: 0 }} transition={{ type: "spring", stiffness: 260, damping: 30, duration: 0.1 }} style={{ width: "100%", height: "100%", }} > {targetStep} </motion.div> </AnimatePresence> ); };
 

사용

실제 사용시 다음과 같이 사용됩니다.
<Funnel> <Funnel.Step name="생성"> <CreateVote onCalendar={() => setStep("캘린더", contextMap["생성"])} onSearch={() => setStep("주소검색", contextMap["생성"])} /> </Funnel.Step> <Funnel.Step name="캘린더"> <CreateCalendar onNext={() => setStep("생성", contextMap["캘린더"])} /> </Funnel.Step> <Funnel.Step name="주소검색"> <SearchPage handleSelectItem={(item) => { setSelectedPlace({ place_name: item.place_name, x: item.x.toString(), y: item.y.toString(), }); navigate(-1); }} /> </Funnel.Step> </Funnel>
이런식으로 우리 팀 서비스에 맞는 useFunnel을 개발하여 다음과 같은 이점이 있었습니다.