계정이 존재하지 않거나 일부 정보가 틀렸을 때 사용자에게 안내 메시지를 제공하기 위해 애플리케이션 전역에서 사용할 토스트 메시지를 구현했습니다.
모든 컴포넌트에서 토스트 메시지를 설정할 수 있으며 토스트 메시지 관련 상태값이 변경되었을 때 이를 구독하고 있는 <Toast />가 호출될 수 있게 리액트의 Context를 사용했습니다.
이 글에서는 리액트의 Context를 사용해서 토스트 메시지를 구현하는 방법과 Context를 사용했을 때 불필요한 렌더링이 발생하지 않도록 최적화하는 방법을 다루고 있습니다.
Context
Context는 리액트 컴포넌트간 어떠한 값을 공유할 수 있게 해주는 기능입니다. 어떠한 값이란 원시 타입의 값일 수도 있고 객체일 수도 있습니다.
리액트에서 Context를 사용하는 이유는 대부분 애플리케이션 전역 또는 컴포넌트 간 상태를 공유하기 위해서 입니다. Context를 사용하면 Props가 아닌 다른 방식으로 상태를 공유하기 때문에 Props Drilling을 해결할 수 있습니다.
추가로, 주로 전역적으로 필요한 값을 다룰 때 사용하지만 꼭 전역적일 필요는 없다고 합니다. 여러 자식 컴포넌트가 부모 컴포넌트의 특정 값에 의존할 때 Context를 사용하면 좋다고 합니다.
import { createContext, useContext } from 'react';
const MyContext = createContext();
function App() {
return (
<MyContext.Provider value="Hello World">
<ParentComponent />
</MyContext.Provider>
);
}
function ParentComponent() {
return (
<div>
<FirstComponent />
<SecondComponent />
<ThirdComponent />
</div>
);
}
function FirstComponent() {
const value = useContext(MyContext);
return <div>First Component says: "{value}"</div>;
}
function SecondComponent() {
const value = useContext(MyContext);
return <div>Second Component says: "{value}"</div>;
}
...
토스트 메시지 구현
본격적으로 Context를 사용해서 토스트 메시지를 구현하는 방법을 소개하겠습니다. 아래는 참고한 강의와 자료입니다.
1. Context 생성
리액트의 createContext 함수를 사용해 Context를 생성할 수 있습니다. 우리가 만들 토스트 메시지는 제목(title), 메시지 내용(message), 상태(status)로 이루어져 있습니다.
Context를 Value와 Actions로 분리한 이유는 불필요한 렌더링을 하지 않기 위해서 입니다. Value와 Actions를 하나의 Context로 관리하면 상태가 변경되었을 때 Actions만을 사용하는 컴포넌트까지 리렌더링이 발생합니다. 참고로, Value는 상태이고 Actions는 set함수입니다.
이렇게 Context는 성능 상의 이유로 Context를 Value와 Actions로 분리하는 경우가 많습니다. Context로 관리하는 상태가 여러개인 경우 각각의 상태에 대해 분리를 해야하기 때문에 코드의 복잡성이 증가할 수 있습니다. 이러한 현상이 우리가 Redux, Recoil, Mobx 등의 상태 관리 라이브러리를 사용하는 이유 중 하나입니다.
아래의 코드처럼 Context를 분리하면 상태가 변경되었을 때 Value를 사용하는 컴포넌트만 리렌더링 됩니다.
// store/notification-context.tsx
'use client';
import { createContext } from 'react';
interface NotificationData {
title: string;
message: string;
status: 'pending' | 'success' | 'error';
}
export const NotificationValueContext = createContext<NotificationData | null>(null);
export const NotificationActionsContext = createContext<{
showNotification(notificationData: NotificationData): void;
hideNotification(): void;
} | null>(null);
2. Provider 컴포넌트 구현
Context로 유동적인 값을 관리하기 위해 useState를 사용합니다. 유동적인 값을 관리할 때는 Provider를 렌더링하는 컴포넌트를 별도로 구현해주는게 좋다고 합니다.
상태를 변화시키는 함수들을 객체로 묶어서 actions 라는 변수에 넣어줬고 컴포넌트가 렌더링될 때마다 함수를 새로 만들지 않게 useMemo로 감싸줬습니다. 함수가 새로 만들어지면 변경되었다고 판단하기 때문에 리렌더링 됩니다.
useEffect에서는 메시지 출력 후 3초가 지났을 때 메시지를 제거해주는 작업이 구현되어 있습니다. 메모리 누수가 발생하지 않게 clean-up 함수에서 타이머를 제거했습니다.
이 Provider 하위 컴포넌트에서만 Provider가 제공하는 값에 접근할 수 있습니다.
// store/notification-context.tsx
import { useEffect, useMemo, useState } from 'react';
// 여기에 Context 생성하는 코드 있음
export function NotificationContextProvider(props: { children: React.ReactNode }) {
const [activeNotification, setActiveNotification] = useState<NotificationData | null>(null);
const actions = useMemo(
() => ({
showNotification(notificationData: NotificationData) {
setActiveNotification(notificationData);
},
hideNotification() {
setActiveNotification(null);
},
}),
[],
);
useEffect(() => {
if (activeNotification && (activeNotification.status === 'success' || activeNotification.status === 'error')) {
const timer = setTimeout(() => {
setActiveNotification(null);
}, 3000);
return () => {
clearTimeout(timer);
};
}
}, [activeNotification]);
return (
<NotificationActionsContext.Provider value={actions}>
<NotificationValueContext.Provider value={activeNotification}>{props.children}</NotificationValueContext.Provider>
</NotificationActionsContext.Provider>
);
}
3. 커스텀 훅 구현
함수형 컴포넌트에서 Context가 관리하는 값에 접근하기 위해 useContext 훅을 사용합니다. useContext에 접근하고자 하는 Context를 인수로 전달해주면 됩니다.
const value = useContext(SomeContext)
이러한 로직을 여러 컴포넌트에서 재사용하기 위해 Value와 Actions 각각의 커스텀 훅을 구현했습니다.
// hooks/useNotificationValue.ts
import { useContext } from 'react';
import { NotificationValueContext } from '@/store/notification-context';
function useNotificationValue() {
const value = useContext(NotificationValueContext);
// Provider에게 값을 전달했는지 확인
if (value === undefined) {
throw new Error('useNotificationValue 오류');
}
return value;
}
export default useNotificationValue;
// hooks/useNotificationActions.ts
import { useContext } from 'react';
import { NotificationActionsContext } from '@/store/notification-context';
function useNotificationActions() {
const value = useContext(NotificationActionsContext);
// Provider에게 값을 전달했는지 확인
if (value === null) {
throw new Error('useNotificationActions 오류');
}
return value;
}
export default useNotificationActions;
4. 컴포넌트에서 Context 접근 및 구독
useContext 훅으로 Context가 관리하는 값에 접근하면 컴포넌트는 해당 값을 구독하게 됩니다. 이는 옵저버 패턴과 관련이 있습니다. Context가 관리하는 값이 변경되면 이를 구독하고 있는 모든 컴포넌트 함수가 호출되어 리렌더링이 발생합니다.
Observer Pattern
상태 변화 등 이벤트가 발생할 때 메서드 등을 통해 객체가 직접 목록의 각 옵저버에게 통지하도록 하는 디자인 패턴
<Toast />를 Context가 관리하는 토스트 메시지 상태를 구독하고 있어 토스트 메시지 상태가 변경되면 리렌더링이 발생하고 토스트 메시지 상태가 있을 때만 노출됩니다.
참고로, 훅을 사용하고 있기 때문에 클라이언트 컴포넌트이어야 합니다.
// components/ui/Toast.tsx
'use client';
import clsx from 'clsx';
import useNotificationValue from '@/hooks/useNotificationValue';
function Toast() {
const notification = useNotificationValue();
return (
<div className={clsx('fixed right-1/2 z-[99999] mt-6 translate-x-2/4', { hidden: !notification })}>
<div className="flex h-[50px] min-w-[400px] items-center rounded-3xl bg-[#222222] py-[6px] pl-3 pr-[43px] opacity-80">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="#f15746"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"
/>
</svg>
<div className="flex-1 text-center text-sm leading-[17px] text-white">
<p>{notification?.message}</p>
</div>
</div>
</div>
);
}
export default Toast;
5. 컴포넌트에서 Context값 변경하기
로그인 폼에서 useNotificationActions 커스텀 훅을 통해 토스트 메시지를 보여줄 수 있는 set함수에 접근할 수 있습니다. 이를 통해 로그인 실패 시 서버에서 전달받은 메시지를 토스트 메시지의 상태로 설정합니다.
위에서 언급한거처럼 Context가 관리하는 값이 변경되면 해당 값을 구독하고 있는 컴포넌트가 리렌더링됩니다. 따라서 토스트 메시지 상태를 구독하고 있는 <Toast />가 리렌더링됩니다.
// components/login/login-form.tsx
'use client';
import { useState } from 'react';
import clsx from 'clsx';
import { authenticate } from '@/lib/actions';
import { emailRegex, passwordRegex } from '@/lib/schemas';
import LoginButton from './login-button';
import useNotificationActions from '@/hooks/useNotificationActions';
function LoginForm() {
const [email, setEmail] = useState({ value: '', isDirty: false, isValid: false });
const [password, setPassword] = useState({ value: '', isDirty: false, isValid: false });
const actions = useNotificationActions();
...
const isFormValid = email.isValid && password.isValid;
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!isFormValid) return;
const message = await authenticate({ email: email.value, password: password.value });
// 로그인 실패시 서버에서 메시지를 보낸다.
if (message) {
actions?.showNotification({
title: '',
message: message,
status: 'error',
});
}
};
const isEmailInvalid = email.isDirty && !email.isValid;
const isPasswordInvalid = password.isDirty && !password.isValid;
return (
<form onSubmit={handleSubmit}>
...
</form>
);
}
export default LoginForm;
마치며
Context를 사용하여 애플리케이션 전역에서 사용하는 토스트 메시지를 구현하는 과정에서 Context, Context와 상태 관리 라이브러리 차이점, 렌더링 성능 최적화, Observer 패턴, Proxy 패턴 등을 이해할 수 있었습니다.
'Next.js' 카테고리의 다른 글
Next.js 14에서 NextAuth 없이 로그인 인증 구현하기 (0) | 2024.04.15 |
---|---|
Next.js 14에서 원격 이미지 최적화하기 (1) | 2024.02.16 |
Next.js 14에서 Tailwind CSS 직접 적용해보기 (1) | 2024.02.12 |
Next.js 14 프로젝트 초기 설정 (0) | 2024.02.10 |