내 입맛대로 toast 만들고 react-query랑 같이 써보기

Doeunnkimm·2024년 1월 14일
10

FE log

목록 보기
4/6
post-thumbnail
post-custom-banner

이번 글에서는 제가 Toast 컴포넌트를 만들기도, 사용하기 편하게 로직을 만들기도, 문서화를 하기도, react-query에서 성공 시 띄우기도 한 과정들을 글로 남겨 보려고 합니다.

아래는 최종적으로 만든 Toast 컴포넌트에요!

좋은 레퍼런스를 찾았어서 처음부터 대단한 고민을 한 것은 아니지만, 그 과정 속에서 제 입맛대로(?) 바꾸고 발견한 것들이 있었어요! 그것들을 함께 이야기 해보려고 합니다.

일단 선 감사합니다 부터...
참고한 레퍼런스

😉 만들어봅시다

제가 지금 사용하고 있는 기술 스택은 다음과 같습니다.

  • Next.js (v14)
  • typescript
  • tailwind css
  • jotai
  • react-query (v5)

1. 전역 상태로 toast들 관리

보통 toast는 여러 개가 나오면서 시간차로 슥슥슥슥 하나씩 없어지는 거 많이 보셨을 거에요.

즉, toast를 배열을 통해 여러 개를 관리한다는 말을 의미하는데요! 그래서 toast들을 담을 수 있는 배열을 전역 상태로 만들어 주었어요.

// Toast.atom.ts
export const toastsAtom = atom<ToastsProps>([]);

2. toasts에 toast를 쉽게 추가하도록

jotai는 기존에 선언되어 있는 atom을 가지고 write하거나 read할 수 있는 atom을 만들 수 있어요. 저는 이를 이용해서 toastsAtom에 편하게 toast를 추가할 수 있는 write용 atom을 만들었어요.

// Toast.atom.ts
export const toastAtom = atom(null, (get, set, type: ToastProps['type']) => (title: string) => () => {
  const prev = get(toastsAtom);
  const newToast = { type, title, id: Date.now().toString() };
  set(toastsAtom, [...prev, newToast]);
});

🚨 주의1.
저희 팀에서 toast를 사용할 때 필요한 정보는 2개 뿐이였어요.

  • toast의 종류 (success / warning)
  • toast에 들어 갈 텍스트

위 코드 중에서 이 부분을 주목해주세요!

(get, set, type: ToastProps['type']) => (title: string) => () => { ... }

즉, 해당 atom을 write할 때 type을 넘겨주면 (title) => () => {}를 반환하도록 되어 있습니다. 즉 위 코드를 사용할 때는 아래와 같이 사용한다는 것을 의미합니다.

const addToast = useSetAtom(toastAtom)
...
addToast(type)(title)()

커링형태로 사용 시 깔끔하게 호출할 수 있도록 구성했습니다.

🤔 엥 뭐가 깔끔하다는 거냐.... 할 수 있지만 다 계획 된.. 아무튼 글을 계속 읽어봐주세요 😉

여기까지 하면 이제 어디서든 toast를 추가할 수 있습니다.

🚨 주의2.

const newToast = { type, title, id: Date.now().toString() };

toast가 필요한 정보 중에 id가 필요한 이유는 고유한 값으로 그걸 가지고 해당 toast를 지우기 위해 필요해요.

처음에는 사실 id: prev.length로 했었는데, 렌더링되고 시간차로 없어지면서 length가 줄어들었다 늘었다 하는 과정 속에서 map 돌릴 때 key값이 중복되어 warning이 뜨더라구요. 그래서 중복될 일이 없는 Date.now()를 이용하여 toast에게 id를 부여해 주었습니다.

3. 사라질 타이밍을 아는 Toast 컴포넌트 만들기

그 다음으로 Toast 컴포넌트를 만들었어요. 그런데, 위 gif를 보면 알 수 있듯이 각 toast가 독립적으로 일정 시간이 지나면 슥하고 사라지고 있어요.

이 말은 전역에서 관리하고 있는 toasts 배열에서 지워졌음을 의미합니다.

3-1. removeAtom 만들기

아까 추가를 위한 atom과 비슷한 맥락으로 삭제하는 atom을 만들었어요.

// Toast.atom.ts
...
export const removeToastAtom = atom(null, (get, set, id: string) => {
  const prev = get(toastsAtom);
  set(
    toastsAtom,
    prev.filter((toast) => toast.id !== id),
  );
});

