이전 포스팅에서 서버 컴포넌트 내부에 각각의 클라이언트 컴포넌트로 사용하면서 Context 관계가 보장이 안되는 문제를 해결했지만 여전히 동일한 에러가 발생했다.

Runtime Error
useTheme must be used within a ThemeProvider
이상한 점은 분명히 ThemeProvider로 감싸고 있다는 거다.
// ClientLayout.tsx
"use client";
export default function ClientLayout({ children }) {
return (
<ThemeProvider>
<Navigation /> {/* useTheme() 사용 */}
{children}
</ThemeProvider>
);
}
Navigation이 ThemeProvider 안에 있는데 왜 Context를 못 찾을까?
문제는 ThemeProvider 내부에 있었다.
// ThemeProvider.tsx
export function ThemeProvider({ children }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// ❌ 문제의 코드!
if (!mounted) {
return <>{children}</>;
}
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
1단계: 첫 렌더링 (mounted = false)
ThemeProvider
└── return <>{children}</> ← Provider 없이 children만 반환!
└── Navigation
└── useTheme() 호출 → Context가 없음 → 💥 에러!
2단계: useEffect 실행 후 (mounted = true)
ThemeProvider
└── return <ThemeContext.Provider> ← 이제서야 Provider 등장
└── Navigation
└── useTheme() → 정상 작동
문제는 2단계에 도달하기 전에 1단계에서 이미 에러가 터진다는 것이다.
보통 hydration mismatch를 방지하려고 이 패턴을 쓴다.
// 서버와 클라이언트의 초기 렌더링 결과가 다르면 경고가 뜬다
// 예: 서버에서는 localStorage를 읽을 수 없으니까
const savedTheme = localStorage.getItem("theme"); // 서버에서 에러!
그래서 mounted 상태로 클라이언트 렌더링 여부를 체크하는 건데, Provider까지 빼버리면 안 된다.
// ThemeProvider.tsx
"use client";
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "dark" | "light";
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>("dark");
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
const savedTheme = localStorage.getItem("theme") as Theme | null;
if (savedTheme) {
setTheme(savedTheme);
}
}, []);
useEffect(() => {
if (mounted) {
document.documentElement.classList.remove("light", "dark");
document.documentElement.classList.add(theme);
localStorage.setItem("theme", theme);
}
}, [theme, mounted]);
const toggleTheme = () => {
setTheme((prev) => (prev === "dark" ? "light" : "dark"));
};
// ✅ 항상 Provider로 감싸서 반환!
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}
Before (❌):
if (!mounted) {
return <>{children}</>; // Provider 없음
}
return (
<ThemeContext.Provider value={...}>
{children}
</ThemeContext.Provider>
);
After (✅):
// 조건문 없이 항상 Provider로 감싸기
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
Provider는 유지하면서 내부 컨텐츠만 조건부로 처리하면 된다.
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
<div style={{ visibility: mounted ? "visible" : "hidden" }}>
{children}
</div>
</ThemeContext.Provider>
);
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{mounted ? children : <LoadingSpinner />}
</ThemeContext.Provider>
);
// layout.tsx
<html lang="ko" suppressHydrationWarning>
사실 다크모드 기본값으로 시작하면 큰 깜빡임은 없다. 굳이 복잡하게 안 해도 되는 경우가 많다.
| 패턴 | 결과 |
|---|---|
if (!mounted) return <>{children}</> | Provider 없이 children 렌더링 → Context 에러 ❌ |
if (!mounted) return null | children 자체가 렌더링 안 됨 → 빈 화면 ⚠️ |
| 조건 없이 항상 Provider 반환 | 정상 작동 ✅ |
기억할 점:
Context Provider 안에서 조건부 렌더링을 할 때, Provider 자체를 조건부로 빼면 안 된다. children만 조건부로 처리하자.
이 에러는 여러 원인으로 발생할 수 있다:
같은 에러 메시지라도 원인이 다를 수 있으니, 두 가지 모두 확인해보자!
"must be used within a Provider" 에러가 나면 보통 Provider로 안 감싼 줄 알고 찾아보는데, 이번 케이스처럼 감쌌는데 조건부로 빠지는 경우도 있다.
Provider 컴포넌트 내부에 if문으로 early return하는 코드가 있다면, Provider가 빠지지 않는지 꼭 확인하자!