🍀 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 과정까지 완료된 후에 비동기로 동작합니다.
Mac 기준으로는, 자동으로 시스템 테마로 그려지고 있습니다. 그 후 useEffect
가 실행되면서 repaint되어 깜빡이는 현상이 발생합니다.
필자는 원하는 방향은 이렇습니다.
client storage에 테마 정보가 저장되지 않은 경우
-> 시스템 테마
client storage에 테마 정보가 저장된 경우 (토글로 테마 변경 시)
-> 저장된 테마
??? : useLayoutEffect는 paint 전에 실행되니까 되지 않을까??
처음에는 useLayoutEffect
를 사용해서 해봐야겠다! 라고 생각했었답니다.
과연 될까요??😏
똑같네요;ㅎㅎ 잘 생각해보니 바보같은 생각이었어요..
next.js는 SSR 방식이기 때문에 정적 html 파일이 로드되는 시점에 이미 PC의 시스템 테마를 우선순위로 생각하고 있고, bundle된 script 파일이 로드되어야 hook(useEffect/useLayoutEffect 등)이 실행되기에 useEffect나 useLayoutEffect나 결과의 차이는 없습니다.
일단 정적 html이 만들어질 때 테마를 설정하면 좋을 것 같습니다.
localStorage나 sessionStorage는 client에서만 가능하니.. Cookie에 테마 정보를 저장하면 접근이 가능하겠네요!
참고로 daisyUI 디자인 프레임워크 사용 중입니다.ㅎㅎ
// 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) 설정하게 되면 결국 똑같은 문제(FOUC)가 발생될테고, 시스템 테마 정보를 가져와 설정하고 싶지만 정적 html을 만들 때는 window 객체
를 접근할 수 없어 불가능하네요.ㅠㅠ
브라우저가 HTML을 parsing 하는 과정에서, script 태그를 만나면 해석이 완료될 때까지 parsing을 중단(blocking)합니다. 이 시점에는 client이기 때문에 window 객체
에 접근을 할 수 있습니다.
이 방식을 적용해봐야겠어요!
// 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>
);
}
오 이제 깜빡이지 않아요!!
Cookie에 테마 정보가 없을 때는 script 태그로 현재 PC의 기본 테마를 가져와 처리하도록 했습니다.
드디어 FOUC 문제를 해결되었습니다 :)
💡 근데 이럴거면 html blocking 사용해서 cookie 사용하지 않고 localStorage로 해도 되는거 아니야??
맞아요 ㅎㅎ localStorage로도 충분히 할 수 있어요. 근데 필자는 모든 내용을 스크립트에서 처리하고 싶지 않았어요. 만약 그런 생각이 드셨다면 직접 구현해보는 것도 좋겠네요!!
(절대 귀찮아서 아님)
참고 자료