스티커를 콜렉팅하는 기능을 포트폴리오 사이트에 넣었는데, 여기에 마운트 언마운트 애니메이션을 적용하기 위해 한번 코드를 펼쳐봤습니다

그런데? 놀랍게도 코드가 너무 더럽습니다. 사실 그렇게 놀랍진 않습니다. 잘 모르고 짰거든요.

애니메이션을 넣기 전에 간단하게 청소를 한 번 하려고 합니다

// Sticker.tsx
const Sticker = ({ name, width, defaultPosition, setSricker }: ITool) => {
  const [cards, setCards] = useRecoilState(collectSticker);
  const setToolsEnter = useSetRecoilState(ToolsAlertAtom);

  const onClick = (iconName: string) => {
    setCards(
      cards.map((card: IStickerTypes) => {
        return card.name === iconName ? { ...card, isGet: true } : card;
      }),
    );
    setSricker!(false);
  };

  useEffect(() => {
    return () => {
      console.log("unmount");
    };
  }, []);

  return (
    <>
      <Card
        onClick={() => {
          onClick(name);
        }}
        width={width}
        top={defaultPosition.y}
        left={defaultPosition.x}
      >
        <img src={`img/sticker/${name}.png`} alt={`${name}`} />
      </Card>
    </>
  );
};

export default Sticker;

마운트, 언마운트 상태를 전역에서 관리하고 있고, 켜지고 꺼지는 동작에 대해선 해당 스티커를 사용하는 컴포넌트에서 입력을 받고 있습니다. useDisplaySticker로 만든 훅을 이용해서요 … 굳이 이런 식으로 props를 전달하는 건 불필요하다는 생각이 듭니다.

간단한 함수인데도 많은 것이 얽혀 너무 복잡하게 느껴지기 때문에 스티커의 동작까지 묶어서 한번에 사용할 수 있도록 커스텀 훅으로 한번 나눠보려고 합니다

// useDisplaySticker.tsx
import { useEffect, useState } from "react";
import { useRecoilState, useRecoilValue } from "recoil";
import { collectSticker } from "../recoil/atom";

const useDisplaySticker = (stickerName: string) => {
  const stickers = useRecoilValue(collectSticker);
  const [displaySticker, setDisplaySticker] = useState(false);

  useEffect(() => {
    stickers.map((x) => {
      if (x.name === stickerName) {
        if (x.isGet === true) {
          setDisplaySticker(false);
        } else {
          setDisplaySticker(true);
        }
      }
    });
  }, []);

  return { displaySticker, setDisplaySticker };
};

export default useDisplaySticker;

기존에는 display동작만 묶은 커스텀 훅을 사용하고 있었는데요, 전역 상태 관리를 위한 recoli을 여기서도 쓰고 있네요

이 코드만 봐서는 사실 뭘 하고 싶은건지 이해하기가 어렵네요. 예전의 제가 대체 무슨 짓을?

상태 관리 툴, 라이브러리를 최대한 숨기는 것. 의존성 역전 법칙이라고 하던가요? 이 점에 유의하며 최대한 스티커의 동작은 한 파일에서만 관리할 수 있도록 묶어보겠습니다.

//useDisplaySticker.tsx
export default function useDisplaySticker(stickerName: string) {
  const [stickers, setStickers] = useRecoilState(collectSticker); // 스티커 종류들을 담은 객체를 가지고 있습니다.
  const [displaySticker, setDisplaySticker] = useState(false); // 스티커의 마운트 여부를 결정짓습니다.

  // 스티커들을 전부 살펴보고, 획득한 스티커인지 아닌지 판별합니다.
  useEffect(() => {
    stickers.map((sticker) => {
      if (sticker.name === stickerName) {
        if (sticker.isGet === true) {
          setDisplaySticker(false);
        } else {
          setDisplaySticker(true);
        }
      }
      return null;
    });
  }, [stickerName, stickers]);

  // 스티커 클릭 시 획득 여부를 기록하고, 스티커를 unmount합니다
  const getSticker = (iconName: string) => {
    setStickers(
      stickers.map((card: IStickerTypes) => {
        return card.name === iconName ? { ...card, isGet: true } : card;
      }),
    );
    setDisplaySticker(false);
  };

  return { displaySticker, getSticker };
}

우선, 꺼지고 켜지는 상태 관리는 훅 내부에서도 다룰 수 있다고 생각해 로직을 아예 훅 내부에 넣었습니다. 이로 인해 개선된 부분은

  1. Recoil 전역상태관리툴이 불필요하게 Sticker.tsx함수에 들어가지 않아도 됨
  2. 각 컴포넌트가 굳이 꺼지는 동작을 관리하지 않아도 된다
  3. 전역 상태 관리 툴(Recoil)을 바꾼다고 하더라도 스티커는 이 훅만 관리하면 된다 ← 제일 좋네요

정도가 되겠네요.

그리고 map에서 사용하던 x를 sticker로 명칭을 바꾸어 뭘 가리키고 있는 건지 확실하게 만들었습니다.

