FOUC(Flash of Unstyled Content) 해결에 대한 트러블 슈팅 과정입니다.
현재 토이 프로젝트에서 다크 모드 기능을 구현하고 있습니다.
기능은 잘 동작하는데, 새로고침하면 "깜빡"했다가 다시 바뀝니다.
깜빡거리는 이유는?
현재 처리 로직은 다음과 같습니다.
- 최초 로드 시
useEffect
에서 localStorage에 저장된 테마 정보를 가져와 설정 - 만약 저장되지 않았다면 시스템 테마 정보로 설정
- 테마 스위치를 클릭하면 localStorage에 저장
// useTheme.ts
import useThemeStore from "@/store/theme/theme";
import { THEME_TYPE } from "@/utils/constants";
import { ChangeEvent, useEffect } from "react";
export default function useTheme() {
const theme = useThemeStore((state) => state.theme);
const { setTheme } = useThemeStore((state) => state.actions);
/**
* @description 테마 변경 시 이벤트
*/
const toggleHandler = (event: ChangeEvent<HTMLInputElement>) => {
const changedTheme = event.target.checked ? THEME_TYPE.DARK : THEME_TYPE.LIGHT;
// 1. 변경된 테마로 설정
document.documentElement.setAttribute("data-theme", changedTheme);
// 2. 로컬 스토리지 설정
localStorage.setItem('theme', changedTheme);
// 3. 전역 상태 저장
setTheme(changedTheme);
};
/**
* @description 최초 로드 시 스토리지 저장 값으로 테마 변경하기
*/
useEffect(() => {
const isDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; // 시스템 테마 확인
const storageTheme = localStorate.getItem('theme') || isDark ? 'dark' : 'light';
document.documentElement.setAttribute("data-theme", storageTheme);
setTheme(storageTheme);
}, [setTheme]);
return {
isDark: theme === THEME_TYPE.DARK,
isLight: theme === THEME_TYPE.LIGHT,
theme,
toggleHandler,
};
}
현재 코드상 문제는 useEffect 때문입니다.
useEffect는 DOM parsing 완료 -> layout/paint 완료 -> 콜백 함수 실행 순서로 동작합니다.
사이트 접속 시, 최초에 자동으로 PC의 시스템 테마로 그려지고 있습니다.(MAC 기준)
그 후 useEffect
가 실행되면서 repaint되어 깜빡이는 현상이 발생합니다.
저는 아래의 목표를 가지고 수정을 하겠습니다.
- storage에 테마 정보가 저장되지 않은 경우: 시스템 테마
- storage에 테마 정보가 저장된 경우: 저장된 테마
해결 과정
1. useLayoutEffect
??? : useLayoutEffect는 paint 전에 실행되니까 되지 않을까??
처음에는 useLayoutEffect를 사용해서 해봐야겠다! 라고 생각했습니다.
하지만 결과는 똑같습니다..😅
안 되는 이유
next.js는 SSR 방식이기 때문에 정적 html 파일이 로드되는 시점에 이미 PC의 시스템 테마를 우선순위로 생각하고 있고, bundle된 script 파일이 로드되어야 hook(useEffect/useLayoutEffect 등)이 실행되기에 useEffect나 useLayoutEffect나 결과의 차이는 없습니다.
2. Cookie를 이용해 정적 html에 설정하기
정적 html이 만들어질 때 테마를 설정하면, 로드될 때 원하는 테마로 그려질 것 같습니다!
원래는 localStorage에 저장하려했지만 client에서만 가능하니.. cookie에 테마 정보를 저장하는 방식으로 수정하겠습니다.
// app/layout.tsx
import "@/assets/styles/globals.css";
import Header from "@/components/common/Header";
import { cookies } from "next/headers";
import { Inter } from "next/font/google";
import type { ReactNode } from "react";
const inter = Inter({ subsets: ["latin"] });
interface IRootLayout {
children: ReactNode;
}
export default function RootLayout({ children }: Readonly<IRootLayout>) {
// 쿠키 정보👇
const cookieStore = cookies();
const theme = cookieStore.get("theme")?.value;
return (
{/* 테마 설정👇 */}
<html lang="ko" data-theme={theme}>
<body className={inter.className}>
<div className="h-screen min-h-screen w-screen">
<Header />
{children}
</div>
</body>
</html>
);
}
그리고 useTheme hook에서 토클 이벤트 부분 로직을 수정해줍니다. (localStorage -> cookie)
Cookie parsing 귀찮아서.. js-cookie 라이브러리 설치했어요. :)
// useTheme.ts
import useThemeStore from "@/store/theme/theme";
import { THEME_TYPE } from "@/utils/constants";
import Cookies from "js-cookie";
import { ChangeEvent, useEffect } from "react";
export default function useTheme() {
const theme = useThemeStore((state) => state.theme);
const { setTheme } = useThemeStore((state) => state.actions);
/**
* @description 테마 변경 시 이벤트
*/
const toggleHandler = (event: ChangeEvent<HTMLInputElement>) => {
const changedTheme = event.target.checked ? THEME_TYPE.DARK : THEME_TYPE.LIGHT;
// 1. 변경된 테마로 설정
document.documentElement.setAttribute("data-theme", changedTheme);
// 2. 쿠키 설정
Cookies.set('theme', changedTheme);
// 3. 전역 상태 저장
setTheme(changedTheme);
};
/**
* @description 최초 로드 시 스토리지 저장 값으로 테마 변경하기
*/
useEffect(() => {
const isDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; // 시스템 테마 확인
const storageTheme = Cookies.get('theme') || isDark ? 'dark' : 'light';
document.documentElement.setAttribute("data-theme", storageTheme);
setTheme(storageTheme);
}, [setTheme]);
return {
isDark: theme === THEME_TYPE.DARK,
isLight: theme === THEME_TYPE.LIGHT,
theme,
toggleHandler,
};
}
쿠키로 변경하니까 해결이 됐습니다! 된 줄 알았습니다..
아직 한 발 남았다..
cookie에 테마 정보가 없는 완전 초기에는 data-theme 값에 undefined가 들어가게 됩니다.
그래서 기본 테마를 설정하려 했지만, 기본 테마(light)와 시스템 테마(dark)가 다르면 똑같은 문제(FOUC)가 발생됩니다.
시스템 테마 정보를 가져와 설정하고 싶지만, 정적 html을 만들 때는 window 객체에 접근할 수가 없습니다.
3. HTML Blocking
브라우저가 HTML을 parsing 하는 과정에서, script 태그를 만나면 해석이 완료될 때까지 parsing을 중단(blocking)합니다.
이 시점에는 client이기 때문에 window 객체에 접근을 할 수 있습니다.
cookie에 테마 정보가 없을 때는 script 태그로 현재 PC의 기본 테마를 가져와 처리하도록 하겠습니다.
// app/layout.tsx
import "@/assets/styles/globals.css";
import Header from "@/components/common/Header";
import { cookies } from "next/headers";
import { Inter } from "next/font/google";
import type { ReactNode } from "react";
const inter = Inter({ subsets: ["latin"] });
interface IRootLayout {
children: ReactNode;
}
export default function RootLayout({ children }: Readonly<IRootLayout>) {
// 쿠키 정보👇
const cookieStore = cookies();
const theme = cookieStore.get("theme")?.value;
return (
<html lang="ko" data-theme={theme}>
{/* 이 부분👇 */}
{!theme && (
<head>
<script
dangerouslySetInnerHTML={{
__html: `
const isDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.setAttribute("data-theme", isDark ? 'dark' : 'light');
`,
}}
/>
</head>
)}
<body className={inter.className}>
<div className="h-screen min-h-screen w-screen">
<Header />
{children}
</div>
</body>
</html>
);
}
이제 정말 깜빡이지 않습니다!!!! 드디어 FOUC 문제를 해결되었습니다 :)
❓ 이러면 localStorage로 해도 되는거 아닌가?
맞아요 ㅎㅎ localStorage로도 충분히 할 수 있어요.
근데 저는 모든 내용을 스크립트에서 처리하고 싶지 않았어요.
만약 그런 생각이 드셨다면 직접 구현해보는 것도 방법 중 하나랍니다.(절대 귀찮아서 아님)
의견은 언제든 댓글로 남겨주세요. 🙂
참고 자료