본문으로 건너뛰기

useFunnel

단계별(멀티스텝) UI 흐름을 간단히 관리하고, 옵션에 따라 브라우저 히스토리(뒤로가기/앞으로가기)와 동기화할 수 있는 커스텀 React Hook입니다.

  • <Funnel> / <Funnel.Step> 컴포넌트 wrapper로 스텝별 조건 렌더링을 간결하게 구현
  • next() / prev()로 단계 이동
  • history: truepushState/popstate 기반으로 브라우저 뒤/앞 이동과 상태 동기화
  • meta를 통해 **현재 스텝 관련 부가정보(파생 상태)**를 함께 제공

🔗 사용법

const { Funnel, step, next, prev, setStep, meta } = useFunnel(steps, options?);

📥 매개변수

이름타입설명
stepsreadonly string[]단계 목록 / 리터럴 배열(as const) 권장
optionsUseFunnelOptions?선택값 / 히스토리 연동 여부 설정

🔧 options 구조

필드타입설명
historyboolean?true면 브라우저 히스토리와 동기화 (기본: true)

🔁 반환값

타입설명
Funnel컴포넌트 & 정적 Step 컴포넌트현재 step을 컨텍스트로 내려줌. Funnel.Stepname이 현재 스텝일 때만 children 렌더
stepS[number]현재 스텝 값
next() => void다음 스텝으로 이동. 마지막 스텝에서는 유지(히스토리도 push 없음)
prev() => void이전 스텝으로 이동. 첫 스텝에서는 유지. history: truewindow.history.back() 실행
setStep(step: S[number]) => void특정 스텝으로 직접 이동
metaFunnelMeta현재 스텝을 기준으로 계산된 파생 메타데이터

📊 meta 구조 (FunnelMeta)

필드타입설명
currentIndexnumber현재 스텝의 0-based 인덱스
lengthnumber전체 스텝 개수
isFirstboolean현재 스텝이 첫 번째인지 여부
isLastboolean현재 스텝이 마지막인지 여부
canPrevboolean이전 스텝으로 이동할 수 있는지 여부
canNextboolean다음 스텝으로 이동할 수 있는지 여부

✅ 예시

1) 기본 예시 (히스토리 연동: 기본 활성)

  • 별도의 옵션을 주지 않아도, 브라우저 뒤로가기/앞으로가기 버튼과 동기화됩니다.
import { useFunnel } from 'hookdle';

const steps = ['Intro', 'Calendar', 'Basic', 'Confirm'] as const;

export default function CreateEvent() {
const { Funnel, step, next, prev, meta } = useFunnel(steps);

return (
<div>
<h3>
Step: {step} ({meta.currentIndex + 1} / {meta.length})
</h3>

<Funnel step={step}>
<Funnel.Step name="Intro">인트로</Funnel.Step>
<Funnel.Step name="Calendar">달력</Funnel.Step>
<Funnel.Step name="Basic">기본 정보</Funnel.Step>
<Funnel.Step name="Confirm">확인</Funnel.Step>
</Funnel>

<button onClick={prev} disabled={!meta.canPrev}>
이전
</button>
<button onClick={next} disabled={!meta.canNext}>
다음
</button>
</div>
);
}

2) 히스토리 비활성 (내부 상태만)

  • 필요 시 history: false로 지정하면, 브라우저 히스토리와는 연동하지 않고 내부 상태만으로 동작합니다.
const steps = ['Intro', 'Calendar', 'Basic', 'Confirm'] as const;

function CreateEventWithoutHistory() {
const { Funnel, step, next, prev, meta } = useFunnel(steps, { history: false });

return (
<>
<Funnel step={step}>
<Funnel.Step name="Intro">인트로</Funnel.Step>
<Funnel.Step name="Calendar">달력</Funnel.Step>
<Funnel.Step name="Basic">기본</Funnel.Step>
<Funnel.Step name="Confirm">확인</Funnel.Step>
</Funnel>

<button onClick={prev} disabled={!meta.canPrev}>
이전
</button>
<button onClick={next} disabled={!meta.canNext}>
다음
</button>
</>
);
}

3) 특정 스텝으로 점프

const steps = ['Intro', 'Form', 'Confirm'] as const;

function JumpExample() {
const { Funnel, step, setStep, meta } = useFunnel(steps);

return (
<>
<p>
현재 스텝: {step} ({meta.currentIndex + 1}/{meta.length})
</p>

<Funnel step={step}>
<Funnel.Step name="Intro">Intro</Funnel.Step>
<Funnel.Step name="Form">Form</Funnel.Step>
<Funnel.Step name="Confirm">Confirm</Funnel.Step>
</Funnel>

<button onClick={() => setStep('Confirm')}>바로 Confirm으로</button>
</>
);
}

🧩 팁

  • 리터럴 배열: const steps = ['A','B','C'] as conststep/setStep/Funnel.Step name에 자동완성/타입 안전
  • 메타 활용: meta를 활용해 버튼 disabled 처리, 진행률 표시(currentIndex / length) 등을 쉽게 구현 가능

💡 만약 이 훅이 없다면?

  • 각 스텝에서 조건 렌더링을 매번 구현해야 하고,
  • 상태/히스토리/뒤로가기 동작을 직접 동기화해야 하며,
  • 경계/중복 로직 관리가 번거롭습니다.
import React, { useCallback, useMemo, useState, createContext, useContext } from 'react';

type Step = 'Intro' | 'Calendar' | 'Basic' | 'Confirm';
const steps = ['Intro', 'Calendar', 'Basic', 'Confirm'] as const;

const StepContext = createContext<Step | null>(null);

function Funnel({ step, children }: { step: Step; children: React.ReactNode }) {
return <StepContext.Provider value={step}>{children}</StepContext.Provider>;
}

Funnel.Step = function FunnelStep({ name, children }: { name: Step; children: React.ReactNode }) {
const step = useContext(StepContext);
if (step == null) throw new Error('Funnel.Step는 <Funnel> 내부에서 사용해야 합니다.');
return step === name ? <>{children}</> : null;
};

export default function ManualFunnelBasic() {
const [step, setStep] = useState<Step>(steps[0]);

const indexMap = useMemo(() => {
const m = new Map<Step, number>();
steps.forEach((s, i) => m.set(s, i));
return m;
}, []);

const next = useCallback(() => {
setStep((prev) => {
const i = indexMap.get(prev) ?? -1;
return i >= 0 && i < steps.length - 1 ? steps[i + 1] : prev;
});
}, [indexMap]);

const prev = useCallback(() => {
setStep((prev) => {
const i = indexMap.get(prev) ?? -1;
return i > 0 ? steps[i - 1] : prev;
});
}, [indexMap]);

return (
<div>
<h3>Step: {step}</h3>
<Funnel step={step}>
<Funnel.Step name="Intro">인트로</Funnel.Step>
<Funnel.Step name="Calendar">달력</Funnel.Step>
<Funnel.Step name="Basic">기본</Funnel.Step>
<Funnel.Step name="Confirm">확인</Funnel.Step>
</Funnel>

<div style={{ marginTop: 8 }}>
<button onClick={prev}>이전</button>
<button onClick={next} style={{ marginLeft: 8 }}>
다음
</button>
<button onClick={() => setStep('Confirm')} style={{ marginLeft: 8 }}>
점프: Confirm
</button>
</div>
</div>
);
}