출처: https://github.com/Yundimin/next15-boilerplate
GitHub - Yundimin/next15-boilerplate: next15-boilerplate with pandacss
next15-boilerplate with pandacss. Contribute to Yundimin/next15-boilerplate development by creating an account on GitHub.
github.com
- src/app/layout.tsx — 루트 레이아웃에서 전역 모달 렌더링 지점 추가
- src/components/common/modal.tsx — 전역 모달 렌더러 (컨텍스트의 열린 모달 목록을 실제 DOM으로 그림)
- src/constants/modal-key-constants.ts — 모달 식별 키 상수
- src/constants/motion-constants.ts — 프레이머 모션 프리셋(슬라이드/페이드/팝/드로어/드롭다운)
- src/contexts/core-provider.tsx — ModalProvider를 코어 프로바이더 트리에 포함
- src/contexts/modal-provider.tsx — 전역 모달 상태 컨텍스트 (열기/닫기/라우트 변경 시 초기화)
- src/hooks/use-modals.ts — openModal, closeModal
전체 흐름
- 컨텍스트 준비 (ModalProvider)
- 전역에서 열려 있는 모달 배열 openedModals를 관리합니다.
- openModal({ id, component })로 푸시, closeModal(id)로 제거.
- 라우트 뒤로가기(popstate) 시 모달 초기화, 모달 열릴 땐 document.body.style.overflow = 'hidden' 처리.
- 전역 렌더러 (components/common/Modal)
- 컨텍스트의 openedModals를 순회 렌더링.
- Dimmed(검은 배경) 클릭 시 해당 id로 닫기.
- 모달 컨텐츠는 openModal 호출 시 넘긴 component를 그대로 사용.
- MOTION 상수 import(프리셋 제공) — 커밋 시점에는 구조만 넣어두고, 실제 변이 적용은 이후 확장 여지.
- 루트 레이아웃에서 한 번만 삽입 (app/layout.tsx)
- <CoreProvider> 트리 안쪽에 <Modal />를 추가하여 어디서든 모달을 띄우면 이 자리에서 렌더되게 함.
- 페이지에서 열기 (app/users/page.tsx)
- useModals().openModal({ id, component }) 호출.
- 이때 component로 실제 렌더링할 리액트 노드(여기서는 UserCreateFormModal)를 넘김.
- 폼 내부에서 닫기 (components/user/user-create-form.tsx)
- useCreateUserMutation 성공 콜백에서 closeModal(MODAL.USER_CREATE) 호출.
- 실패 시 react-hook-form의 setError로 필드 에러 표기.
1) Modal Context 생성 : src/contexts/modal-provider.tsx
'use client';
import React, { createContext, useCallback, useEffect, useState } from 'react';
export interface ModalComponentType {
id: React.Key;
component: React.ReactNode;
}
interface ModalContextType {
openedModals: ModalComponentType[];
openModal: (modalComponent: ModalComponentType) => void;
closeModal: (id: React.Key) => void;
}
interface ModalContextProps {
children: React.ReactNode;
}
export const ModalContext = createContext<ModalContextType>({} as ModalContextType);
const ModalProvider: React.FC<ModalContextProps> = ({ children }) => {
const [openedModals, setOpenedModals] = useState<ModalComponentType[]>([]);
const openModal = useCallback((props: ModalComponentType) => {
setOpenedModals((modals) => {
return [...modals, { ...props }];
});
}, []);
const closeModal = useCallback((id: React.Key) => {
setOpenedModals((modals) => {
return modals.filter((modal) => modal.id !== id);
});
}, []);
useEffect(() => {
document.body.style.overflow = openedModals.length > 0 ? 'hidden' : 'auto';
return () => {
document.body.style.overflow = 'auto';
};
}, [openedModals]);
useEffect(() => {
const handleRouteChange = () => {
setOpenedModals([]);
};
window.addEventListener('popstate', handleRouteChange);
return () => {
window.removeEventListener('popstate', handleRouteChange);
};
}, []);
return (
<ModalContext.Provider
value={{
openedModals,
openModal,
closeModal,
}}
>
{children}
</ModalContext.Provider>
);
};
export default ModalProvider;
2) 전역 렌더러: src/components/common/modal.tsx
'use client';
import { MOTION } from '@/constants/motion-constants';
import { ModalContext } from '@/contexts/modal-provider';
import useModals from '@/hooks/use-modals';
import { sva } from '@/styled-system/css';
import { Box } from '@/styled-system/jsx';
import { motion } from 'motion/react';
import { useContext, useEffect } from 'react';
const Modal = () => {
const modalStyle = ModalSva();
const { openedModals } = useContext(ModalContext);
const { closeModal } = useModals();
useEffect(() => {
document.body.style.overflow = openedModals.length > 0 ? 'hidden' : 'auto';
}, [openedModals]);
return (
<>
{openedModals.map((modal, index) => {
const { id, component } = modal;
return (
<Box className={modalStyle.wrapper} key={index}>
<Box className={modalStyle.dimmed} onClick={() => closeModal(id)} />
<motion.div className={modalStyle.modal} {...MOTION.POP}>
{component}
</motion.div>
</Box>
);
})}
</>
);
};
export default Modal;
const ModalSva = sva({
slots: ['wrapper', 'dimmed', 'modal'],
base: {
wrapper: {
position: 'fixed',
left: 0,
top: 0,
zIndex: 10,
display: 'flex',
height: '100dvh',
width: '100vw',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
dimmed: {
position: 'fixed',
top: 0,
left: 0,
width: '100dvw',
height: '100dvh',
overflow: 'hidden',
backgroundColor: 'black',
opacity: 0.48,
},
modal: {
position: 'relative',
},
},
});
3) 모션 프리셋: src/constants/motion-constants.ts
import { HTMLMotionProps } from 'framer-motion';
export type MotionType = 'SLIDE' | 'FADE' | 'POP' | 'DRAWER' | 'DROPDOWN';
export const MOTION: Record<MotionType, HTMLMotionProps<'div'>> = {
SLIDE: { initial: { opacity: 0, y: '50%' }, animate: { opacity: 1, y: 0, transition: { bounce: 0, duration: 0.3 } } },
FADE: { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } },
POP: { initial: { opacity: 0, y: '100%' }, animate: { opacity: 1, y: 0, transition: { bounce: 0, duration: 0.2 } }, exit: { opacity: 0, y: '100%' } },
DRAWER: { initial: { opacity: 0, x: '100%' }, animate: { opacity: 1, x: 0 }, exit: { opacity: 0, x: '100%' }, transition: { bounce: 0, duration: 0.2 } },
DROPDOWN:{ initial: { opacity: 0, height: 0 }, animate: { opacity: 1, height: 'auto' }, exit: { opacity: 0, height: 0 }, transition: { duration: 0.1 } },
};
4) 루트 배치: src/app/layout.tsx
import './globals.css';
import Modal from '@/components/common/modal';
import CoreProvider from '@/contexts/core-provider';
// ... 생략
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
<CoreProvider>
<Modal />
{children}
</CoreProvider>
</body>
</html>
);
}
그리고 src/contexts/core-provider.tsx에서 ModalProvider를 트리에 포함시킵니다.
import AuthProvider from './auth-provider';
import ModalProvider from './modal-provider';
import { QueryProvider } from './query-provider';
import SessionProvider from '@/contexts/session-provider';
const CoreProvider = ({ children }: { children: React.ReactNode }) => (
<SessionProvider>
<QueryProvider>
<AuthProvider>
<ModalProvider>{children}</ModalProvider>
</AuthProvider>
</QueryProvider>
</SessionProvider>
);
5) 실제 사용: src/app/users/page.tsx
'use client';
import UserCreateForm from '@/components/user/user-create-form';
import Button from '@/components/common/button';
import useModals from '@/hooks/use-modals';
import { useUsers } from '@/lib/user';
const Page = () => {
const { openModal } = useModals();
const { data: users } = useUsers({ _page: 1, _per_page: 10 });
const handleClickCreateButton = () => {
openModal({
id: 'user-create-form-modal',
component: <UserCreateForm />,
});
};
return (
<>
{/* 리스트 */}
{users?.data.map((user) => (
<div key={user.id}>{user.name}</div>
))}
{/* 모달 열기 */}
<Button onClick={handleClickCreateButton}>Create</Button>
</>
);
};
export default Page;
6) 폼에서 닫기: src/components/user/user-create-form.tsx
import Button from '@/components/common/button';
import { MODAL } from '@/constants/modal-key-constants';
import useModals from '@/hooks/use-modals';
import { useForm, Controller } from 'react-hook-form';
import { useCreateUserMutation } from '@/lib/user';
const UserCreateFormModal = () => {
const { closeModal } = useModals();
const { handleSubmit: formHandleSubmit, formState, control, setError } = useForm({
defaultValues: { name: '' },
});
const { mutate: createUser } = useCreateUserMutation();
const handleSubmit = formHandleSubmit(async (data) => {
createUser(data, {
onSuccess: () => closeModal(MODAL.USER_CREATE),
onError: (error) => setError('name', { type: 'manual', message: error.message }),
});
});
return (
<form onSubmit={handleSubmit}>
<Controller
name="name"
control={control}
render={({ field }) => <input {...field} placeholder="name" />}
/>
{formState.errors.name && <span>{formState.errors.name.message}</span>}
<Button type="submit">Submit</Button>
</form>
);
};
export default UserCreateFormModal;
'개발 공부 > React' 카테고리의 다른 글
| Clerk 인증 정보와 Prisma User 테이블 동기화(sync) (0) | 2025.11.06 |
|---|---|
| useSearchParams hook 만들기 (1) | 2025.10.22 |
| React App Router, Page Route (0) | 2025.10.19 |
| Server Components, Client Components (0) | 2025.09.03 |
| useOptimistic (0) | 2025.08.28 |