프로젝트에서 Toast 컴포넌트 구현을 맡게 되었다. Toast는 라이브러리로도 사용해 본 적이 없었는데 직접 구현하게 되어서 좋았다.
구현할 Toast의 조건
1. Context API를 이용하여 전역 관리
2. Toast Portal을 만들고 이 안에 Toast를 띄울 것!
3. useToast hook을 통해 팀원들이 사용하기 쉽도록 할 것
1. Toast 컴포넌트 스타일링
우리 팀은 위와 같이 6가지 형태의 toast를 상황에 맞게 사용하기로 하였다.
그래서 메시지를 필수 기본 요소로 두고, 메시지 옆의 아이콘 유무, 닫기 버튼의 유무, action 버튼의 유무에 따라 렌더링 할 요소와 스타일을 다르게 적용하기로 했다.
// components/Toast/index.tsx
import { X } from "@phosphor-icons/react";
import { useCallback, useEffect } from "react";
import Button from "@/components/Button";
import { theme } from "@/styles/theme";
import { ToastContainer, Left, Divider, Content, Right } from "./style";
export type ToastPosition = "top" | "bottom";
export type ToastColor = keyof typeof theme.colors;
export interface ToastProps {
id: string;
message: string;
icon?: React.ReactNode;
iconColor?: ToastColor;
closeButton?: boolean;
onClose: () => void;
onClickButton?: (e: React.MouseEvent) => void;
buttonText?: string;
position?: ToastPosition;
duration?: number;
}
export default function Toast({
id,
message,
icon,
iconColor,
closeButton = false,
onClose,
onClickButton,
buttonText,
position = "bottom",
duration = 2,
}: ToastProps) {
const closeToast = useCallback(onClose, [onClose]);
useEffect(() => {
setTimeout(() => {
closeToast();
}, duration * 1000);
}, [duration, closeToast]);
const handleButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => {
onClickButton && onClickButton(e);
onClose();
};
return (
<ToastContainer
id={id}
hasButton={Boolean(onClickButton)}
position={position}
>
<Content>
<Left iconColor={iconColor}>
{icon && icon}
<span>{message}</span>
</Left>
{closeButton && (
<Right>
<Divider />
<Button
icon={<X />}
name="closeToast"
variant="text"
color="neutral"
onClick={onClose}
/>
</Right>
)}
</Content>
{onClickButton && (
<Button name="button" onClick={handleButtonClick}>
{buttonText}
</Button>
)}
</ToastContainer>
);
}
// components/Toast/style.ts
import { SerializedStyles, css } from "@emotion/react";
import styled from "@emotion/styled";
import { getAnimation } from "@/components/SnackBar/style";
import { ToastPosition, ToastProps } from ".";
interface ToastStyleProps extends Pick<ToastProps, "position"> {
hasButton?: boolean;
}
const ToastListPosition: Record<ToastPosition, SerializedStyles> = {
top: css`
top: 10px;
`,
bottom: css`
bottom: 76px;
`,
};
export const ToastListContainer = styled.div<Pick<ToastProps, "position">>`
width: calc(100% - 32px);
max-width: calc(600px - 32px);
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
position: absolute;
left: 50%;
transform: translateX(-50%);
${({ position = "bottom" }) => ToastListPosition[position]};
`;
export const ToastContainer = styled.div<ToastStyleProps>`
width: 100%;
height: fit-content;
box-shadow:
0px 8px 24px -4px rgba(24, 39, 75, 0.08),
0px 6px 12px -6px rgba(24, 39, 75, 0.12);
background-color: white;
border-radius: 8px;
padding: 24px 16px;
z-index: ${({ theme }) => theme.zIndex.toast};
display: flex;
flex-direction: column;
align-items: flex-start;
pointer-events: all;
gap: 10px;
${({ theme }) => theme.typo["body-2-r"]};
color: ${({ theme }) => theme.colors["neutral"]["90"]};
svg {
width: 20px;
height: 20px;
}
& > button {
width: 100%;
}
${({ position = "bottom" }) => css`
animation: ${getAnimation(position)} 0.3s;
`}
`;
(생략)
`;
우리는 toast에 배경색을 여러 개 두지 않는 대신 들어가는 아이콘에 상황에 맞는 색상을 설정하기로 했다.
처음엔 아이콘 색상 props는 없었는데 매번 아이콘이 포함된 toast를 사용할 때마다
import useTheme from "@emotion/react"
const theme = useTheme();
toast.open({ ..., icon: <SomeIcon color={theme.colors...} />})
이런 식으로 theme를 불러오고 색상을 넣는 과정이 번거로울 것 같다는 생각이 들어 아이콘 색상으로 사용 가능한 색상을 5가지 정해두고 원하는 옵션을 iconColor로 넘기기로 했다.
2. Toast Portal을 만들고 Toast를 배치하기
처음엔 portal을 만들어야 할까 고민은 했지만 나는 아직까지 context 사용 경험이 적었고, 이런 식으로 전역 관리를 할 수 있는 컴포넌트를 만들어 보는 것도 처음이어서 일단 없이 구현한 후에 피드백을 받아보자..! 했다. 그리고 역시 Portal이 있으면 더 좋을 것 같다는 의견을 받았다. portal을 이용하면 부모 컴포넌트의 스타일링으로부터 자유롭고, 부모 컴포넌트의 DOM 계층 구조 바깥에 있는 DOM 노드로 자식을 렌더링하기 때문에 개발자 도구에서 볼 때도 편하다..! React 공식 문서에서도 modal, tooltip과 같은 페이지 위에 띄워지는 요소에 사용하는 걸 portal의 사용 예시로 들고 있다.
// components/Toast/ToastPortal.tsx
import { createPortal } from "react-dom";
import { ToastListContainer, ToastPortalContainer } from "./style";
import Toast, { ToastProps } from ".";
interface ToastPortalProps {
topToasts: ToastProps[];
bottomToasts: ToastProps[];
}
export default function ToastPortal({
topToasts,
bottomToasts,
}: ToastPortalProps) {
return createPortal(
<ToastPortalContainer id="toast-portal">
{topToasts.length !== 0 && (
<ToastListContainer position="top">
{topToasts
.filter((toast) => toast.position === "top")
.map((item) => (
<Toast
id={item.id}
message={item.message}
icon={item.icon}
iconColor={item.iconColor}
closeButton={item.closeButton}
onClose={item.onClose}
buttonText={item.buttonText}
onClickButton={item.onClickButton}
duration={item.duration}
position={item.position}
key={item.id}
/>
))}
</ToastListContainer>
)}
{bottomToasts.length !== 0 && (
<ToastListContainer position="bottom">
{bottomToasts.map((item) => (
<Toast
id={item.id}
message={item.message}
icon={item.icon}
iconColor={item.iconColor}
closeButton={item.closeButton}
onClose={item.onClose}
buttonText={item.buttonText}
onClickButton={item.onClickButton}
duration={item.duration}
key={item.id}
/>
))}
</ToastListContainer>
)}
</ToastPortalContainer>,
document.body,
);
}
// components/Toast/style.ts
export const ToastPortalContainer = styled.div`
width: 100%;
height: 100dvh;
position: fixed;
inset: 0;
pointer-events: none;
z-index: ${({ theme }) => theme.zIndex.toast};
padding: 10px 0px 76px;
`;
다음과 같이 createPortal을 사용하여 toast portal을 만들었다. 나는 toast가 여러 개 띄워질 때 각각의 toast를 화면의 상단, 하단 중 원하는 곳에 띄울 수 있도록 하기 위해 각 영역에 toast들이 들어올 수 있는 ListContainer를 만들었다. ListContainer는 position을 props로 받아 위치를 정하게 된다. 이러면 toast 요소 자체는 따로 위치를 지정을 위해 스타일 코드를 작성할 필요가 없다!
toast가 추가될 때 portal 내부에 자식이 생기고 삭제될 때 자식도 사라진다.
useEffect로 추가적인 과정 없이 portal을 생성했더니 자동으로 body 최하단에 위치해서 script보다도 밑에 위치하고 있다. 당장 불편하거나 문제가 되는 점은 없으니 이 부분도 후에 개선하면 좋을 것 같다!
3. Context API를 통한 전역 관리
import { createContext, useCallback, useMemo, useState } from "react";
import { ToastProps } from "@/components/Toast";
import ToastPortal from "@/components/Toast/ToastPortal";
import { StrictPropsWithChildren } from "@/types";
interface State {
toasts: ToastProps[];
addToast: (toast: Omit<ToastProps, "id" | "onClose">, id: string) => void;
removeToast: (id: string) => void;
}
export const ToastContext = createContext<State | null>(null);
export function ToastContextProvider({ children }: StrictPropsWithChildren) {
const [toasts, setToasts] = useState<ToastProps[]>([]);
// 상단에 위치할 toasts
const topToasts = useMemo(() => {
return toasts.filter((toast) => toast.position === "top");
}, [toasts]);
// 하단에 위치할 toasts
const bottomToasts = useMemo(() => {
return toasts.filter((toast) => toast.position !== "top");
}, [toasts]);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
const addToast = useCallback(
(toast: Omit<ToastProps, "id" | "onClose">, id: string) => {
setToasts((prev) => [
...prev,
{ ...toast, id, onClose: () => removeToast(id) },
]);
},
[removeToast],
);
const value = useMemo(
() => ({ toasts, addToast, removeToast }),
[addToast, toasts, removeToast],
);
return (
<ToastContext.Provider value={value}>
{children}
<ToastPortal topToasts={topToasts} bottomToasts={bottomToasts} />
</ToastContext.Provider>
);
}
4. useToast 구현
import { useContext } from "react";
import { v4 as uuidv4 } from "uuid";
import { ToastContext } from "@/contexts/ToastContext";
import { ToastProps } from ".";
export default function useToast() {
const context = useContext(ToastContext);
if (!context) throw new Error("can not found toast provider");
const open = (toast: Omit<ToastProps, "id" | "onClose">) => {
context.addToast(toast, uuidv4());
};
const close = (id: string) => {
context.removeToast(id);
};
return { open, close, list: context.toasts };
}
toast의 각 id는 고유 값인 uuid를 이용하였다. 각각의 toast의 유지 시간을 다르게 설정할 수 있기 때문에 단순히 toast list에 추가될 때 index를 이용하여 id를 설정하면 후에 삭제 시 문제가 일어날 수 있다고 생각했다. 처음엔 id만 uuid를 이용하였는데 portal에서 toast list를 map 할 때 각 toast의 key 또한 list가 계속 변하기 때문에 고유 값을 설정하는 게 좋을 것 같다는 팀원 분의 피드백으로 key에도 id로 들어가는 uuid를 지정해 주었다.
그리고 여기엔 길어서 생략했지만 toast에 있는 props가 많다 보니 open 함수에 파라미터인 toast의 각 속성에 대한 설명과 예시를 추가하였다.
완성된 Toast 사용하기
import useToast from "@/components/Toast/useToast";
const toast = useToast();
const handleReviewSubmit = () => {
...
reviewMutation.mutate(
review,
{
onSuccess: () => {
toast.open({
message: "리뷰가 등록되었어요.",
icon: <CheckCircle weight="fill" />,
iconColor: "green",
buttonText: "내 모든 리뷰 보러 가기",
onClickButton: () => navigate("/profile"),
position: "top",
});
},
onError: (error) => {
...
toast.open({
message: "오류가 발생했어요. 잠시 후 다시 시도해 주세요.",
icon: <WarningCircle weight="fill" />,
iconColor: "warn",
position: "top",
});
},
},
);
};
전역으로 관리되는 UI 컴포넌트는 처음 구현해 보았는데 과정은 쉽지 않았지만 한번 만들어두면 나와 다른 팀원들 모두 편하게 사용할 수 있다는 점이 참 좋은 것 같다. 라이브러리 없이 구현해서 뿌듯하기도 하다! 코드가 아주 만족스럽지는 않지만 좋은 경험을 한 것 같다.
참고
'Develop > React' 카테고리의 다른 글
[React] useRef, scrollIntoView를 통한 스크롤 위치 이동 (0) | 2024.09.23 |
---|---|
Zustand 사용해 보기 (+ Redux와 비교) (0) | 2024.06.27 |
[React] ios 환경에서 input, textarea 화면 확대 방지하기 (+ 웹 접근성) (0) | 2023.10.13 |
[React] useReducer (0) | 2023.09.11 |
[React] React Router - RouterProvider, createBrowserRouter (0) | 2023.09.11 |