이전 글에 이어서, 이번에는 useFunnel
커스텀 훅이 어떻게 동작하는지 분석해보자. 구현 원리가 복잡하여 다소 긴 글이 될 것 같다. 먼저 전체 코드를 살펴보자.
/** @tossdocs-ignore */
import { assert } from '@toss/assert';
import { safeSessionStorage } from '@toss/storage';
import { useQueryParam } from '@toss/use-query-param';
import { QS } from '@toss/utils';
import deepEqual from 'fast-deep-equal';
import { useRouter } from 'next/router.js';
import { SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from 'react-query';
import { Funnel, FunnelProps, Step, StepProps } from './Funnel';
import { NonEmptyArray } from './models';
interface SetStepOptions {
stepChangeType?: 'push' | 'replace';
preserveQuery?: boolean;
query?: Record<string, any>;
}
type RouteFunnelProps<Steps extends NonEmptyArray<string>> = Omit<FunnelProps<Steps>, 'steps' | 'step'>;
type FunnelComponent<Steps extends NonEmptyArray<string>> = ((props: RouteFunnelProps<Steps>) => JSX.Element) & {
Step: (props: StepProps<Steps>) => JSX.Element;
};
const DEFAULT_STEP_QUERY_KEY = 'funnel-step';
export const useFunnel = <Steps extends NonEmptyArray<string>>(
steps: Steps,
options?: {
/**
* 이 query key는 현재 스텝을 query string에 저장하기 위해 사용됩니다.
* @default 'funnel-step'
*/
stepQueryKey?: string;
initialStep?: Steps[number];
onStepChange?: (name: Steps[number]) => void;
}
): readonly [FunnelComponent<Steps>, (step: Steps[number], options?: SetStepOptions) => void] & {
withState: <StateExcludeStep extends Record<string, unknown> & { step?: never }>(
initialState: StateExcludeStep
) => [
FunnelComponent<Steps>,
StateExcludeStep,
(
next:
| Partial<StateExcludeStep & { step: Steps[number] }>
| ((next: Partial<StateExcludeStep & { step: Steps[number] }>) => StateExcludeStep & { step: Steps[number] })
) => void
];
} => {
const router = useRouter();
const stepQueryKey = options?.stepQueryKey ?? DEFAULT_STEP_QUERY_KEY;
assert(steps.length > 0, 'steps가 비어있습니다.');
const FunnelComponent = useMemo(
() =>
Object.assign(
function RouteFunnel(props: RouteFunnelProps<Steps>) {
// eslint-disable-next-line react-hooks/rules-of-hooks
const step = useQueryParam<Steps[number]>(stepQueryKey) ?? options?.initialStep;
assert(
step != null,
`표시할 스텝을 ${stepQueryKey} 쿼리 파라미터에 지정해주세요. 쿼리 파라미터가 없을 때 초기 스텝을 렌더하려면 useFunnel의 두 번째 파라미터 options에 initialStep을 지정해주세요.`
);
return <Funnel<Steps> steps={steps} step={step} {...props} />;
},
{
Step,
}
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const setStep = useCallback(
(step: Steps[number], setStepOptions?: SetStepOptions) => {
const { preserveQuery = true, query = {} } = setStepOptions ?? {};
const url = `${QS.create({
...(preserveQuery ? router.query : undefined),
...query,
[stepQueryKey]: step,
})}`;
options?.onStepChange?.(step);
switch (setStepOptions?.stepChangeType) {
case 'replace':
router.replace(url, undefined, {
shallow: true,
});
return;
case 'push':
default:
router.push(url, undefined, {
shallow: true,
});
return;
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[options, router]
);
/**
* 아래부터 withState() 구현입니다.
* 외부 함수로 분리하기 어려워 당장은 inline 해둡니다.
* FIXME: @junyong-lee withState() 구현을 외부 함수로 분리하기
*/
type S = Record<string, unknown>;
const [state, _setState] = useFunnelState<S>({});
type Step = Steps[number];
type NextState = S & { step?: Step };
const nextPendingStepRef = useRef<Step | null>(null);
const nextStateRef = useRef<Partial<S> | null>(null);
const setState = useCallback(
(next: Partial<NextState> | ((next: Partial<NextState>) => NextState)) => {
let nextStepValue: Partial<NextState>;
if (typeof next === 'function') {
nextStepValue = next(state);
} else {
nextStepValue = next;
}
if (nextStepValue.step != null) {
nextPendingStepRef.current = nextStepValue.step;
}
nextStateRef.current = nextStepValue;
_setState(next);
},
[_setState, state]
);
useEffect(() => {
if (nextPendingStepRef.current == null) {
return;
}
if (deepEqual(nextStateRef.current, state)) {
setStep(nextPendingStepRef.current);
nextPendingStepRef.current = null;
}
}, [setStep, state]);
const initializedRef = useRef(false);
function withState<State extends Record<string, unknown>>(initialState: State) {
if (!initializedRef.current) {
setState(initialState);
initializedRef.current = true;
}
return [FunnelComponent, state, setState] as const;
}
return Object.assign([FunnelComponent, setStep] as const, { withState }) as unknown as readonly [
FunnelComponent<Steps>,
(step: Steps[number], options?: SetStepOptions) => Promise<void>
] & {
withState: <StateExcludeStep extends Record<string, unknown> & { step?: never }>(
initialState: StateExcludeStep
) => [
FunnelComponent<Steps>,
StateExcludeStep,
(
next:
| Partial<StateExcludeStep & { step: Steps[number] }>
| ((next: Partial<StateExcludeStep & { step: Steps[number] }>) => StateExcludeStep & { step: Steps[number] })
) => void
];
};
};
type FunnelStateId = `use-funnel-state__${string}`;
function createFunnelStateId(id: string): FunnelStateId {
return `use-funnel-state__${id}`;
}
/**
* NOTE: 이후 Secure Storage 등 다른 스토리지를 사용하도록 스펙이 변경될 수 있으므로, Asynchronous 함수로 만듭니다.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function createFunnelStorage<T>(funnelStateId: FunnelStateId, storageType = 'sessionStorage'): FunnelStorage<T> {
switch (storageType) {
case 'sessionStorage':
return {
get: async () => {
const d = safeSessionStorage.get(funnelStateId);
if (d == null) {
return null;
}
return JSON.parse(d) as Partial<T>;
},
set: async (value: Partial<T>) => {
safeSessionStorage.set(funnelStateId, JSON.stringify(value));
},
clear: async () => {
safeSessionStorage.remove(funnelStateId);
},
};
default:
throw new Error('정확한 스토리지 타입을 명시해주세요.');
}
}
interface FunnelStorage<T> {
get: () => Promise<Partial<T> | null>;
set: (value: Partial<T>) => Promise<void>;
clear: () => Promise<void>;
}
function useFunnelState<T extends Record<string, any>>(
defaultValue: Partial<T>,
options?: { storage?: FunnelStorage<T> }
) {
const { pathname, basePath } = useRouter();
const storage = options?.storage ?? createFunnelStorage<T>(createFunnelStateId(`${basePath}${pathname}`));
const persistentStorage = useRef(storage).current;
const initialState = useQuery({
queryFn: () => {
return persistentStorage.get();
},
suspense: true,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
}).data;
const [_state, _setState] = useState<Partial<T>>(initialState ?? defaultValue);
const setState = useCallback(
(state: SetStateAction<Partial<T>>) => {
_setState(prev => {
/**
* React Batch Update 그리고 Local State와 Persistent Storage의 State의 일관성을 위해서 이렇게 작성했습니다.
*/
if (typeof state === 'function') {
const newState = state(prev);
persistentStorage.set(newState);
return newState;
} else {
persistentStorage.set(state);
return state;
}
});
},
[persistentStorage]
);
const clearState = useCallback(() => {
_setState({});
persistentStorage.clear();
}, [persistentStorage]);
return [_state, setState, clearState] as const;
}
악. 길다... 맨 위 코드부터 차근차근 보자.
interface SetStepOptions {
stepChangeType?: 'push' | 'replace';
preserveQuery?: boolean;
query?: Record<string, any>;
}
stepChangeType
은 스텝 페이지가 넘어갈 때 동작하는 라우터 방식을 push
로 할지, replace
로 할지 결정하는 부분이다. 이 것만 읽어보면 SetStepOptions
가 어떤 일을 하는지 알기 어렵다.
type RouteFunnelProps<Steps extends NonEmptyArray<string>> = Omit<FunnelProps<Steps>, 'steps' | 'step'>;
type FunnelComponent<Steps extends NonEmptyArray<string>> = ((props: RouteFunnelProps<Steps>) => JSX.Element) & {
Step: (props: StepProps<Steps>) => JSX.Element;
};
아직까지는 RouteFunnelProps
가 어떤 일을 하는지 잘 모르겠지만, FunnelProps
에서 Omit
을 활용하여 steps
,step
이라는 요소를 제거한 타입을 정의한다. 그럼 React Element인 children
만이 남는다.
자 여기서 이제 FunnelComponent
의 타입을 정의하기 위해서 교집합 연산을 사용하는데, 이게 무엇인지 궁금하다면 이전에 Effective TypeScript 스터디하면서 정리했던 내용을 참고해볼 수 있다 ㅎㅎ (깨알 광고) &
연산자를 통해서 두 타입을 말 그대로 합쳐준다!! 아래 예시 코드를 보자면,,
interface Person {
name: string;
age: number;
}
interface Developer {
name: string;
skill: number;
}
type Capt = Person & Developer;
의 결과물은
{
name: string;
age: number;
skill: string;
}
다시 본론으로 돌아오자면 FunnelComponent
는 아래 두 가지 조건에 모두 해당하는 타입이 되어야 한다는 것이다.
RouteFunnelProps
타입을 매개변수로 받아 JSX.Element
를 반환하는 함수Step
을 props로 가진 컴포넌트크아악.. 도대체 함수와 객체가 공존하는 타입은 어떻게 구현되어 있을까? 너무 궁금했다. 궁금증을 꾹 참고 더 읽어보도록 하자....
const DEFAULT_STEP_QUERY_KEY = 'funnel-step';
기본적인 쿼리키가 funnel-step
으로 정의되어 있는 것 보니까 url
에 정의할 이름인가보다.
드디어 커스텀 훅의 내부로 들어간다. 집중 빡 하고 같이 보자. 첫 줄부터 만만치 않다. 타입 정의 + 매개변수 불러오는 부분 엄청 길다.
export const useFunnel = <Steps extends NonEmptyArray<string>>(
steps: Steps,
options?: {
/**
* 이 query key는 현재 스텝을 query string에 저장하기 위해 사용됩니다.
* @default 'funnel-step'
*/
stepQueryKey?: string;
initialStep?: Steps[number];
onStepChange?: (name: Steps[number]) => void;
}
): readonly [FunnelComponent<Steps>, (step: Steps[number], options?: SetStepOptions) => void] & {
withState: <StateExcludeStep extends Record<string, unknown> & { step?: never }>(
initialState: StateExcludeStep
) => [
FunnelComponent<Steps>,
StateExcludeStep,
(
next:
| Partial<StateExcludeStep & { step: Steps[number] }>
| ((next: Partial<StateExcludeStep & { step: Steps[number] }>) => StateExcludeStep & { step: Steps[number] })
) => void
];
} => {
...
}
readonly [FunnelComponent<Steps>, (step: Steps[number], options?: SetStepOptions) => void] & {
withState: <StateExcludeStep extends Record<string, unknown> & { step?: never }>(
initialState: StateExcludeStep
) => [
FunnelComponent<Steps>,
StateExcludeStep,
(
next:
| Partial<StateExcludeStep & { step: Steps[number] }>
| ((next: Partial<StateExcludeStep & { step: Steps[number] }>) => StateExcludeStep & { step: Steps[number] })
) => void
];
}
먼저 반환하는 간단한 튜플 타입부터 똑 떼서 보자.
readonly [FunnelComponent<Steps>, (step: Steps[number], options?: SetStepOptions) => void]
수정 불가능한 튜플 타입을 반환하고 있다.
첫 번째 튜플 요소는 ,FunnelComponent
이다. 위에서 아까 정의하였듯이 두 가지 특성을 가지고 있다.
이제 두번째 튜플 요소는 useFunnel
의 props 요소들을 받아 void를 반환하는 함수이다.
교집합으로 정의한 객체는 어떤 타입을 가지고 있는지 또 따로 똑 떼어서 보자.
{
withState: <StateExcludeStep extends Record<string, unknown> & { step?: never }>(
initialState: StateExcludeStep
) => [
FunnelComponent<Steps>,
StateExcludeStep,
(
next:
| Partial<StateExcludeStep & { step: Steps[number] }>
| ((next: Partial<StateExcludeStep & { step: Steps[number] }>) => StateExcludeStep & { step: Steps[number] })
) => void
];
}
withState
라는 키가 존재하는 객체인데, withState
는 튜플을 반환하는 함수이다. 타입 정의가 조금 복잡하게 되어 있는데, 하나씩 분석해보면 크게 어려울 것 은 없다.
<StateExcludeStep extends Record<string, unknown> & { step?: never }>
Record<string, unknown
이면 모든 객체 타입을 허용하는 것으로 보이는데, 여기에 교집합으로 {step?: never}
을 넣음으로써 step
이라는 키는 사용하지 못하게 하는 것으로 보인다.
아래에 보면
(
next:
| Partial<StateExcludeStep & { step: Steps[number] }>
| ((next: Partial<StateExcludeStep & { step: Steps[number] }>) => StateExcludeStep & { step: Steps[number] })
) => void
이렇게 이미 step
이라는 키 값을 수정 불가능한 string
타입의 배열인 Steps의 값을 정의하는 데 쓰이고 있기 때문에 이렇게 정의를 한 것 같다.
const router = useRouter();
const stepQueryKey = options?.stepQueryKey ?? DEFAULT_STEP_QUERY_KEY;
assert(steps.length > 0, 'steps가 비어있습니다.');
router 객체를 사용할 수 있는 useRouter
API 함수를 불러와 사용한다. 그리고, stepQueryKey
를 정의하는데, options
에 stepQueryKey
를 정의하지 않았다면 기본적으로 설정되어 있는 값인 funnel-step을 이용한다.
만약 steps의 길이가 0이라면, 비어 있는 배열이라고 오류를 내뱉는다.
const FunnelComponent = useMemo(
() =>
Object.assign(
function RouteFunnel(props: RouteFunnelProps<Steps>) {
// eslint-disable-next-line react-hooks/rules-of-hooks
const step = useQueryParam<Steps[number]>(stepQueryKey) ?? options?.initialStep;
assert(
step != null,
`표시할 스텝을 ${stepQueryKey} 쿼리 파라미터에 지정해주세요. 쿼리 파라미터가 없을 때 초기 스텝을 렌더하려면 useFunnel의 두 번째 파라미터 options에 initialStep을 지정해주세요.`
);
return <Funnel<Steps> steps={steps} step={step} {...props} />;
},
{
Step,
}
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
Object.assign
은 객체들의 모든 열거 가능한 자체 속성을 복사해 첫번째 객체에 붙여넣고, 복사한 결과의 객체를 반환한다.
먼저, RouteFunnel
함수가 어떤 일을 하는지 보자. 먼저 props로는 react element가 되는 children 객체를 받고, useQueryParam
이라는 훅을 이용하여 step
을 정의한다. useQueryParam
은 토스 라이브러리에서 정의하여 사용하고 있는 커스텀 훅인데, 내부 구현을 조금 뜯어보자.
export function useQueryParam<T = string>(name: string): T | undefined;
export function useQueryParam<T = string>(name: string, options: Options<T> & { required: true }): T;
export function useQueryParam<T = string>(name: string, options: Options<T>): T | undefined;
export function useQueryParam<T = string>(name: string, options?: Options<T>) {
const router = useNextRouter({ suspense: options?.suspense });
const value = router.query[name] as string | undefined;
if (options?.required === true && value == null) {
throw new Error(`${name} is required`);
}
if (options?.parser != null && value != null) {
return options.parser(value);
}
return value;
}
오버로딩을 통하여 다양한 타입이 정의되어 있는데, 우리에게 필요한 것만 떼 와서 해석해보자면, name
이라는 이름으로 string 값을 가져오고 Next의 useRouter
값을 반환하는 useNextRouter
를 활용하여 router 객체를 생성한다.
단, optionsd에서 suspense
상태가 true라면 router.isReady
가 true가 될 때까지 Suspense를 발생시킨다고 한다. Next의 API 함수를 정말 잘 이용한다. 다시 본론으로 돌아와서 useNextRouter
함수를 통하여 현재 현재 stepQueryKey
값으로 가지고 있는 queryString을 가져와 반환한다.
예를 들어 설명하자면,
https://tify.com/profile/newTaste/BMLIP?funnel-step=OneAnswer
stepQueryKey
인 funnel-steop
의 값인 OneAnswer
를 반환해서 value값에 저장하고 반환하는 것이다.
그런 다음, 해당 step값을 가진 Funnel
을 반환하는 것이다.
return <Funnel<Steps> steps={steps} step={step} {...props} />;
그리고 두 번째 객체값인
{
Step,
}
은
export const Step = <Steps extends NonEmptyArray<string>>({ onEnter, children }: StepProps<Steps>) => {
useEffect(() => {
onEnter?.();
}, [onEnter]);
return <>{children}</>;
};
이렇게 구현되어 있어 onEnter
함수가 있다면 이를 실행시키는 역할을 하며 Funnel 스텝의 자식 요소들을 랜더링한다. 결론을 정리하자면
현재 queryString의 값과 일치하는 step을 가진 Funnel 컴포넌트를 찾아 반환한다. 이 때,
onEnter
함수가 있다면 이를 실행시킨다. ex. <퍼널.Step name="">
FunnelComponent
함수는 어짜피 처음으로 랜더링 될 때 기능을 수행하고 다시 값이 바뀌지 않아도 되므로 빈 의존성 배열을 가진 useMemo
로 감싸 처리해 불필요한 메모리 낭비를 방지한다.
const setStep = useCallback(
(step: Steps[number], setStepOptions?: SetStepOptions) => {
const { preserveQuery = true, query = {} } = setStepOptions ?? {};
const url = `${QS.create({
...(preserveQuery ? router.query : undefined),
...query,
[stepQueryKey]: step,
})}`;
options?.onStepChange?.(step);
switch (setStepOptions?.stepChangeType) {
case 'replace':
router.replace(url, undefined, {
shallow: true,
});
return;
case 'push':
default:
router.push(url, undefined, {
shallow: true,
});
return;
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[options, router]
);
스텝을 설정하는 부분의 구현부를 뜯어보자. setStepOptions
를 통하여 받아온 값들을 객체 구조 분해 할당으로 하나씩 꺼내온다. 그리고 퍼널 스텝을 생성할 url
을 만드는데, 그 과정은 토스의 QS
유틸을 사용한다. 유틸이 어떻게 생겼는지 보자.
createQueryString (QS.create)
GET 파라미터로 전달되는 쿼리 스트링을 제작합니다. 주의: params가 비었을 경우, 빈 문자열을 반환하지만, params에 키-값 쌍이 존재하면 ?가 앞에 추가됩니다.
{ a: 1, b: 2, c: '가나다' } // => '?a=1&b=2&c=%EA%B0%80%EB%82%98%EB%8B%A4',
{} // => ''
첫 번째 파라미터 Record의 key는 string이고, 값은 string | number | string[] | number[]이어야 합니다.
객체를 넘겨주면, 그 객체를 이용하여 쿼리 파라미터로 만들어주는 유틸함수임을 알 수 있다.
const url = `${QS.create({
...(preserveQuery ? router.query : undefined),
...query,
[stepQueryKey]: step,
})}`;
이므로 preserveQuery
값이 true라면 현재 router
에 있는 query 객체를 가져오고, 추가 옵션으로 사용하고 있는 쿼리가 있다면 그 쿼리에 대한 쿼리들도 생성해준다. 마지막으로 가장 중요한 것은 stepQueryKey
를 통해서 라우터할 퍼널 컴포넌트가 달라지므로 쿼리스트링의 맨 끝에 추가해준다.
options?.onStepChange?.(step);
switch (setStepOptions?.stepChangeType) {
case 'replace':
router.replace(url, undefined, {
shallow: true,
});
return;
case 'push':
default:
router.push(url, undefined, {
shallow: true,
});
return;
}
},
step
을 변화시키는 방식을 선택할 수 있다. replace
, push
중에서 선택할 수 있는데 두가지 동작에는 차이점이 존재한다.
url
로 대체. history Stack이 하나더 쌓이지 않는다.그리고 shallow
Routing을 지원하여 새로고침 깜빡! 없이 화면을 자연스럽게 넘겨줄 수 있다.
여기까지 withState
를 제외한 부분에 대한 구현부를 함께 살펴보았다. 예시코드를 살펴보자.
function TestComponent() {
const [테스트퍼널, setStep] = useFunnel(퍼널스텝리스트);
return (
<테스트퍼널>
<테스트퍼널.Step name="test1">
<h1>Test1</h1>
<button onClick={() => setStep('test2')}>next</button>
</테스트퍼널.Step>
<테스트퍼널.Step name="test2">
<h1>Test2</h1>
</테스트퍼널.Step>
</테스트퍼널>
);
}
이제 이 퍼널의 기본적인 원리를 이용하여 TIFY 프로젝트에 적용해보자.