HooksuseFunnel

useFunnel

๋‹จ๊ณ„๋ณ„ UI ํ๋ฆ„(ํผ๋„)์„ ์„ ์–ธ์ ์œผ๋กœ ๊ด€๋ฆฌํ•˜๋Š” Hook์ž…๋‹ˆ๋‹ค.

์–ธ์ œ ์‚ฌ์šฉํ•˜๋‚˜์š”?

  • ๐Ÿ“ ํšŒ์›๊ฐ€์ž… ํ”Œ๋กœ์šฐ
  • ๐Ÿ’ณ ๊ฒฐ์ œ ํ”„๋กœ์„ธ์Šค
  • ๐ŸŽฏ ์˜จ๋ณด๋”ฉ ํŠœํ† ๋ฆฌ์–ผ
  • ๐Ÿ“‹ ์„ค๋ฌธ์กฐ์‚ฌ, ํผ ์œ„์ €๋“œ

์ฃผ์š” ํŠน์ง•

1. ์„ ์–ธ์  UI

Funnel/Step ์ปดํฌ๋„ŒํŠธ๋กœ ์ง๊ด€์ ์ธ ๊ตฌ์กฐ

2. ์ปจํ…์ŠคํŠธ ๊ด€๋ฆฌ

์Šคํ… ๊ฐ„ ๋ฐ์ดํ„ฐ ์ž๋™ ๋ˆ„์ 

3. ๋ธŒ๋ผ์šฐ์ € ํžˆ์Šคํ† ๋ฆฌ ์ง€์›

๋’ค๋กœ๊ฐ€๊ธฐ ์‹œ ์ด์ „ ์Šคํ… + ๋ฐ์ดํ„ฐ ๋ณต์›

4. ํ”„๋ ˆ์ž„์›Œํฌ ์–ด๋Œ‘ํ„ฐ

Memory, Browser, Next.js, React Router ์ง€์›


๊ธฐ๋ณธ ์‚ฌ์šฉ๋ฒ•

import { useFunnel } from '@frontend-toolkit-js/hooks';
 
const STEPS = ['email', 'password', 'profile', 'done'] as const;
 
function SignUpFunnel() {
  const funnel = useFunnel(STEPS, {
    initialStep: 'email',
  });
 
  return (
    <funnel.Funnel>
      <funnel.Step name="email">
        <div>
          <h2>์ด๋ฉ”์ผ ์ž…๋ ฅ</h2>
          <button onClick={() => funnel.history.push('password')}>
            ๋‹ค์Œ
          </button>
        </div>
      </funnel.Step>
 
      <funnel.Step name="password">
        <div>
          <h2>๋น„๋ฐ€๋ฒˆํ˜ธ ์„ค์ •</h2>
          <button onClick={() => funnel.history.back()}>์ด์ „</button>
          <button onClick={() => funnel.history.push('profile')}>
            ๋‹ค์Œ
          </button>
        </div>
      </funnel.Step>
 
      <funnel.Step name="profile">
        <div>
          <h2>ํ”„๋กœํ•„ ์„ค์ •</h2>
          <button onClick={() => funnel.history.back()}>์ด์ „</button>
          <button onClick={() => funnel.history.push('done')}>
            ์™„๋ฃŒ
          </button>
        </div>
      </funnel.Step>
 
      <funnel.Step name="done">
        <div>
          <h2>๊ฐ€์ž… ์™„๋ฃŒ!</h2>
        </div>
      </funnel.Step>
    </funnel.Funnel>
  );
}

์ปจํ…์ŠคํŠธ๋กœ ๋ฐ์ดํ„ฐ ์ „๋‹ฌ

์Šคํ… ๊ฐ„ ๋ฐ์ดํ„ฐ๋ฅผ ์ „๋‹ฌํ•˜๋ ค๋ฉด history.push์˜ ๋‘ ๋ฒˆ์งธ ์ธ์ž๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

interface SignUpContext {
  email?: string;
  password?: string;
  name?: string;
}
 