//Sticker.tsx
export default function Sticker({ name, width, defaultPosition }: ITool) {
  const { displaySticker, getSticker } = useDisplaySticker(name);

  return (
    <>
      {displaySticker ? (
        <Card
          onClick={() => {
            getSticker(name);
          }}
          width={width}
          top={defaultPosition.y}
          left={defaultPosition.x}
        >
          <img src={`img/sticker/${name}.png`} alt={`${name}`} />
        </Card>
      ) : null}
    </>
  );
}

덕분에 훨씬 간결해진 스티커의 동작. 이제 스타일 속성만 받아오고, 그걸 화면 위에 그려주는 역할만 잘 수행합니다. 청소의 대가는 스티커가 삽입된 컴포넌트 10개를 둘러보는 것으로 치루었습니다…

스티커 하나를 추가하기 위해 불필요하게 추가된 코드들을 전부 지워줬습니다! 후련하네요

스티커 하나를 추가하기 위해 불필요하게 추가된 코드들을 전부 지워줬습니다! 후련하네요

이제 애니메이션을 삽입해보려고하는데요.

기존에 임의로 넣은 스윙 애니메이션이 있는데, 스티커가 마운트 되는 시점에만 실행되고, 언마운트 될 때에는 그냥 사라져버려서 부자연스럽더라고요. 동작이 어떻게 수행될지 먼저 고민해봅니다

  1. 스티커가 어플리케이션에 마운트
  2. 애니메이션 동작
  3. 스티커 클릭
  4. 애니메이션 동작
  5. 언마운트

이런 식으로 동작이 될 거라고 생각합니다.

마운트 된 후에 애니메이션을 동작시키는건 어렵지 않지만, unmount 관리가 어렵다고 느껴집니다.

그래서 display동작을 받아와 직접 조작하지 않고, 중간에 완충을 해 줄 값을 하나 더 만들었습니다.

// Sticker.tsx
export default function Sticker({ name, width, defaultPosition }: ITool) {
  const { displaySticker, getSticker } = useDisplaySticker(name);
  const [mount, setMount] = useState(displaySticker);

  useEffect(() => {
    if (displaySticker) setMount(true);
  }, [displaySticker]);

  return (
    <>
      {mount && (
        <Card
          onClick={() => {
            getSticker(name);
          }}
          className={`${displaySticker ? "fadeIn" : "fadeOut"}`}
          width={width}
          top={defaultPosition.y}
          left={defaultPosition.x}
        >
          <img src={`img/sticker/${name}.png`} alt={`${name}`} />
        </Card>
      )}
    </>
  );
}
// useDisplaySticker.tsx
import { useEffect, useState } from "react";
import { useRecoilState } from "recoil";
import { IStickerTypes, collectSticker } from "../recoil/atom";

export default function useDisplaySticker(stickerName: string) {
  const [stickers, setStickers] = useRecoilState(collectSticker); // 스티커 종류들을 담은 객체를 가지고 있습니다.
  const [displaySticker, setDisplaySticker] = useState(false); // 스티커의 마운트 여부를 결정짓습니다.

  // 스티커들을 전부 살펴보고, 획득한 스티커인지 아닌지 판별합니다.
  useEffect(() => {
    stickers.map((sticker) => {
      if (sticker.name === stickerName) {
        if (sticker.isGet !== true) {
          setDisplaySticker(true);
        } else {
          setDisplaySticker(false);
        }
      }
      return null;
    });
  }, [stickerName, stickers]);

  // 스티커 클릭 시 획득 여부를 기록합니다.
  const getSticker = (iconName: string) => {
    setStickers(
      stickers.map((card: IStickerTypes) => {
        return card.name === iconName ? { ...card, isGet: true } : card;
      }),
    );
  };

  return { displaySticker, getSticker };
}

이렇게 하면 mount는 displaySticker를 참조하여 동작하게 됩니다.

마운트와 언마운트 애니메이션은 각 css클래스에 담아 두고, 클래스 이름을 변경하는 방법으로 사용했습니다.

순서를 따져보자면

  1. displaySticker가 해당 컴포넌트에게 마운트하라고 요청
  2. displaySticker가 true라면 mount를 true로 변경
  3. mount의 상태에 따라 Card의 ReactNode가 렌더링 ← 여기서 애니메이션 동작
  4. onClick 동작에 따라 useDisplaySticker에서 가져온 getSticker 실행
  5. getSticker가 전역변수 값에서 해당 스티커의 isGet값을 변경
  6. isGet값이 변화함에 따라 해당 스티커의 setDisplaySticker값이 false로 변경
  7. displaySticker의 값을 참조하고 있는 mount의 값이 false로 변경됨 ← 여기서 애니메이션 동작
  8. unmount

정도가 되겠네요.

https://github.com/HamsterStudent/HamsterStudent/assets/60914441/356722eb-18fa-47cf-abbe-42e2a2d66137

훅을 변경해 스티커 내부에서 렌더링 여부를 판별하니, 다른 컴포넌트에서 recoil 라이브러리를 쓸 필요도 없네요! 코드가 간결해져서 기쁩니다

참고

https://czaplinski.io/blog/super-easy-animation-with-react-hooks/