삭제하기를 원하는 id를 받아서 이 id를 가지고 toasts 배열에서 없애줍니다.

3-2. 일정 시간을 가지고 opacity 변화와 remove하기

Toast 컴포넌트는 각자 시간만큼 보여주기를 완수하면 사라집니다. 즉, 이 timer는 Toast 컴포넌트 내에서 관리한다는 말을 의미합니다.

그래서 이 일정 시간이라는 주기를 useEffect를 통해 작성하면 아래와 같습니다.

// Toast.tsx
const TOAST_DURATION = 3000;
const ANIMATION_DURATION = 350;

export const Toast = ({ id, title, type = 'success' }: ToastProps) => {
  const [opacity, setOpacity] = useState('opacity-[0.2]');
  const removeToastItem = useSetAtom(removeToastAtom);

  useEffect(() => {
    setOpacity('opacity-[0.8]');
    const timeoutForRemove = setTimeout(() => {
      removeToastItem(id);
    }, TOAST_DURATION);

    const timeoutForVisible = setTimeout(() => {
      setOpacity('opacity-0');
    }, TOAST_DURATION - ANIMATION_DURATION);

    return () => {
      clearTimeout(timeoutForRemove);
      clearTimeout(timeoutForVisible);
    };
  }, [id, removeToastItem]);

  return (
    <div
      className={`w-fit flex gap-5xs justify-center items-center px-3xs py-5xs bg-gray-60 rounded-[12px] mb-5xs transition-all duration-350 ease-in-out ${opacity}`}
    >
      ...
    </div>
  );
};

위와 같이 opacity와 remove를 해주게 되면 스타일에 적용되어 있는 transition 덕분에 스르륵 보였다가 스르륵 없어지는 것처럼 보이게 됩니다. 스르륵 이거 무슨 느낌인지 다들 아시죠?

4. ToastProvider 만들기

여기까지 우리가 한 작업들은 다음과 같아요.

  • 전역으로 관리하는 toast 배열
  • 전역에서 toast 배열에 고유한 id와 함께 toast를 추가하는 메서드
  • 사라질 타이밍을 아는 Toast 컴포넌트

만들 건 다 만들었는데 렌더링하는 코드만 없죠! map을 사용해서 toast를 렌더링할 Provider를 만들어봅시다.

🤔 왜 Provider 일까?

Toast는 레이아웃과 상관없이 어디서든 뜰 수 있게 하고 싶었었죠? Toast 메시지를 띄우려는 컴포넌트들이 직접 메시지를 관리하지 않고, ToastProvider를 통해 메시지를 렌더링할 수 있도록 합니다.

// ToastProvider.tsx
import { Portal } from '@radix-ui/react-portal';
import { useAtomValue } from 'jotai';

import { Toast } from './Toast';
import { toastsAtom } from './Toast.atom';

export const ToastProvider = () => {
  const toasts = useAtomValue(toastsAtom);

  return (
    <Portal>
      <div className={`fixed bottom-[82px] left-1/2 transform translate-x-[-50%]`}>
        {toasts.map((toast) => (
          <Toast key={toast.id} {...toast} />
        ))}
      </div>
    </Portal>
  );
};

toastProvider에서는 전역 상태로 관리 중인 toast 배열을 가져와서 map 돌려주는 역할을 하고 있어요. 이때 오버레이 형태로 렌더링되어야 하기 때문에 현재 팀에서 headless UI가 필요할 때 사용하기로 했던 radix-uiPortal 컴포넌트를 사용했어요.

덕분에 z-index를 신경쓰지 않을 수 있었습니다.

5. useToast로 간편하게 추가할 수 있도록

아까 toast 배열에 toast를 추가할 수 있는 atom을 만들었던 거 기억하시나요?

addToast(type)(title)()

이런 코드와 함께 소개했던 것요..!

이제 이걸 좀 더 이쁘게(?) 사용할 수 있도록 하는 커스텀 훅을 만들어 보려고 합니다.

// useToast.ts
import { useSetAtom } from 'jotai';

import { toastAtom } from '@/components/atoms/toast/Toast.atom';

export const useToast = (option?: ToastOptionProps) => {
  const addToast = useSetAtom(toastAtom);

  return {
    success: addToast('success'),
    warning: addToast('warning'),
  };
};

위 코드를 살펴보면 훅 함수로 2가지의 메서드를 리턴하고 있어요. 한 가지를 가지고 이야기해보면, addToast('success')라고 하면 success라는 프로퍼티에는 (title: string) => () => {}라는 메서드를 가지게 되는 것과 같아요. 다시 말해서, title을 매개변수를 받아서 VoidFunction을 리턴하는 함수를 갖게 되는 것이죠!