function SignUpFunnel() {
  const funnel = useFunnel<(typeof STEPS)[number], SignUpContext>(STEPS, {
    initialStep: 'email',
    initialContext: {},
  });
 
  return (
    <funnel.Funnel>
      <funnel.Step name="email">
        <EmailStep
          onNext={(email) => funnel.history.push('password', { email })}
        />
      </funnel.Step>
 
      <funnel.Step name="password">
        <PasswordStep
          onNext={(password) => funnel.history.push('profile', { password })}
          onBack={() => funnel.history.back()}
        />
      </funnel.Step>
 
      <funnel.Step name="profile">
        <ProfileStep
          onNext={(name) => funnel.history.push('done', { name })}
          onBack={() => funnel.history.back()}
        />
      </funnel.Step>
 
      <funnel.Step name="done">
        {/* ๋ˆ„์ ๋œ ์ปจํ…์ŠคํŠธ ์‚ฌ์šฉ */}
        <p>ํ™˜์˜ํ•ฉ๋‹ˆ๋‹ค, {funnel.context.name}๋‹˜!</p>
        <p>์ด๋ฉ”์ผ: {funnel.context.email}</p>
      </funnel.Step>
    </funnel.Funnel>
  );
}

๋ฐ์ดํ„ฐ๋Š” ์ž๋™์œผ๋กœ ๋ˆ„์ ๋ฉ๋‹ˆ๋‹ค:

// email step
funnel.history.push('password', { email: 'user@example.com' });
// context: { email: 'user@example.com' }
 
// password step
funnel.history.push('profile', { password: '1234' });
// context: { email: 'user@example.com', password: '1234' }
 
// profile step
funnel.history.push('done', { name: 'ํ™๊ธธ๋™' });
// context: { email: 'user@example.com', password: '1234', name: 'ํ™๊ธธ๋™' }

์–ด๋Œ‘ํ„ฐ

์ƒํƒœ ์ €์žฅ ๋ฐฉ์‹์„ ์–ด๋Œ‘ํ„ฐ๋กœ ์„ ํƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

Memory ์–ด๋Œ‘ํ„ฐ (๊ธฐ๋ณธ๊ฐ’)

๋ฉ”๋ชจ๋ฆฌ์—๋งŒ ์ƒํƒœ๋ฅผ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. ์ƒˆ๋กœ๊ณ ์นจ ์‹œ ์ดˆ๊ธฐํ™”๋ฉ๋‹ˆ๋‹ค.

const funnel = useFunnel(STEPS, {
  initialStep: 'email',
  // adapter ๋ฏธ์ง€์ • ์‹œ memory ์–ด๋Œ‘ํ„ฐ ์‚ฌ์šฉ
});

Browser ์–ด๋Œ‘ํ„ฐ

URL ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ์™€ ๋™๊ธฐํ™”ํ•ฉ๋‹ˆ๋‹ค. ๋ธŒ๋ผ์šฐ์ € ๋’ค๋กœ๊ฐ€๊ธฐ/์•ž์œผ๋กœ๊ฐ€๊ธฐ๋ฅผ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค.

import { useFunnel, createBrowserAdapter } from '@frontend-toolkit-js/hooks';
 
const funnel = useFunnel(STEPS, {
  initialStep: 'email',
  adapter: createBrowserAdapter(),
});
 
// URL: ?step=password&context={"email":"user@example.com"}

Next.js App Router

import { useFunnel } from '@frontend-toolkit-js/hooks';
import { useNextAppAdapter } from '@frontend-toolkit-js/hooks/useFunnel/adapters/next-app';
 
function SignUpFunnel() {
  const adapter = useNextAppAdapter();
  const funnel = useFunnel(STEPS, {
    initialStep: 'email',
    adapter,
  });
 
  return <funnel.Funnel>...</funnel.Funnel>;
}

Note: useSearchParams๋ฅผ ์‚ฌ์šฉํ•˜๋ฏ€๋กœ Suspense๋กœ ๊ฐ์‹ธ์•ผ ํ•ฉ๋‹ˆ๋‹ค.

Next.js Pages Router

