"useTheme must be used within a ThemeProvider" 에러 해결하기(2) - React Context Provider 조건부 렌더링의 함정

박은정·2026년 2월 11일

🚨 문제 상황

이전 포스팅에서 서버 컴포넌트 내부에 각각의 클라이언트 컴포넌트로 사용하면서 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까지 빼버리면 안 된다.


✅ 해결 방법

핵심: 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 vs After

Before (❌):

if (!mounted) {
  return <>{children}</>;  // Provider 없음
}
return (
  <ThemeContext.Provider value={...}>
    {children}
  </ThemeContext.Provider>
);

After (✅):

// 조건문 없이 항상 Provider로 감싸기
return (
  <ThemeContext.Provider value={{ theme, toggleTheme }}>
    {children}
  </ThemeContext.Provider>
);

🤔 Hydration 깜빡임이 걱정된다면?

Provider는 유지하면서 내부 컨텐츠만 조건부로 처리하면 된다.

방법 1: visibility로 숨기기

return (
  <ThemeContext.Provider value={{ theme, toggleTheme }}>
    <div style={{ visibility: mounted ? "visible" : "hidden" }}>
      {children}
    </div>
  </ThemeContext.Provider>
);

방법 2: 로딩 상태 보여주기

return (
  <ThemeContext.Provider value={{ theme, toggleTheme }}>
    {mounted ? children : <LoadingSpinner />}
  </ThemeContext.Provider>
);

방법 3: suppressHydrationWarning 사용

// layout.tsx
<html lang="ko" suppressHydrationWarning>

사실 다크모드 기본값으로 시작하면 큰 깜빡임은 없다. 굳이 복잡하게 안 해도 되는 경우가 많다.


📌 핵심 정리

패턴결과
if (!mounted) return <>{children}</>Provider 없이 children 렌더링 → Context 에러 ❌
if (!mounted) return nullchildren 자체가 렌더링 안 됨 → 빈 화면 ⚠️
조건 없이 항상 Provider 반환정상 작동 ✅

기억할 점:

Context Provider 안에서 조건부 렌더링을 할 때, Provider 자체를 조건부로 빼면 안 된다. children만 조건부로 처리하자.


🔗 관련 글

이 에러는 여러 원인으로 발생할 수 있다:

  1. 서버/클라이언트 컴포넌트 경계 문제 - Provider와 Consumer가 다른 컴포넌트 트리에 있는 경우
  2. 조건부 렌더링 실수 - 이 글에서 다룬 내용

같은 에러 메시지라도 원인이 다를 수 있으니, 두 가지 모두 확인해보자!


마무리

"must be used within a Provider" 에러가 나면 보통 Provider로 안 감싼 줄 알고 찾아보는데, 이번 케이스처럼 감쌌는데 조건부로 빠지는 경우도 있다.

Provider 컴포넌트 내부에 if문으로 early return하는 코드가 있다면, Provider가 빠지지 않는지 꼭 확인하자!

profile
새로운 것을 도전하고 배운것을 정리하려 합니다.

0개의 댓글