[유데미x스나이퍼팩토리] 10주 완성 프로젝트 캠프 - 디자인 시스템 프로젝트 - Snack Bar 컴포넌트 (3)

jay__ss·2023년 8월 2일
0
post-thumbnail

첫 구현 코드

setSnackbarMessage로 메세지만 바꿔주면 자동적으로 스낵바가 하단에서 나오는 사용자경험을 가진다

사용부

import { FoundationStyles, LayoutStyles, TypographyStyles } from '@lib/foundation';
import { useSnackbar, SnackBar } from '@lib/components/SnackBar';

const getRandomMsg = () =>
  Array(5)
    .fill('')
    .map((_, i) => `${i + 1}번째 테스트 메세지 입니다`)[Math.floor(Math.random() * 5)];

function App() {
  const [snackbarMessage, setSnackbarMessage] = useSnackbar();

  return (
    <>
      <FoundationStyles />
      <LayoutStyles size='tablet' system='android'>
        <TypographyStyles.Body1>Hello World</TypographyStyles.Body1>
        <button onClick={() => setSnackbarMessage(getRandomMsg())}>Open Snackbar</button>
        <SnackBar message={snackbarMessage} setMessage={setSnackbarMessage} duration={3000} isAppbar={false} />
      </LayoutStyles>
    </>
  );
}

export default App;

특이한 점은 스낵바 컴포넌트를 써야하는 페이지에 스낵바 컴포넌트를 위치시키면서, useSanckbar라는 커스텀 훅을 쓰고 있다는 점이다
그리고 해당 커스텀훅의 리턴값이 스낵바에서 노출시키고 싶은 메세지의 값과, 해당 메세지를 set시키는 setter함수라는 것을 알 수 있다
(일종의 useStatesetXxxx함수)
setSnackbarMessage를 내려준 이유는, 일정시간후에 스낵바 컴포넌트에서 메세지를 빈 문자열 ''로 초기화하여 컴포넌트를 제거해주기 위해서이다

컴포넌트

useSnackbar.ts

import { useState } from 'react';

type ReturnType = [string, (text: string) => void];

export const useSnackbar = (): ReturnType => {
  const [message, setMessage] = useState({ text: '' });

  function setSnackbarMessage(text: string): void {
    setMessage({ text });
  }

  return [message.text, setSnackbarMessage];
};

useSnackbar는 그냥 useState를 쓰는것과 비슷하다고 볼 수 있지만 메세지의 타입이 조금 다르다
일반적이라면 string값을 사용할텐데 여기서는 개체안에 text라는 키에 밸류로 저장하고 이를 반환한다

더욱 이상한 점은 불변성을 지키지 않는 형태를 가지는 것이다

이는 내가 의도한 코드인데, 이렇게되면 같은 메세지라 할지라도 다른 상태로 리액트가 인식하여 리렌더링을 유발할 것이다
(예를 들어, 무엇인가를 연속적으로 삭제하여 '삭제되었습니다'라는 알림이 여러번 뜨는것과 같은 상황)

index.tsx

export const SnackBar: FC<SnackBarProps> = ({ message, duration, setMessage, isAppbar }) => {
  useEffect(() => {
    if (message.length) {
      const timer = setTimeout(() => {
        setMessage('');
      }, duration);

      return () => {
        clearTimeout(timer);
      };
    }
  }, [message]);

  return (
    <>
      {message.length ? (
        <St.SnackBar key={Math.random()} duration={duration} isAppbar={isAppbar}>
          <Typo.Caption1>{message}</Typo.Caption1>
        </St.SnackBar>
      ) : null}
    </>
  );
};

컴포넌트는 메세지가 빈 문자열이 아니라면 전달받은 duration뒤에 메세지를 빈 문자열로 할당하여 스낵바 컴포넌트를 언마운트 하게된다

또한 useEffect에서 return문을 사용하여 언마운트시에 타이머를 클리어해주는 혹시 모르는상황에도 처리를 해주었다

type.ts

특별한건 없고 메세지를 세팅하는 함수의 타입string을 인자로 받아 아무것도 반환하지 않는 void함수로 해주었다

type SetMessage = (text: string) => void;

export type SnackBarProps = {
  message: string;
  duration: number;
  setMessage: SetMessage;
  isAppbar: boolean;
};

