ReactuseFunnel 훅 간단 구현기

토스 Slash에서 제공하는 use-funnel 훅을 보면, 사용자 입력 흐름을 단계적으로 구성하는 Funnel 형식의 UI에 특화되어 있어 꽤 흥미롭게 봤던 기억이 있다. 이전 회사에서 관련 프로젝트를 진행할 때 코드를 참고하여 구현해봤었고, 최근에 다시 이 구조가 필요해져서 새롭게 정리해보고자 한다.

구현 목표 🎯

🧩 기본 컴포넌트 만들기

  1. Funnel 컴포넌트 여러 개의 Step 중 현재 step에 해당하는 것만 보여준다. 전달된 children 중 유효한 Step만 필터링하여 처리한다.
import {
	Children,
	isValidElement,
	type ReactElement,
	type ReactNode,
} from 'react';
 
interface FunnelProps<T> {
	steps: T[];
	step: T;
	children: ReactNode;
}
 
export function Funnel<T extends string>({ steps, step, children }: FunnelProps<T>) {
	const validChildren = Children.toArray(children).reduce<ReactElement<StepProps<T>>[]>(
		(p, child) => {
			if (!isValidElement(child)) return p;
			if (!steps.includes((child.props as StepProps<T>)?.name ?? '')) return p;
			return [...p, child as ReactElement<StepProps<T>>];
		},
		[],
	);
 
	const targetStep = validChildren.find((child) => child.props.name === step);
	return <>{targetStep}</>;
}
  1. Step 컴포넌트 각 단계에서 보여줄 UI를 감싸는 용도다.
interface StepProps<T> {
	name: T;
	children: ReactNode;
}
export const Step = <T extends string>({ children }: StepProps<T>) => {
	return <>{children}</>;
};

🔁 useFunnel 훅 구현

기본 정의

useFunnel 훅은 다음 두 가지 값을 인자로 받는다.

import { useState, useMemo } from 'react';
 
interface useFunnelProps<T, S> {
	steps: T[];
	initialState?: S;
}
 
export default function useFunnel<
	T extends string,
	S extends Record<string, any> = any,
>({ steps, initialState = {} as S }: useFunnelProps<T, S>) {
	if (!steps.length) throw Error('you must set steps');
 
	const [_state, _setState] = useState<S>(initialState);
	const [_step, _setStep] = useState<T>(steps[0]);
 
	// ...
}

FunnelComponent

실제 라이브러리에서는 QueryParam을 통해 현재 스텝을 가져오지만, 여기서는 단순히 state에 저장된 _step을 사용한다. Funnel.Step 형식으로 사용할 수 있도록 컴바운드 컴포넌트 방식으로 구성한다.

	const FunnelComponent = useMemo(() =>
		Object.assign(
			function _Funnel(props: RouteFunnelProps<T>) {
				return <Funnel<T> steps={steps} step={_step} {...props} />;
			},
			{ Step }
		),
	[_step]);
);

setFunnel 함수 구현

상태(state)와 단계(step)를 동시에 업데이트할 수 있다.

nextState가 함수일 경우 콜백 방식으로도 업데이트 가능하게 했다.

const setFunnel: SetFunnel<T, S> = (nextStep, nextState) => {
	_setStep(nextStep);
	_setState((prev) =>
		typeof nextState === 'function'
			? nextState(prev)
			: { ...prev, ...nextState },
	);
};

🧪 실제 사용 예시

간단한 설문 폼을 만들어 테스트했다.

function App() {
	const [Funnel, state, setFunnel] = useFunnel<string>({
		steps,
		initialState: {},
	});
 
	const handleSubmit = (idx: number) => {
		const nextIdx = idx + 1;
		if (nextIdx === surveyQuestions.length) setFunnel('결과');
		else setFunnel(surveyQuestions[nextIdx].id);
	};
 
	return (
		<div>
			<Funnel>
				{surveyQuestions.map((q, idx) => (
					<Funnel.Step name={q.id} key={q.id}>
						<QuestionForm
							title={q.question}
							options={q.options}
							selectedIdx={q.options.findIndex((opt) => opt === state[q.id])}
							onSubmit={() => handleSubmit(idx)}
							onSelected={(i) => setFunnel(state.step, { [q.id]: q.options[i] })}
						/>
					</Funnel.Step>
				))}
				<Funnel.Step name="결과">
					<dl>
						{Object.entries(state).map(([key, value]) => (
							<Fragment key={key}>
								<dt>{surveyQuestions.find((q) => q.id === key)?.question}</dt>
								<dd>{value as string}</dd>
							</Fragment>
						))}
					</dl>
				</Funnel.Step>
			</Funnel>
		</div>
	);
}

✅ 마무리

라이브러리처럼 완전한 기능은 아니지만, 단계 전환과 상태 관리를 단순화한 Funnel 흐름을 직접 구현해본 것에 의의를 두고자한다ㅎㅎ 프로덕션 수준의 안정성과 유연함은 부족하지만, 테스트나 사이드 프로젝트에는 쓸 수 있을 것 같다.


토스 Slash에서 use-funnel코드를 보면, 각 단계를 QueryParam으로 컨트롤하기 때문에 이때 state 유실을 막기 위해서 sessionStorage를 활용하는 것 같다.(참 멋진 코드)


참고 Toss Slash @useFunnel