사용자에게 간단한 피드백을 제공하는 UI 요소 중 하나로 Toast 메시지가 있다. 네트워크 요청이 성공하거나 실패했을 때 토스트 메세지로 빠르게 알림을 보여주면 UX가 훨씬 좋아진다.
하지만 토스트를 무심코 구현하다 보면 불필요한 상태 관리, 과도한 리렌더링, 여러 개의 토스트를 띄우지 못하는 등의 문제가 생길 수 있다.
이번 글에서는 가장 단순한 토스트 구현부터 시작해, 점차 개선하여 Context API로 토스트를 구현한 과정을 공유하려 한다.
Context API 에 대해 우테코 레벨 2때 공부하면서 이 글을 계속 문서화하려고 했으나 이래저래 레벨 4인 지금에서야 작성하게 되었다...
참고) 이 글에서 사용한 스타일 라이브러리는 emotion/styled 이고,예제는 우테코 레벨2 react-shopping-products 미션에서 구현한 코드이다.
이 구현 코드를 바탕으로 현재 픽잇 프로젝트에도 효율적인 토스트를 구현하였다.
가장 쉽게 토스트를 구현하는 방법은 아래처럼 상태를 컴포넌트 안에 두고 조건부 렌더링을 통해 토스트 메시지를 보여주는 것이다.
function ComponentWithToast() {
const [message, setMessage] = useState('');
const handleClick = () => {
setMessage('저장되었습니다!');
setTimeout(() => setMessage(''), 3000);
};
return (
<div>
<button onClick={handleClick}>저장하기</button>
{message && <Toast>{message}</Toast>}
</div>
);
}
이 방식은 간단하지만 다음과 같은 문제가 있다
1. 상태가 사용하는 쪽 컴포넌트에 있기 때문에 다른 곳에서 재사용하기 어렵다.
2. 토스트의 관리가 특정 컴포넌트에 강하게 결합되어 있다.
3. 하나의 상태만 관리하므로 여러 개의 토스트를 동시에 띄울 수 없다.
4. 토스트 상태 (const [message, setMessage] = useState('');) 를 만든 컴포넌트 하위 자식이 모두 리렌더링된다.
이러한 문제점을 해결할 수 있도록 하나씩 차근차근 아이디어를 내보자.
1. 상태가 사용하는 쪽 컴포넌트에 있기 때문에 다른 곳에서 재사용하기 어렵다.
→ 사용하는 쪽에서 함수 호출 처럼 토스트를 띄우고 내릴 수 있다면, 재사용도 가능해지고 try catch 블록에서 에러 처리도 쉽게 할 수 있지 않을까?
2. 토스트의 관리가 특정 컴포넌트에 강하게 결합되어 있다.
→ 토스트가 뜨는 곳은 고정되어있고, 이를 한 곳에서 관리할 수 있으면 좋을 것 같다.
3. 하나의 상태만 관리하므로 여러 개의 토스트를 동시에 띄울 수 없다.
→ 토스트의 정보를 배열로 관리한다면 해소할 수 있을 것 같다.
4. 토스트 상태 (const [message, setMessage] = useState('');) 를 만든 컴포넌트 하위 자식이 모두 리렌더링된다.
→ Context API 로 context 를 구독하는 컴포넌트만 리렌더링되도록 영향 범위를 쪼개보자
→ 또한 value 값을 고정시킬 수 있다면, 구독하고있는 컴포넌트들조차 리렌더링을 발생시키지 않을 수 있을 것이다.
Context API 를 활용하여 위 아이디어를 적용해보면 해당 문제점들을 해소할 수 있다.
👇 ToastMessage 컴포넌트 구현 코드
interface ToastMeesageProps {
message: string;
type: MessageType;
onClose: () => void;
}
function ToastMessage({ message, type, onClose }: ToastMeesageProps) {
// ToastMessage 컴포넌트는 onClose 함수를 받아 3초 뒤 실행시킨다.
setTimeout(() => {
if (onClose) {
onClose();
}
}, 3000);
return (
<S.Container>
<S.Wrapper type={type}>
<S.ErrorText>{message}</S.ErrorText>
</S.Wrapper>
</S.Container>
);
}
export default ToastMessage;
👇 ToastProvider 구현 코드
export interface ToastItem {
id: string;
type: ToastType;
message: string;
}
interface ToastContextType {
showToast: (message: string, type: ToastType) => void;
removeToast: (id: string) => void;
}
export const ToastContext = createContext<ToastContextType | undefined>(
undefined
);
export function ToastProvider({ children }: { children: ReactNode }) {
// ToastItem 객체를 배열로 담고있는 상태를 만들어 여러 개의 토스트를 동시에 순차적으로 띄울 수 있도록 한다.
const [toasts, setToasts] = useState<ToastItem[]>([]);
const showToast = useCallback((message: string, type: ToastType) => {
const id = Math.random().toString();
setToasts((prev) => [...prev, { id, message, type }]);
}, []);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
// children 과 toast 상태 영향 범위를 분리한다.
return (
<ToastContext.Provider value={{ showToast, removeToast }}>
{children}
<S.ToastContainer>
{toasts.map((toast) => (
<ToastMessage
key={toast.id}
message={toast.message}
type={toast.type}
onClose={() => removeToast(toast.id)}
/>
))}
</S.ToastContainer>
</ToastContext.Provider>
);
}
export function useToastContext() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('컨텍스트는 Provider 내부에서만 사용할 수 있습니다.');
}
return context;
}
위 코드를 보며 앞에서 언급했던 세 가지 문제점을 하나씩 다시 짚어보고, 지금 구조에서 어떻게 해결했는지 정리해보자.
초기의 단순한 구현에서는 useState로 관리하는 message 상태가 개별 컴포넌트 안에 존재했다.
따라서 다른 컴포넌트에서 토스트를 띄우고 싶으면 매번 상태를 따로 만들고, 조건부 렌더링 로직도 반복해서 작성해야 했다.
해결 방법
ToastProvider 내부에서 toasts 배열을 상태로 관리한다.showToast, removeToast 함수를 Context를 통해 하위 컴포넌트로 내려주어, 어디서든 함수 호출 한 줄만으로 토스트를 띄우고 내릴 수 있도록 한다..즉, 사용하는 쪽에서는 상태 관리 코드를 몰라도 되고, 단순히:
function Example() {
const { showToast } = useToastContext();
const handleAsync = async () => {
try {
// 비동기 요청 블록
} catch (e) {
showToast('비동기 요청에 실패하였습니다.', 'error');
}
};
이처럼 호출만 하면 된다. 재사용성이 크게 증가했다.
특정 UI 안에서 토스트를 직접 렌더링할 경우, 토스트는 그 컴포넌트의 라이프사이클에 묶여버린다. 토스트는 사실 전역 UI 알림에 가깝기 때문에, 특정한 페이지·뷰보다 앱 전역의 고정된 위치에서 출력되는 게 자연스럽다고 생각했다.
return (
<ToastContext.Provider value={{ showToast, removeToast }}>
{children}
<S.ToastContainer>
{toasts.map((toast) => (
<ToastMessage
key={toast.id}
message={toast.message}
type={toast.type}
onClose={() => removeToast(toast.id)}
/>
))}
</S.ToastContainer>
</ToastContext.Provider>
)
해결 방법
ToastProvider 하단에 <S.ToastContainer>를 두고, 여기서만 토스트를 실제 렌더링한다.
children은 본래의 화면이고, 토스트가 렌더링되는 <S.ToastContainer> 와 형제 관계에 둠으로써 영향을 분리시킨다.
ToastMessage 는 onClose 를 prop 으로 받고 3초 후 스스로 onClose()를 호출해 배열에서 제거된다.
이렇게 하면 토스트 메시지는 항상 동일한 위치에 고정적으로 렌더링되고, 특정 컴포넌트와 결합되지 않는다.
토스트 관리의 관심사를 전역 컨텍스트로 위임하여 컴포넌트 로직과 UI 로직을 분리한 것이다.
초기 구현에서는 message라는 단일 문자열만 상태로 관리했을 뿐이라, 토스트를 하나 더 띄우면 기존 토스트가 덮어쓰기 되었다. 이런 방식은 연속적으로 네트워크 요청이 발생하는 경우 UX가 좋지 않다.
해결 방법
export interface ToastItem {
id: string;
type: ToastType;
message: string;
}
...
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
...
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
ToastItem[])로 관리한다. showToast는 새로운 토스트 객체를 만들어 배열에 push하고, removeToast는 특정 id를 가진 토스트를 제거한다. 앞에서 말한 것처럼 상태를 한 컴포넌트 안에 두면, 토스트가 뜰 때 해당 컴포넌트 하위 트리가 모두 다시 그려진다.
예를 들어 ComponentWithToast 아래에 무겁거나 복잡한 UI가 있으면, 단순히 토스트만 띄워도 매번 같은 UI들이 리렌더링된다.
해결 방법
ToastProvider 내부에서 토스트 전용 상태(toasts)를 관리하고, children과 토스트 렌더링 영역을 형제 관계로 둔다.즉, 토스트 렌더링 범위를 ToastProvider 내부의 별도 <S.ToastContainer>로만 제한함으로써, 토스트 상태 변경이 전체 UI 리렌더링으로 번지지 않도록 최적화한 것이다.
이제 구조상 깔끔해졌지만, 아직 문제가 하나 있다.
바로 "토스트가 뜰 때마다 showToast 함수를 사용하는 모든 컴포넌트가 리렌더링되는 것"이다.

