Next.js 레이아웃 공유 문제 해결 방법
Next.js App Router에서 여러 레이아웃 간 상태 공유와 레이아웃 전환 문제를 해결하는 방법을 알아봅니다.
Next.js 레이아웃 공유 문제 해결 방법
Introduction
Next.js App Router의 레이아웃은 파일 시스템 기반으로 동작하며, 각 레이아웃은 독립적인 상태를 가집니다. 문제는 레이아웃 간 상태를 공유하거나 레이아웃을 전환할 때 발생합니다. 이 글에서는 레이아웃 공유 문제를 해결하는 다양한 패턴을 다룹니다.
Environment
# 프로젝트 구조
my-app/
├── app/
│ ├── layout.tsx # 루트 레이아웃
│ ├── dashboard/
│ │ ├── layout.tsx # 대시보드 레이아웃
│ │ └── page.tsx
│ ├── settings/
│ │ ├── layout.tsx # 설정 레이아웃
│ │ └── page.tsx
│ └── page.tsx
├── contexts/
│ └── AppContext.tsx
└── package.json
# 기술 스택
Next.js: 14.2.5
React: 18.3.1Problem
레이아웃 전환 시 상태가 유실됩니다:
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
// 이 상태는 대시보드 레이아웃에서만 유지됨
const [sidebarOpen, setSidebarOpen] = useState(true);
return (
{children}
);
}
// ❌ 설정 페이지로 이동하면 sidebarOpen 상태 유실Analysis
App Router의 레이아웃은:
- 파일 경로별로 독립적으로 렌더링
- 레이아웃 간 상태 자동 공유 안 됨
- 서버 컴포넌트에서는 useState 사용 불가
- 클라이언트 컴포넌트에서만 상태 관리 가능
Solution
1. Context를 이용한 상태 공유
// contexts/AppContext.tsx
'use client';
import { createContext, useContext, useState, ReactNode } from 'react';
interface AppState {
sidebarOpen: boolean;
theme: 'light' | 'dark';
notifications: number;
}
interface AppContextType extends AppState {
setSidebarOpen: (open: boolean) => void;
toggleSidebar: () => void;
setTheme: (theme: 'light' | 'dark') => void;
incrementNotifications: () => void;
}
const AppContext = createContext(undefined);
export function AppProvider({ children }: { children: ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(true);
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const [notifications, setNotifications] = useState(0);
const toggleSidebar = () => setSidebarOpen(!sidebarOpen);
const incrementNotifications = () => setNotifications(n => n + 1);
return (
{children}
);
}
export function useApp() {
const context = useContext(AppContext);
if (!context) {
throw new Error('useApp must be used within AppProvider');
}
return context;
} // app/layout.tsx
import { AppProvider } from '@/contexts/AppContext';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
{children}
);
}2. 커스텀 훅으로 레이아웃 상태 관리
// hooks/useLayoutState.ts
'use client';
import { useState, useEffect, useCallback } from 'react';
interface LayoutState {
sidebarOpen: boolean;
breadcrumbs: Array<{ label: string; href: string }>;
title: string;
}
const STORAGE_KEY = 'layout-state';
export function useLayoutState() {
const [state, setState] = useState(() => {
// 로컬 스토리지에서 초기 상태 복원
if (typeof window !== 'undefined') {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
return JSON.parse(saved);
}
}
return {
sidebarOpen: true,
breadcrumbs: [],
title: '',
};
});
// 상태 변경 시 로컬 스토리지에 저장
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}, [state]);
const toggleSidebar = useCallback(() => {
setState(prev => ({ ...prev, sidebarOpen: !prev.sidebarOpen }));
}, []);
const setBreadcrumbs = useCallback((breadcrumbs: LayoutState['breadcrumbs']) => {
setState(prev => ({ ...prev, breadcrumbs }));
}, []);
const setTitle = useCallback((title: string) => {
setState(prev => ({ ...prev, title }));
}, []);
return {
...state,
toggleSidebar,
setBreadcrumbs,
setTitle,
};
} 3. URL 기반 상태 동기화
// hooks/useUrlState.ts
'use client';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import { useCallback } from 'react';
export function useUrlState(key: string, defaultValue: T) {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const value = searchParams.get(key)
? JSON.parse(searchParams.get(key)!)
: defaultValue;
const setValue = useCallback((newValue: T) => {
const params = new URLSearchParams(searchParams);
params.set(key, JSON.stringify(newValue));
router.push(`${pathname}?${params.toString()}`);
}, [key, defaultValue, searchParams, router, pathname]);
return [value, setValue] as const;
} // components/Sidebar.tsx
'use client';
import { useUrlState } from '@/hooks/useUrlState';
export function Sidebar() {
const [isOpen, setIsOpen] = useUrlState('sidebar', true);
return (
);
}4. 레이아웃 컴포넌트 분리
// components/Layout.tsx
'use client';
import { useApp } from '@/contexts/AppContext';
import { Sidebar } from './Sidebar';
import { Header } from './Header';
interface LayoutProps {
children: React.ReactNode;
showSidebar?: boolean;
}
export function Layout({ children, showSidebar = true }: LayoutProps) {
const { sidebarOpen, theme } = useApp();
return (
{showSidebar && }
{children}
);
}// app/dashboard/layout.tsx
import { Layout } from '@/components/Layout';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
{children}
);
}
// app/settings/layout.tsx
import { Layout } from '@/components/Layout';
export default function SettingsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
{children}
);
}Lessons Learned
- Context 위치: 루트 레이아웃에 Context Provider 배치
- 지속성 전략: 로컬 스토리지 또는 URL 파라미터로 상태 유지
- 컴포넌트 분리: 레이아웃 공통 로직을 별도 컴포넌트로 분리
- 서버 컴포넌트 고려: 서버 컴포넌트에서는 상태 관리 불가능
This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.