6. (선택) option 받기

현재는 toast의 color나 position이 고정되어 있어요. 별도의 옵션 없이 이들의 값을 하드하게 넣어줘도 되지만, 저희 팀에서는 option을 받을 수 있는 전역 상태를 통해 좀 더 나중에 유연할 수 있도록 해주었어요.

우선 UI에서는 스타일 면이나 위치가 항상 동일해서 전달 받은 점은 없었지만, 우선 위치에 대한 옵션을 만들어 두었어요.

// Toast.atom.ts
export const toastOptionAtom = atom<ToastOptionProps>({
  position: 'bottom-[84px]',
});

default로 위치를 명시해주었어요.

// Toast.atom.ts
...
export const toastOptionChangeAtom = atom(null, (get, set, changeOption: ToastOptionProps) => {
  const prev = get(toastOptionAtom);
  const updatedOption = { ...prev, ...changeOption };
  set(toastOptionAtom, updatedOption);
});

이 write용 atom을 통해서는 default 옵션은 default대로 적용을 하고, 더 필요한 옵션을 넘겨주면 default + 필요한 옵션이 set될 수 있도록 만들어주었어요.

이를 적용해보면 아래와 같아요.

// ToastProvider.tsx
export const ToastProvider = () => {
  ...
  const { position } = useAtomValue(toastOptionAtom);

  return (
    <Portal>
      <div className={`fixed ${position} ...`}>
        ...
      </div>
    </Portal>
  );
};

필요한 부분에 맞게 불러와서 사용하면 됩니다.

옵션을 넘겨주고 싶을 때는 커스텀훅을 통해 넘길 수 있도록 useToast도 수정해주었습니다.

// useToast.ts
export const useToast = (option?: ToastOptionProps) => {
  ...
  const setOption = useSetAtom(toastOptionChangeAtom);

  useEffect(() => {
    if (option) setOption(option);
  }, [option, setOption]);

  ...
};

🥳 사용해봅시다

만든 과정을 통해 우리가 사용할 때 건드릴 건 다음과 같아요.

  • ToastProvider
  • useToast

atom들도 빠지고 Toast 컴포넌트가 빠져있어요! useToast를 통해 추가를 하면 ToastProvider에서 toast의 렌더링을 제어하게 되는 거죠

1. 최상단에 ToastProvider 렌더링해주기

ToastProvider만 한번 렌더링해주면 이 친구가 toast들의 렌더링을 제어해줄 거에요!

저희 팀 같은 경우에는 provider들을 모아두는 파일이 존재해서 여기에 포함했어요. 일반적인 경우라면 layout.tsx에 해주시면 돼요!

// providers.tsx
'use client';

const Providers = ({ children }: PropsWithChildren) => {

  return (
    ...
	  <ToastProvider />
      {children}
    ...
  );
};

export default Providers;

2. useToast로 바로 사용하기

const toast = useToast()
...
return (
  <button onClick={toast.success('토스트가 열렸어요.')}>이거 누르면 토스트</button>
)

지금 toast.success('토스트가 열렸어요.')VoidFunction 타입이에요. 다시 돌아볼게요.

(type: TypeProps) => (title: string) => () => {}

우리는 위와 같은 함수를 통해 여기까지 온 것인데요! 만약

(type: TypeProps) => (title: string) => {}

이렇게 해도 전혀 문제가 없어요! 다만, 위와 같이 될 경우 사용할 때 코드는 아래와 같이 되게 되어요.

<button onClick={() => toast.success('토스트가 열렸어요.')}>이거 누르면 토스트</button>

onClick은 항상 VoidFunction을 받기 때문에 우리는 항상 저런 식으로 () => ...이라는 코드를 통해 VoidFunction을 만들어서 넘겨주었던 것인데요.

저는 이러한 특별한 의미없는 코드를 줄이고자, 설계 시 최종적으로 VoidFunction을 리턴할 수 있도록 커링 형태를 가져간 것이였어요.

🚨 굳이라는 생각이 드신다면 안 해도 전혀 상관 없습니다! 🚨

🤩 요청이 성공하면 toast 띄우기

저희 팀은 react-query를 사용 중이에요. react-query의 장점 중 하나는 비동기 처리에 대한 성공과 실패에 대한 처리를 명시적으로 처리할 수 있다는 점이라고 생각해요!