왜 이런 일이 일어날까? 분명 showToast, removeToast를 useCallback으로 감쌌고, 토스트 영역과 children을 분리했는데도 리렌더링이 발생한다.
그 이유는 Provider의 value 때문이다.
ContextAPI 는 value 변경 시 해당 Context를 구독(useContext)하고 있는 모든 하위 컴포넌트를 리렌더링한다.
showToast와 removeToast 의 참조값은 고정해주었지만,
<ToastContext.Provider value={{ showToast, removeToast }}>
위처럼 작성하면 매번 { showToast, removeToast }라는 새로운 객체를 넘겨주는 것이기 때문에 Context를 구독하는 모든 컴포넌트가 리렌더링되는 것이다.
이 문제를 해결하기 위해 value를 useMemo로 감싸 참조값을 고정했다.
const valueToast = useMemo(() => {
return { showToast, removeToast };
}, [removeToast, showToast]);
return (
<ToastContext.Provider value={valueToast}>
{children}
import {
createContext,
useState,
ReactNode,
useCallback,
useContext,
useMemo,
} from 'react';
import ToastMessage from '../components/common/ToastMessage';
import styled from '@emotion/styled';
type ToastType = 'error' | 'info';
export interface ToastItem {
id: string;
type: ToastType;
message: string;
}
interface ToastContextType {
showToast: (message: string, type: ToastType) => void;
removeToast: (id: string) => void;
}
export const ToastContext = createContext<ToastContextType | undefined>(
undefined
);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const showToast = useCallback((message: string, type: ToastType) => {
const id = crypto.randomUUID();
setToasts((prev) => [...prev, { id, message, type }]);
}, []);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
const valueToast = useMemo(() => {
return { showToast, removeToast };
}, [removeToast, showToast]);
return (
<ToastContext.Provider value={valueToast}>
{children}
<S.ToastContainer>
{toasts.map((toast) => (
<ToastMessage
key={toast.id}
message={toast.message}
type={toast.type}
onClose={() => removeToast(toast.id)}
/>
))}
</S.ToastContainer>
</ToastContext.Provider>
);
}
export function useToastContext() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('컨텍스트는 Provider 내부에서만 사용할 수 있습니다.');
}
return context;
}
const S = {
ToastContainer: styled.div`
position: fixed;
bottom: 40px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
display: flex;
gap: 8px;
flex-direction: column;
`,
};
이제 최종 ToastProvider는 다음과 같은 장점을 갖는다:
showToast("저장 성공", "info") 호출만으로 어디서든 토스트 사용이 가능하다.useMemo로 Context value의 참조값을 고정하여 불필요한 리렌더링을 차단했다.
단순해 보이는 토스트 구현에서도 고민하고 배울 점이 있었다.
리액트가 제공하는 생명 주기 안에서 어떻게 최대한 선언적이고 최적화된 코드를 작성할 수 있는지 고민하는 것은 늘 새롭고 즐거운 일인 것 같다.