네~! 제목을 보고 들어오신 분이 있다면 지금부터 제가 퍼널 관련 Hook을 만들어보려고 합니다 ㅎㅎ
요즘 거의 모든 서비스에서 퍼널 관련된 페이지가 많이 보이는 것 같은데 해당 부분을 간편하고 추상적이게 만들어서 사용하면 좋을 것 같아 만들면 좋겠다고 생각하다가 지금에서야 만들게 되었습니다!
개인 프로젝트에서 사용했던 퍼널 관련 페이지를 통해 코드를 설명하고 기존 방식과 퍼널 Hook을 사용한 방식 어떤 개선점이 있었는지 설명해보려고 합니다
퍼널이란 사용자가 웹사이트나 애플리케이션을 방문해서 최종 목표까지 달성하는데 거치는 단계를 의미합니다.
예를들어 계좌 및 카드 개설 하는 퍼널이 있다고 가정하면 아래와 같은 단계를 걸쳐 진행하게 됩니다.
첫번 째 Step - 본인 확인
두번 째 Step - 약관 동의
세번 째 Step - 개설하는 데 필요한 정보 입력
이제 퍼널에 대한 기본적인 개념을 이해하셨다면, 코드로 넘어가볼게요 ㅎㅎ
기존 코드에서는 다음과 같이 Step State 관리를 하고 있습니다.
- Funnel 단계를 0~2 숫자 타입의 state로 관리
- 0~2 Step State에 조건에 따라
<Temrs />
<BasicInfo />
<CardInfo />
를 각각 조건부 렌더링
const LAST_STEP = 3
const Apply = ({
onSubmit,
}: {
onSubmit: (applyValues: ApplyValues) => void
}) => {
const user = useRecoilValue(userState)
const { id } = useParams()
const storageKey = `apply-${user?.uid}-${id}`
const [applyValues, setApplyValues] = useLocalStorage<Partial<ApplyValues>>(
storageKey,
{
userId: user?.uid,
cardId: id,
step: 0,
},
)
useEffect(() => {
if (applyValues.step === 3) {
localStorage.removeItem(storageKey)
onSubmit({
...applyValues,
appliedAt: new Date(),
status: APPLY_STATUS.READY,
} as ApplyValues)
}
}, [applyValues, onSubmit, storageKey])
const handleTermsChange = (terms: ApplyValues['terms']) => {
setApplyValues((prevValues: ApplyValues) => ({
...prevValues,
terms,
step: (prevValues.step as number) + 1,
}))
}
const handleBasicInfoChange = (values: BasicInfoValues) => {
setApplyValues((prevValues: ApplyValues) => ({
...prevValues,
...values,
step: (prevValues.step as number) + 1,
}))
}
const handleCardInfoChange = (cardInfoValues: CardInfoValues) => {
setApplyValues((prevValues: ApplyValues) => ({
...prevValues,
...cardInfoValues,
step: (prevValues.step as number) + 1,
}))
}
return (
<>
<ProgressBar progress={(applyValues?.step as number) / LAST_STEP} />
{applyValues.step === 0 && <Terms onNext={handleTermsChange} />}
{applyValues.step === 1 && <BasicInfo onNext={handleBasicInfoChange} />}
{applyValues.step === 2 && <CardInfo onNext={handleCardInfoChange} />}
</>
)
}
export default Apply
기존 코드를 보았을 때 코드의 흐름을 잘 파악할 수 있지만 몇 가지 개선점을 도출 해낼 수 있습니다.
- Apply Page를 제외한 Funnel이 필요한 Component가 필요하다면 어떻게 해야할까요?
- 즉 코드의 재사용성이 필요하다고 생각했습니다.
- applyValues는 각 Funnel의 value를 관리하는 state인데 step을 같이 관리해야 할까요?
- 즉 state도 관심사 분리가 필요하다고 생각했습니다.
- step이 number로 관리되고 있으니 어떤 컴포넌트가 렌더링 될지 파악하기 힘들기 때문에 string으로 관리하면 어떨가 싶었다.
useFunnel Hook
- 지금 현재 어떤 Step의 Component가 렌더링 될 것인지 결정 => Funnel
- setStep 함수로 Step의 update 관리 => setStep
- 현재 Step state 관리 => currentStep
- 현재 Step state의 단계 => currentStepIndex
Apply Component
- useFunnel hook을 선언하여 Funnel의 children 중 현재 단계만 해당하는 부분을 렌더링
- 숫자 타입의 0~2 단계를 사용하기보다 보다 문자열 타입의 ['terms', 'basicInfo', 'cardInfo'] 을 사용하여 단계를 정의
개선사항
- 코드의 가독성 향상
- useFunnel 훅을 사용하여 step 관리 로직을 추상화하고, Apply 컴포넌트 내의 코드가 더 간결하고 명확해졌습니다.
- 재사용성 증가
- useFunnel 훅은 퍼널을 사용하는 컴포넌트에서 재사용할 수 있어, 동일한 step 관리 로직을 여러 곳에서 중복 작성할 필요가 없어졌습니다.
- 유지보수 용이성
- step을 문자열로 정의하여 step 순서 수정이나 새로운 step 추가가 쉬워졌고, step 관리 로직이 useFunnel 훅에 집중되어 있어, 수정이 필요한 경우 쉽게 유지보수 할 수 있게 되었습니다.
// useFunnel.tsx
type StepName = string
interface StepProps {
children: React.ReactNode
name: string
}
type UseFunnelResult = [
React.FC<{ children: React.ReactNode }>,
(step: StepName) => void,
StepName,
number,
]
const STEP_STORAGE_KEY = 'funnel-step'
export const useFunnel = (steps: readonly StepName[]): UseFunnelResult => {
const [currentStep, setCurrentStep] = useLocalStorage<StepName>(
STEP_STORAGE_KEY,
steps[0],
)
const Funnel: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { open } = useAlertContext()
const validElement = React.Children.toArray(children)
.filter(isValidElement)
.filter((i) => steps.includes((i.props as StepProps).name))
const targetStep = validElement.find(
(child) => (child.props as StepProps).name === currentStep,
)
if (targetStep === null) {
open({
title: `${currentStep}이 존재하지 않습니다.`,
buttonLabel: '확인',
onComplete: () => {},
})
}
return <>{targetStep}</>
}
const setStep = (step: StepName) => {
if (steps.includes(step)) {
setCurrentStep(step)
}
}
const currentStepIndex = steps.indexOf(currentStep)
return [Funnel, setStep, currentStep, currentStepIndex]
}
// Apply.tsx
const LAST_STEP = 3
const Apply = ({
onSubmit,
}: {
onSubmit: (applyValues: ApplyValues) => void
}) => {
const [Funnel, setStep, currentStep, stepCount] = useFunnel([
'terms',
'basicInfo',
'cardInfo',
'complete',
] as const)
return (
<>
<ProgressBar progress={stepCount / LAST_STEP} />
<Funnel>
<FunnelStep name="terms">
<Terms onNext={handleTermsChange} />
</FunnelStep>
<FunnelStep name="basicInfo">
<BasicInfo onNext={handleBasicInfoChange} />
</FunnelStep>
<FunnelStep name="cardInfo">
<CardInfo onNext={handleCardInfoChange} />
</FunnelStep>
</Funnel>
</>
)
}
export default Apply
https://www.slash.page/ko/libraries/react/use-funnel/README.i18n#initialize
https://github.com/toss/slash/blob/main/packages/react/use-funnel/src/Funnel.tsx
https://github.com/toss/slash/blob/main/packages/react/use-funnel/src/useFunnel.tsx