이를 이용해서 useMutation의 onSuccess를 통해 "성공하면 toast 띄워!"를 작성해주었어요.

export const useDeleteTask = () => {
  ...
  const toast = useToast();

  return useMutation({
    ...
    onSuccess: toast.success('세부 목표를 삭제했어요.'),
    ...
  });
};

그러면 요청이 성공했을 경우 toast가 띄워지게 됩니다. 아래와 같이요!

여기까지 하면 끝입니다!

내 코드에 내가 발목 잡혀 수정까지

추가로.. 🤔 이 toast를 띄우는 과정에서 제가 한 바보 같은 짓이 있었어요.

export const useDeleteTask = () => {
  ...
  const toast = useToast();

  return useMutation({
    ...
    onSuccess: () => {
        toast.success('세부 목표를 삭제했어요.')
    },
    ...
  });
};

위와 같이 작성하면 토스트가 죽어도(?) 안 뜨더라구요. 처음에 이렇게 해놓고 왜 안 될까 계속 고민하다가 제가 작성한 로직들을 돌아보면서 깨닫게 되었어요.

지금 toast.success('세부 목표를 삭제했어요.')는 다음과 같은 값이에요.

"🫤 이게 왜" 하실 수 있지만 잘 보시면 함수를 호출하고 있지 않아요. 그냥 함수 자체를 불러온 거죠 저는.....

그래서 만약에 onSuccess: () => { ... } 형태를 유지하면서 toast를 띄우고 싶다면 아래와 같이 해야 하는 것입니다.

export const useDeleteTask = () => {
  ...
  const toast = useToast();

  return useMutation({
    ...
    onSuccess: () => {
        toast.success('세부 목표를 삭제했어요.')()
    },
    ...
  });
};

이건 제가 설계했던 커링에 의해 발목을 잡힌 것 같았어요. (type) => (title) => {}로 했더라면 위와 같은 일이 없었을 겁니다.

이로인해 커링으로 인한 장단점이 생겨서, 더 보편적인 상황을 생각해보았어요. 보통 toast만 띄우는 상황이 많다면 최종적으로 VoidFunction을 리턴하는 커링을 유지했을 거에요.

고민 끝에 toast만 띄우는 상황 보다는 어떤 상태 처리나 부가적인 액션을 처리를 한 후에 toast를 띄워줄 일이 많다고 판단하여 최종적으로 이전에 작성했던 커링을 조금 수정했어요.

export const toastAtom = atom(
  (get) => get(toastsAtom),
  (get, set, type: ToastProps['type']) => (title: string) => {
    const prev = get(toastsAtom);
    const newToast = { type, title, id: Date.now().toString() };
    set(toastsAtom, [...prev, newToast]);
  },
);

이렇게 해주면 사용할 때는 아래와 같을 거에요.

const toast = useToast()

const handleToastButton = () => {
    toast.success('세부 목표를 수정했어요.')
}

return (
    <button onClick={handleToastButton}>누르면 토스트가 열려요</button>
)

그러면 useMutation에서도 다음과 같은 형태가 될 겁니다.

export const useDeleteTask = () => {
  ...
  const toast = useToast();

  return useMutation({
    ...
    onSuccess: () => {
        toast.success('세부 목표를 삭제했어요.')
    },
    ...
  });
};

좀 더 일반적인 상황에서 자연스럽게 처리될 수 있었습니다. 다른 개발자가 사용하더라도 지금 작성한 코드의 상황이 더 자연스러울 거라고 생각했습니다.


이번 글에서는 제가 제 코드에 발목이 잡혀 반나절 정도를 고생한 과정들을 적어보았어요. 저는 자주 쓰일 컴포넌트를 만들 때 미래(=사용할 때)를 굉장히 고려하는 습관이 있어요. 이번에는 그 미래를 너무 많이 고려하다보니 일반적인 경우에 대해 지나쳐 버린 것 같았습니다.

또, 코드를 거슬러 올라가면서 문제점을 찾고 이를 개선하는 과정의 의미를 배운 것 같아요. 이러한 과정들을 돌아보기 위함으로 글을 작성했음을 알려드리고 글을 마무리해보려고 합니다 :)

profile
개발자와 사용자 모두의 눈👀을 즐겁게 하는 개발자가 되고 싶어요 :) 👩🏻‍💻
post-custom-banner

2개의 댓글

comment-user-thumbnail
2024년 1월 18일

재밌게 읽었습니다!!

1개의 답글