import { useFunnel } from '@frontend-toolkit-js/hooks';
import { useNextPagesAdapter } from '@frontend-toolkit-js/hooks/useFunnel/adapters/next-pages';
 
function SignUpFunnel() {
  const adapter = useNextPagesAdapter({ shallow: true });
  const funnel = useFunnel(STEPS, {
    initialStep: 'email',
    adapter,
  });
 
  return <funnel.Funnel>...</funnel.Funnel>;
}

React Router v6+

import { useFunnel } from '@frontend-toolkit-js/hooks';
import { useReactRouterAdapter } from '@frontend-toolkit-js/hooks/useFunnel/adapters/react-router';
 
function SignUpFunnel() {
  const adapter = useReactRouterAdapter();
  const funnel = useFunnel(STEPS, {
    initialStep: 'email',
    adapter,
  });
 
  return <funnel.Funnel>...</funnel.Funnel>;
}

API Reference

useFunnel(steps, options)

Parameters

์ด๋ฆ„ํƒ€์ž…์„ค๋ช…
stepsreadonly string[]์Šคํ… ์ด๋ฆ„ ๋ฐฐ์—ด (as const ๊ถŒ์žฅ)
options.initialStepstring์ดˆ๊ธฐ ์Šคํ… (ํ•„์ˆ˜)
options.initialContextobject์ดˆ๊ธฐ ์ปจํ…์ŠคํŠธ ๋ฐ์ดํ„ฐ
options.onStepChange(step, context) => void์Šคํ… ๋ณ€๊ฒฝ ์ฝœ๋ฐฑ
options.adapterFunnelAdapter์ƒํƒœ ๊ด€๋ฆฌ ์–ด๋Œ‘ํ„ฐ

Returns

์ด๋ฆ„ํƒ€์ž…์„ค๋ช…
currentStepstringํ˜„์žฌ ์Šคํ…
contextobject๋ˆ„์ ๋œ ์ปจํ…์ŠคํŠธ ๋ฐ์ดํ„ฐ
historyFunnelHistoryํžˆ์Šคํ† ๋ฆฌ ๊ด€๋ฆฌ ๊ฐ์ฒด
FunnelComponentํผ๋„ ์ปจํ…Œ์ด๋„ˆ
StepComponent์Šคํ… ์ปดํฌ๋„ŒํŠธ

history ๊ฐ์ฒด

๋ฉ”์„œ๋“œ/์†์„ฑํƒ€์ž…์„ค๋ช…
push(step, data?) => void์ƒˆ ์Šคํ…์œผ๋กœ ์ด๋™ (ํžˆ์Šคํ† ๋ฆฌ ์ถ”๊ฐ€)
replace(step, data?) => voidํ˜„์žฌ ์Šคํ… ๊ต์ฒด (ํžˆ์Šคํ† ๋ฆฌ ์œ ์ง€)
back() => void์ด์ „ ์Šคํ…์œผ๋กœ ์ด๋™
canGoBackboolean๋’ค๋กœ๊ฐ€๊ธฐ ๊ฐ€๋Šฅ ์—ฌ๋ถ€

Funnel / Step ์ปดํฌ๋„ŒํŠธ

<funnel.Funnel>
  <funnel.Step name="step1">
    {/* step1 ๋‚ด์šฉ */}
  </funnel.Step>
  <funnel.Step name="step2">
    {/* step2 ๋‚ด์šฉ */}
  </funnel.Step>
</funnel.Funnel>
  • Funnel: ํ˜„์žฌ ์Šคํ…์— ํ•ด๋‹นํ•˜๋Š” Step๋งŒ ๋ Œ๋”๋ง
  • Step: name prop์œผ๋กœ ์Šคํ… ์‹๋ณ„

์‹ค๋ฌด ํŒจํ„ด

์ง„ํ–‰๋ฅ  ํ‘œ์‹œ

const steps = ['a', 'b', 'c'] as const;
const funnel = useFunnel(steps, { initialStep: 'a' });
 
const currentIndex = steps.indexOf(funnel.currentStep);
const progress = ((currentIndex + 1) / steps.length) * 100;
 
