최근 위와 같은 스타일의 페이지를 개발할 일이 아주 많았다.
첫 페이지 로드 시 A 화면, 3초 뒤에 B 화면, API 응답 성공 후에 C 화면, 다시 2초 뒤에 D 화면 ... 이런 식이다.
이런 유형의 페이지를 처음 작성할 땐 아래처럼 작성했다.
function useTransition() {
const [isPending, setIsPending] = useState(true);
const [isFinishSoon, setIsFinishSoon] = useState(false);
const [isFinishAll, setIsFinishAll] = useState(false);
useEffect(() => {
const handle = setTimeout(() => setIsPending(false), 3000);
return () => clearTimeout(handle);
}, []);
useEffect(() => {
if (isFinishSoon) {
const handle = setTimeout(() => setIsFinishAll(true), 2000);
return () => clearTimeout(handle);
}
}, [isFinishSoon]);
}
화면 타입마다 useState를 선언해서 사용했는데
언제나 리팩토링이 그렇듯 어떻게든 돌아가지만 요구사항의 변경으로 코드를 수정할 때 문제가 많이 생겼다.
위 방식의 코딩의 문제점은 새로운 화면마다 state를 선언해야 하는 것이다.
또, 이 hook이 어떻게 돌아갈지 전혀 예측할 수 없기 때문에 재사용은 당연히 할 수 없을 것이고, 훅을 사용하는 의미가 없어진다.
처음엔 state가 많아질 때 액션 기반으로 묶기 위해 useReducer를 기계적으로 사용하려고 했으나
그렇게 하면 위 문제를 아무 것도 해결할 수 없다. 액션 기반으로 state가 변할테니 의미 파악 정도는 되겠지만.
나는 훅을 리팩토링 할 때는 먼저 훅을 그냥 필요한 컴포넌트에서 마치 구현이 된 것처럼 가장 편한 형태로 선언해본다.
그렇게 한 뒤에 다듬어 나가면 어려운 리팩토링에 점진적으로 접근할 수 있는 것 같다.
const { currentState, transition } = useTimeoutTransitionState<'pending' | 'wait' | 'finishSoon' | 'finishAll'>({
initialState: 'pending',
states: {
pending: {
value: '잠시만 기다려주세요.',
duration: 3000,
onTimeout: transition => {
setIsEnabledAPI(true);
transition('wait');
},
},
wait: {
value: '약 1분 정도 걸릴 수 있어요',
duration: 7000,
onTimeout: () => {
setIsEnabledAPI(false);
},
},
finishSoon: {
value: '거의 다 되어가요',
duration: 2000,
onTimeout: transition => {
transition('finishAll');
},
},
finishAll: {
value: '완료되었습니다!',
},
},
});
return (
<p>{currentState}</p>
)
이렇게 하면 훅을 선언하는 쪽에서 필요한 상태와 변화 조건들을 선언할 수 있으니
비슷한 화면에서 훅을 재사용할 수 있고, 상태 변화도 훨씬 명확하게 알 수 있다.
내부 구현은 다음과 같다.
type States<K extends string> = Record<K, State<K>>;
type State<K> = {
value: string | ReactNode;
duration?: number;
onTimeout?: (transition: (name: K) => void) => void;
};
type CurrentStateValue<K extends string> = { name: K; state: State<K> };
interface Props<K extends string, T = States<K>> {
states: T;
initialState: K;
}
function useTimeoutTransitionState<K extends string>({ states, initialState }: Props<K>) {
const [currentState, setCurrentState] = useState<CurrentStateValue<K>>({
name: initialState,
state: states[initialState],
});
const timeoutId = useRef<NodeJS.Timeout>();
const transition = (name: K) => {
const found = states[name];
if (found) {
setCurrentState({ name, state: found });
}
};
useEffect(() => {
const { onTimeout, duration = 0 } = currentState.state;
timeoutId.current = setTimeout(() => {
onTimeout?.(transition);
}, duration);
return () => timeoutId?.current && clearTimeout(timeoutId.current);
}, [currentState]);
return {
transition,
currentState,
};
이렇게 구현을 다 해보고 코드 리뷰에서 동료가 알려준 것인데
xstate라는 라이브러리가 내가 한 리팩토링 형태와 아주 흡사하다고 알려줬다.
xstate 라이브러리로 필요한 기능을 구현해본 코드는 다음과 같다.
import { createMachine } from 'xstate';
const pendingMachine = createMachine({
initial: 'pending',
states: {
pending: {
after: {
5000: { target: 'wait' },
},
},
wait: {
after: {
1000: { target: 'finishSoon' },
},
},
finishSoon: {
after: {
2000: { target: 'finishAll' },
},
},
finishAll: {
after: {},
},
},
});
이 라이브러리는 복잡한 상태 변화를 표현하는 화면에 아주 훌륭하고 기능도 내가 한 리팩토링 수준보다 훨씬 많다.
아주 가볍고 리액트 뿐만 아니라 자바스크립트가 지원되는 모든 프레임워크에 사용될 수 있어서 활용하기 좋아보인다.
하지만 내가 필요한 것보다 너무 많은 기능을 지원해서 우선은 내가 리팩토링한 훅으로 사용하기로 했다.
https://www.npmjs.com/package/xstate
'업무' 카테고리의 다른 글
react-query에서 Throttling API 호출 구현하기 (leading, trailing) (0) | 2023.02.22 |
---|---|
IOS 웹뷰 페이지 개발 업무에서 문제 해결 과정 (0) | 2023.02.02 |
BFF 활용하여 프론트엔드만을 위한 API 만들기 (0) | 2022.11.01 |
requestIdleCallback을 활용한 렌더링 성능 개선 경험 (1) | 2022.10.07 |
[Next.js] Script 추가하기 (채널톡 추가하기, next/script) (0) | 2022.09.28 |