Toast Component는 일반적인 컴포넌트와 달리, 우선 Toast 클래스를 만들어 관리를 하는 방식으로 진행하였다. Toast 컴포넌트는 독립적으로 동작하는 컴포넌트가 아니다. 어떤 메시지를 사용자에게 전달해 주어야 할 때, 알림을 알려야 할 때 등 수동적으로 노출되는 존재이기 때문에, 구현 영역과 API 영역을 나누어 컴포넌트에서 해당 클래스 api를 호출할 수 있도록 하였다.
구현 영역
element.render(
<ToastManager
bind={(createToast) => {
this.createToast = createToast;
}}
/>,
);
class 변수에 해당 컴포넌트 내부에서 구현한 createToast를 바인드하여, 다른 컴포넌트에서 toast.show()
를 호출했을 때 해당 함수를 호출할 수 있도록 한다.
API 영역 toast.show()
컴포넌트에서 어떤 상호작용에 의하여 토스트를 불러오므로, 해당 컴포넌트에서 toast.show()
를 불러 호출하도록 한다.
show(message: string, duration = 2000) {
this.createToast?.(message, duration);
}
기존의 형태와 다른 방식으로 작성하는 이유?
ToastManager
는 모든 Toast를 관리하는 컴포넌트이다. useState로 모든 토스트 데이터들을 관리한다.ToastManager
는 createToast
와 removeToast
메서드를 내부적으로 가진다. createToast를 props으로부터 받은 bind에 전달해주어야 하므로, useEffect
hook에서 해당 함수를 전달한다.removeToast
는 토스트가 생기고 일정 시간이 지나면 알아서 사라져야하므로, 각각 토스트에서 관리하도록 프롭으로 전달해준다.useEffect hook에서 해당 함수를 bind 하는 이유가 무엇인가?
useTimeout
hook을 가지고 있다. 따라서 prop으로 넘겨받은 removeItem을 useTimeout 내부에서 실행하고 자동으로 일정 시간이 지난 뒤에 사라지도록 만든다.// ToastItem.tsx
const ToastItem = ({duration, onDone}) => {
useTimeout(() => {
onDone()
}, duration)
return (
// ...
)
}
동작은 잘 하였으나, Storybook으로 테스트 해 본 결과 여러 ToastItem을 호출했을 때 각 ToastItem이 생성된 시간으로부터 duration이 지난 후 사라져야 하나, 가장 마지막 ToastItem이 호출됬을 때 시간으로 맞춰지고, 가장 마지막 ToastItem duration이 지난 뒤에 동시에 삭제되는 버그가 있었다.
console.log로 해당되는 모든 호출 스택에 찍어보니, useTimeoutFn부터 이런 식으로 동작하는 문제가 있었다.
// 기존 useTimeoutFn.ts
const useTimeoutFn = (cb: () => void, ms = 0): [() => void, () => void] => {
const timeoutId = useRef<null | ReturnType<typeof setTimeout>>(null);
const run = useCallback(() => {
if (timeoutId.current) clearTimeout(timeoutId.current);
timeoutId.current = setTimeout(() => {
cb();
}, ms);
}, [cb, ms]);
// ...
};
export default useTimeoutFn;
원인은 의존성 문제
일단 가설을 입증하기 위하여 모든 호출 스택에 console.log를 찍어보았다.
// ToastManager.tsx
const ToastManager = () => {
// ...
console.log('ToastManager 호출')
}
// ToastItem.tsx
const ToastItem = ({ id }) => {
// ...
console.log(id)
}
// useTimeoutFn.ts
const useTimeout = () => {
useEffect(() => {
console.log('callback 호출')
})
}
// in browser console
/*
ToastManager 호출 -> state 변경이 일어났으므로
id -> ToastItem 호출에 의한 호출
callback 호출 -> initial call
id -> state 변경에 의한 재호출
callback 호출 -> ???
ToastManager 호출 -> ToastItem이 사라졌으니 상태가 변경됨
*/
내가 의도하던 결과가 아닌 callback 호출
이라는 문구가 콘솔에 뜨는 것을 확인했다. 분명히 ToastManager에서 콜백을 useCallback으로 감쌌고, useCallback은 내부적으로 어떤 의존성도 가지고 있지 않아서 컴포넌트가 한 번 호출된 후 재호출 때에는 useCallback으로 감싼 함수가 호출되지 않는다고 생각했는데, 그것이 아니었나?
useTimeout 내부의 useEffect에 [cb]
콜백의존성이 바뀌었기 때문에 다시 useCallback이 호출됨을 알 수 있었다.
const useTimeout = (cb) => {
const ref = useRef(cb)
console.log(ref.current === cb) // 첫번째에는 true, 두번째에는 false
}
콜백 의존성이 바뀌었음을 확인할 수 있었다. 분명히 useCallback으로 감쌌는데..?
useEffect로 콜백 함수가 바뀌었는지 확인해 보기로 한다.
const ToastItem = ({ onDone }) => {
useEffect(() => {
console.log('onDone changed')
}, [onDone])
}
연속으로 두 번 클릭하여 호출했을 때, onDone이 변했다는 사실을 알 수 있다.
// ToastManager.tsx
const ToastManager: React.FC<ToastManagerProps> = ({ bind }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
console.log('toast manager 호출');
const createToast = useCallback((message: string, duration: number) => {
// ...
}, []);
const removeToast = useCallback((id: string) => {
// ...
}, []);
useEffect(() => {
bind(createToast);
}, [bind, createToast]);
return (
<Container>
{toasts.map(({ id, message, duration }) => (
<ToastItem
id={id}
message={message}
duration={duration}
key={id}
onDone={() => removeToast(id)} // ??? 여기 안감쌌네
/>
))}
</Container>
);
};
onDone으로 전달되는 함수를 useCallback으로 감싸지 않아서 재렌더링 시마다 다시 호출되는 것을 확인했다.
onDone이 리렌더링에 의해 다시 정의됨 → useEffect 의존성의 변화 → 다시 setTimeout 진행 → 마지막 컴포넌트가 종료될 때 같이 종료의 메커니즘으로 버그가 발생한 듯 하였다.
useRef 사용하기
따라서 useRef에 콜백함수를 저장하였고, 해당 값을 useEffect 내부에서 사용하였다.
// 개선한 useTimeout.ts
import { useCallback, useEffect, useRef } from 'react';
const useTimeoutFn = (cb: () => void, ms = 0): [() => void, () => void] => {
const timeoutId = useRef<null | ReturnType<typeof setTimeout>>(null);
const fn = useRef(cb);
const run = useCallback(() => {
if (timeoutId.current) clearTimeout(timeoutId.current);
timeoutId.current = setTimeout(() => {
fn.current();
}, ms);
}, [ms]);
// ...
};
export default useTimeoutFn;
따라서 cb가 변하더라도, 변한 cb에 의하여 useEffect가 재정의되지 않는다. 굿~!