TIL32. React Toast 컴포넌트 만들기 feat.useTimeout

imloopy·2022년 6월 4일
0

Today I Learned

목록 보기
35/56

오늘 배운 것들

  • Toast Component
  • useTimeout hook → 버그가 발생하여 고쳤음

Toast Component & useTimeout hook

Toast Component

Toast Component는 일반적인 컴포넌트와 달리, 우선 Toast 클래스를 만들어 관리를 하는 방식으로 진행하였다. Toast 컴포넌트는 독립적으로 동작하는 컴포넌트가 아니다. 어떤 메시지를 사용자에게 전달해 주어야 할 때, 알림을 알려야 할 때 등 수동적으로 노출되는 존재이기 때문에, 구현 영역과 API 영역을 나누어 컴포넌트에서 해당 클래스 api를 호출할 수 있도록 하였다.

  • 또 클래스를 이용하여 구현할 때의 장점은 라이브러리에 의존적이지 않다는 것이다. (물론 이번에 만든 컴포넌트는 리액트 의존적이다)
  • 객체 지향으로 코드를 작성하여 구현과 api를 분리하기 쉽다.

구현 영역

  • 실제 DOM을 구현하는 영역이다. 토스트, 모달 등은 가장 최상위 컴포넌트에 위치해야 하기 때문에, portal-id를 갖는 div DOM을 만들고, ToastManager 컴포넌트를 렌더링한다
  • ToastManager는 bind라는 매개 변수로 함수를 입력받기 때문에, 바인드에 함수를 할당한다.
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);
  }

기존의 형태와 다른 방식으로 작성하는 이유?

  • 토스트 UI가 필요한 곳들에 전부 ToastUI 매니저를 호출할 필요가 없다.
  • 토스트 UI는 전역적으로 관리되므로, 각각의 컴포넌트에서 Toast 매니저가 필요하지 않다. 만약 토스트 컴포넌트를 직접 호출한다면, 토스트 컴포넌트가 필요한 모든 곳에 토스트 매니저를 불러오는 것은 관심사의 분리를 어렵게 만든다. (단, 토스트 컴포넌트를 만드는 함수로 만드는 hook으로 구성하는 것도 괜찮을 것 같다)

ToastManager

  • ToastManager는 모든 Toast를 관리하는 컴포넌트이다. useState로 모든 토스트 데이터들을 관리한다.
  • ToastManagercreateToastremoveToast 메서드를 내부적으로 가진다. createToast를 props으로부터 받은 bind에 전달해주어야 하므로, useEffect hook에서 해당 함수를 전달한다.
  • removeToast는 토스트가 생기고 일정 시간이 지나면 알아서 사라져야하므로, 각각 토스트에서 관리하도록 프롭으로 전달해준다.

useEffect hook에서 해당 함수를 bind 하는 이유가 무엇인가?

  • 리액트 라이프사이클 단계상 렌더 단계 → ref 연결 및 DOM 업데이트 → 사이드 이펙트 실행 단계로 구성이 되어 있음
  • render 단계가 지나고 DOM이 업데이트가 되어 사이드 이펙트가 제대로 연결됨을 보장하고, 이 모든 사이드이펙트의 실행은 useEffect hook 내부에서 이루어지기 때문에 useEffect hook 내부에 사용자가 정의한 사이드 이펙트를 실행해야 한다.
  • 리액트에서 사이드이펙트는 함수 내부에서 함수 외부 데이터를 조작할 수 있는 모든 행위들을 뜻한다

ToastItem

  • ToastItem은 내부적으로 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

// 기존 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])
}

image.png

연속으로 두 번 클릭하여 호출했을 때, 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는 값을 저장할 수 있는 공간으로, 컴포넌트가 재렌더링 되더라도 값이 초기화가 되지 않는다.
  • ref.current로 mutational 하게 값을 지정할 수 있다.
  • ref.current로 값을 지정하기 전 까지는 값이 바뀌지 않는다. → 재호출이 되더라도 useRef에 의해 값이 변하지 않는다.
  • ref.current로 관리하는 값은 값이 변경되더라도 DOM이 업데이트 되지 않는다. (리액트는 함수형 패러다임을 따르므로 prop과 state간 일관성을 유지하기 위하여)

따라서 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가 재정의되지 않는다. 굿~!

느낀 점

  • 오늘 세션이 오전, 오후 두 개가 있어서 여유롭게 해결하지 못했다. 사실 이 Toast 구현 방법이 이렇게 복잡할 줄 몰랐기 때문에 조금 여유로웠던 것도 있다. 근데, 예상하지 못한 버그가 있었고, 어디서 발생했는지 잘 몰랐기 때문에 확인하는데 몇 시간이 걸렸다…. 글쓰는건 30분이면 충분하지만 ㅜㅜ
  • 리액트를 잘 사용하려면 리액트 기본 지식에 대하여 좀 더 공부하는것이 필수적이다. 알고 쓰는 것과 모르고 쓰는 것은 천지 차이이다... 그 동안 그냥 동작만 하면 되는거 아니야? 라고 생각했던 나는 반성하자..!

출처

Hook 자주 묻는 질문 - React

0개의 댓글