return (
  <div>
    <div style={{ width: `${progress}%`, height: 4, background: 'blue' }} />
    <funnel.Funnel>...</funnel.Funnel>
  </div>
);

์กฐ๊ฑด๋ถ€ ์Šคํ…

<funnel.Step name="payment">
  {needsVerification ? (
    <button onClick={() => funnel.history.push('verify')}>
      ๋ณธ์ธ์ธ์ฆ ํ•„์š”
    </button>
  ) : (
    <button onClick={() => funnel.history.push('complete')}>
      ๊ฒฐ์ œํ•˜๊ธฐ
    </button>
  )}
</funnel.Step>

์ดˆ๊ธฐํ™” (์ฒ˜์Œ๋ถ€ํ„ฐ ๋‹ค์‹œ)

<button onClick={() => funnel.history.replace('email', {})}>
  ์ฒ˜์Œ๋ถ€ํ„ฐ ๋‹ค์‹œ
</button>

replace๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ํžˆ์Šคํ† ๋ฆฌ์— ์Œ“์ด์ง€ ์•Š๊ณ , ๋นˆ ๊ฐ์ฒด {}๋ฅผ ์ „๋‹ฌํ•˜๋ฉด ์ปจํ…์ŠคํŠธ๊ฐ€ ์ดˆ๊ธฐํ™”๋ฉ๋‹ˆ๋‹ค.


์ฃผ์˜์‚ฌํ•ญ

์–ด๋Œ‘ํ„ฐ๋ณ„ ์š”๊ตฌ์‚ฌํ•ญ

์–ด๋Œ‘ํ„ฐ์š”๊ตฌ์‚ฌํ•ญ
Next.js App RouterSuspense๋กœ ๊ฐ์‹ธ๊ธฐ ํ•„์š”
Next.js Pages RouterRouter ์ปจํ…์ŠคํŠธ ๋‚ด์—์„œ ์‚ฌ์šฉ
React Router<BrowserRouter> ๋“ฑ Router ์ปจํ…์ŠคํŠธ ๋‚ด์—์„œ ์‚ฌ์šฉ

๋ธŒ๋ผ์šฐ์ € ๋’ค๋กœ๊ฐ€๊ธฐ ์‹œ context ์œ ์ง€

history.push ํ˜ธ์ถœ ์‹œ ๋‚ด๋ถ€์ ์œผ๋กœ replaceโ†’push ํŒจํ„ด์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

1. replace: ํ˜„์žฌ ํžˆ์Šคํ† ๋ฆฌ ์—”ํŠธ๋ฆฌ์— context ์ €์žฅ
2. push: ์ƒˆ ์Šคํ…์œผ๋กœ ์ด๋™

์ด๋ฅผ ํ†ตํ•ด ๋ธŒ๋ผ์šฐ์ € ๋’ค๋กœ๊ฐ€๊ธฐ ์‹œ์—๋„ ์ด์ „ ์Šคํ…์˜ context๊ฐ€ ์œ ์ง€๋ฉ๋‹ˆ๋‹ค.


TypeScript

์Šคํ…๊ณผ ์ปจํ…์ŠคํŠธ ํƒ€์ž…์ด ์ž๋™์œผ๋กœ ์ถ”๋ก ๋ฉ๋‹ˆ๋‹ค.

const STEPS = ['email', 'password', 'done'] as const;
 
interface MyContext {
  email?: string;
  password?: string;
}
 
const funnel = useFunnel<(typeof STEPS)[number], MyContext>(STEPS, {
  initialStep: 'email',
  initialContext: {},
});
 
funnel.currentStep;  // 'email' | 'password' | 'done'
funnel.context;      // MyContext
 
// ์ž˜๋ชป๋œ ์Šคํ… ์ด๋ฆ„ โ†’ ํƒ€์ž… ์—๋Ÿฌ
funnel.history.push('invalid'); // โŒ Error

๊ด€๋ จ ๋ฌธ์„œ

  • useQueryParams - URL ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ ๊ด€๋ฆฌ