구현화면

이렇게 같은 메세지이더라도 렌더링되는 스낵바 컴포넌트를 제작하였다

문제점

버튼을 연타하여 5번 스낵바를 오픈했다고 가정해보자
첫번째 스낵바가 오픈이 되면서 컴포넌트 내부에서 타이머가 실행된다
듀레이션이 지나면 메세지를 빈 문자열로 초기화하여 렌더링되던 스낵바가 보이지않게 된다
두번째 오픈이 될 때 해당 첫 타이머가 클리어 되어야 안정적으로 듀레이션동안 알림을 줄 수 있다
문제는 메세지가 바뀜에따라 컴포넌트가 없어졌다가 다시 생기는것이 아니라는 점이다
(없어졌다가 생기는 마법같은 현상이라면 유즈이펙트의 리턴문에서 클리어해주기에 문제가 없다)
하지만 컴포넌트는 그대로 존재하고 메세지만 바뀌기에 첫 타이머의 클리어가 되지않는 문제가 발생한다

위에서 말했듯이 props로 세터함수를 내려받은 뒤, 타이머를 스낵바 컴포넌트 내부에서 실행시키는 로직은 특수한 상황에서 원하는대로 클리어가 되지않는다

그렇다면 타이머와 클리어 로직은 스낵바 컴포넌트의 외부에서 관리되어야 한다는 의미이고, 이러한 내부 로직을 사용자에게 강제할 수 없으니 useSnackbar 내부로 전부 밀어넣으면 된다고 생각했다

리팩토링 코드

useSnackbar.ts

useEffect로 메세지를 감시하고, 클리어 로직을 리턴문에 넣어서 컴포넌트가 사라질 때를 대비하였고, 커런트값을 가진다면(==아직 스낵바가 동작중인데 스낵바가 또 오픈이되는 상황이라면) 클리어 로직을 걸어주었다

이렇게되면 사용부에서는 스낵바의 렌더링 로직은 몰라도되는채로, 듀레이션만 유즈스낵바와 컴포넌트의 프롭스로 동일하게 넣어준다면 원하는 시간에 맞게 스낵바 알림이 뜨는 기능을 구현할 수 있다

import { useState, useRef, useEffect } from 'react';

type Timer = ReturnType<typeof setTimeout>;

export const useSnackbar = (ms: number): [string, (text: string) => void] => {
  const [message, setMessage] = useState({ text: '' });
  const timer = useRef<Timer>();

  function setSnackbarMessage(text: string): void {
    setMessage({ text });
  }

  useEffect(() => {
    if (!message.text.length) return;
    if (timer.current) clearTimeout(timer.current);

    timer.current = setTimeout(() => {
      setSnackbarMessage('');
    }, ms);
    return () => {
      clearTimeout(timer.current);
    };
  }, [message]);

  return [message.text, setSnackbarMessage];
};

index.tsx

타이머와 관련된 로직을 전부 제거하고 전달받던 세터함수도 빼주었다

import { FC } from 'react';
import { TypographyStyles as Typo } from '@lib/foundation';
import * as St from './style';
import { SnackBarProps } from './type';
import { useSnackbar } from './useSanckbar';
export { useSnackbar };

export const SnackBar: FC<SnackBarProps> = ({ message, duration, isAppbar, width = '1024px' }) => {
  return (
    <>
      {message.length ? (
        <St.SnackBar key={Math.random()} duration={duration} isAppbar={isAppbar} width={width}>
          <Typo.Caption1>{message}</Typo.Caption1>
        </St.SnackBar>
      ) : null}
    </>
  );
};

type.ts

너비가 맞지않게 나온다는 의견이 있어, 프롭스로 전달받고 없으면 너비만큼의 기본값을 주는 코드로 변경

export type SnackBarProps = {
  message: string;
  duration: number;
  isAppbar: boolean;
  width?: string;
};

본 후기는 유데미-스나이퍼팩토리 10주 완성 프로젝트캠프 학습 일지 후기로 작성 되었습니다.

profile
😂그냥 직진하는 (예비)개발자

1개의 댓글

comment-user-thumbnail
2023년 8월 2일

좋은 글 감사합니다.

답글 달기