퍼널(Funnel) 만들어보기
2023. 07. 24.
#리액트
토스ㅣSLASH 23 - 퍼널: 쏟아지는 페이지 한 방에 관리하기 주제의 발표 내용을 보고 학습을 위해 단계별로 퍼널에 대해 이해하고 구현하였습니다. 발표내용을 보면서 평소에 관심있었던 컴포넌트 추상화와 적절한 예제라고 생각이 들어 직접 구현해보면서 학습했습니다.
💡 퍼널이란?
- 여러가지 페이지를 통해 상태(데이터)를 수집하고, 결과 페이지를 보여주는 형태
- 유저가 서비스에 접속해서 최종 목표지점까지 조금씩 이탈한 현상을? 퍼널(깔대기) 과 같은 모양을 띠기 때문에 이런 형태를 퍼널이라함.
💡 작업 내용
UI 와 등록 로직을 제외한 퍼널에 대한 내용만 작성하고자 합니다
👨🏻💻 1차 작업 - 일반적인 방식으로 구현
import axios from 'axios'; import { useState } from 'react'; import { PageLayout, 가입방식, 가입완료, 주민번호, 집주소 } from '@/pages'; const InitRegisterData = { 가입방식: '', 주민번호: '', 집주소: '', }; function App() { const [registerData, setRegisterData] = useState<RegisterData>(InitRegisterData); const [step, setStep] = useState<'가입방식' | '주민번호' | '집주소' | '가입성공'>('가입방식'); return ( <PageLayout> {step === '가입방식' && ( <가입방식 onNext={(data) => { setRegisterData((prev) => ({ ...prev, 가입방식: data })); setStep('주민번호'); }} /> )} {step === '주민번호' && ( <주민번호 onNext={(data) => { setRegisterData((prev) => ({ ...prev, 주민번호: data })); setStep('집주소'); }} /> )} {step === '집주소' && ( <집주소 onNext={(data) => { axios .post('/api/user', { ...registerData, 집주소: data }) .then(() => setStep('가입성공')); }} /> /> )} {step === '가입성공' && <가입완료 onNext={() => setStep('가입방식')} />} </PageLayout> ); } export default App; export type RegisterData = { 가입방식: string; 주민번호: string; 집주소: string; };
- 가장 일반적인 패턴으로? 각 스텝에 따라 그에 맞는 컴포넌트를 렌더링하는 방식이다.
{step === '가입성공' && <가입완료 onNext={() => setStep('가입방식')} />}
이를 한단계 추상화하면 아래와 같이 할 수도 있다.
// components/Step.tsx import { PropsWithChildren } from 'react'; type Props = { show: boolean; }; export function Step({ show, children }: PropsWithChildren<Props>) { if (show === true) { return children; } return null; } // App.tsx function App() { const [registerData, setRegisterData] = useState<RegisterData>(InitRegisterData); const [step, setStep] = useState<'가입방식' | '주민번호' | '집주소' | '가입성공'>('가입방식'); return ( <PageLayout> {/* Step 컴포넌트로 추상화 */} <Step show={step === '가입방식'}> <가입방식 onNext={(data) => { setRegisterData((prev) => ({ ...prev, 가입방식: data })); setStep('주민번호'); }} /> </Step> <Step show={step === '주민번호'}> <주민번호 onNext={(data) => { setRegisterData((prev) => ({ ...prev, 주민번호: data })); setStep('집주소'); }} /> </Step> <Step show={step === '집주소'}> <집주소 onNext={(data) => { axios .post('/api/user', { ...registerData, 집주소: data }) .then(() => setStep('가입성공')); }} /> </Step> <Step show={step === '가입성공'}> <가입완료 onNext={() => setStep('가입방식')} /> </Step> </PageLayout> ); }
기존 형태는 step 상태와 step 상태에 따른 조건부 렌더링하는 기능이기 때문에 이를 한단계 더 추상화하면
<Step />
컴포넌트와 같이 구현할 수 있다.👨🏻💻 퍼널 라이브러리화 하기
Step 컴포넌트로 추상화해도 외부에서 step 상태를 관리해야한다. step 상태도 퍼널 내부에서 관리하는 방식으로 다시 한번 더 추상화해보자
// hooks/useFunnel.tsx import { Children, isValidElement, ReactNode, useState } from 'react'; type FunnelProps = { children: ReactNode; }; type StepProps<T extends string> = { name: T; children?: ReactNode; }; export function useFunnel<T extends string>(initStep: T) { const [step, setStep] = useState<T>(initStep); const Step = (props: StepProps<T>) => { return <>{props.children}</>; }; const Funnel = ({ children }: FunnelProps) => { const validElement = Children.toArray(children).filter(isValidElement); const targetElement = validElement.find((children) => (children.props as StepProps<T>).name === step); if (!targetElement) { return null; } return <>{targetElement}</>; }; return [Object.assign(Funnel, { Step: (props: StepProps<T>) => <Step {...props} /> }), setStep] as const; }
// App.tsx function App() { const [Funnel, setStep] = useFunnel<'가입방식' | '주민번호' | '집주소' | '가입성공'>('가입방식'); const [registerData, setRegisterData] = useState<RegisterData>(InitRegisterData); return ( <PageLayout> <Funnel> <Funnel.Step name="가입방식"> <가입방식 onNext={(data) => { setRegisterData((prev) => ({ ...prev, 가입방식: data })); setStep('주민번호'); }} /> </Funnel.Step> <Funnel.Step name="주민번호"> <주민번호 onNext={(data) => { setRegisterData((prev) => ({ ...prev, 주민번호: data })); setStep('집주소'); }} /> </Funnel.Step> <Funnel.Step name="집주소"> <집주소 onNext={(data) => { axios .post('/api/user', { ...registerData, 집주소: data }) .then(() => setStep('가입성공')); }} /> </Funnel.Step> <Funnel.Step name="가입성공"> <가입완료 onNext={() => setStep('가입방식')} /> </Funnel.Step> </Funnel> </PageLayout> ); }
useFunnel.tsx
- Funnel 패턴을 추상화한 것으로 합성 컴포넌트 패턴을 이용하여 Funnel 을 정의했다.
export function useFunnel<T extends string>(initStep: T) { const [step, setStep] = useState<T>(initStep); const Step = (props: StepProps<T>) => { return <>{props.children}</>; }; const Funnel = ({ children }: FunnelProps) => { const validElement = Children.toArray(children).filter(isValidElement); const targetElement = validElement.find((children) => (children.props as StepProps<T>).name === step); if (!targetElement) { return null; } return <>{targetElement}</>; }; return [Object.assign(Funnel, { Step: (props: StepProps<T>) => <Step {...props} /> }), setStep] as const; }
- useFunnel 내부에 step 을 두고 funnel 내부에 step 상태를 관리할 수 있게 하였다.
- Funnel 컴포넌트 및 Step 컴포넌트를 정의하고 Object.assign 을 통해
Funnel
과Funnel.Step
와 같은 형태로 합성하여 사용할 수 있게 하였다.
Funnel
컴포넌트는 children 이 유효한지 판단한 후에 하위 컴포넌트(Step) 에 step 상태와 일치하는 컴포넌트가 있는 경우 반환하여 렌더링되게 하였다.
Step
컴포넌트는 Step 컴포넌트의 children 을 반환하여가입방식
주민번호
페이지와 같은 해당 name 과 일치하는 페이지들을 반환하도록 하였다.
- 마지막으로 사용하는 패턴으로
[Funnel 의 합성 컴포넌트, setState]
와 같은 형태로 정의했다.
그래서 사용하는 곳에서 아래와 같이
Funnel
안에 Funnel.Step name
으로 각 단계별로 정의해서 사용하면 된다.<Funnel> <Funnel.Step name="가입방식"> <가입방식 onNext={(data) => { setRegisterData((prev) => ({ ...prev, 가입방식: data })); setStep('주민번호'); }} /> </Funnel.Step> <Funnel.Step name="주민번호"> <주민번호 onNext={(data) => { setRegisterData((prev) => ({ ...prev, 주민번호: data })); setStep('집주소'); }} /> </Funnel.Step> <Funnel.Step name="집주소"> <집주소 onNext={(data) => { axios .post('/api/user', { ...registerData, 집주소: data }) .then(() => setStep('가입성공')); }} /> </Funnel.Step> <Funnel.Step name="가입성공"> <가입완료 onNext={() => setStep('가입방식')} /> </Funnel.Step> </Funnel>
🚀 결과 화면
추가 구현 내용
현재는 상태로 페이지를 전환했는데 URL 의 path 까지 고려하여 렌더링되도록 변경할 수 있다. 이는 추후에 업데이트 할 수 있으면 하고자 한다.