Next.js로 포트폴리오 만들기 2 - 다크모드 개선기 (Extra attributes from the server: data-theme,style 에러 해결, 서버에서 클라이언트 정보를 아는 법)

Extra attributes from the server 에러

etc-image-0

다크모드를 추가해둔 사이트에서  Extra attributes from the server: data-theme,style 에러가 발생했다

서버에서 렌더링된 html 파일과 다른 data-theme, style 속성이 있어 발생하는 에러다

 

suppressHydrationWarning

<html suppressHydrationWarning>
  ...
</html>

suppressHydrationWarning으로 hydration mismatch로 발생하는 에러를 무시하도록 설정할 수 있지만, 다른 원인으로 hydration mismatch가 일어났을 때도 경고를 무시할 수 있으므로 다른 방법을 찾아보고싶었다

 

enableColorScheme

<ThemeProvider
  attribute='data-theme'
  defaultTheme='dark'
  enableColorScheme={false}
>
  {children}
</ThemeProvider>

ThemeProvider에서 enableColorScheme을 fasle로 설정하면 next-themes가 동적으로 인라인 스타일을 추가하지 않는다

이 설정으로 hydration mismatch는 해결됐다

 

cookie 활용하기

etc-image-1
skeleton UI가 적용된 다크모드 토글 버튼

지난번 다크모드 토글 버튼의 깜빡임을 개선하기 위해 마운트 전까지 skeleton ui를 렌더링하는 방식을 선택했었다.

개발을 하는 입장에서는 첫 렌더링은 서버에서 한 후, 클라이언트에서 사용자의 다크모드 선택 여부를 알 수 있으니 그 사이 로딩이 있다고 생각이 들면서도 사용자 입장에서 그 ui가 로딩되는 건 어색하게 느껴져 skeleton ui를 없애고싶었다.

 

 

etc-image-2

서버에서 판단한 버튼의 텍스트가 클라이언트에서 렌더링될 때 다른 이유를 파악하기 위해 next-themes의 useTheme에서 theme을 확인했는데, 마운트 전까지는 undefined임을 발견했다

 

<button>
  {theme === 'dark' ? '라이트모드' : '다크모드'}
</button>

theme이 dark인지 판단하여 버튼의 텍스트를 결정하고 있었기때문에 기본적으로 '다크모드'라고 렌더링되면서 깜빡임이 발생한 것이었다

 

서버에서부터 사용자가 마지막에 설정한 테마로 렌더링된 html을 받아올 수 있다면 hydration mismatch와 함께 내가 없애고싶었던 skeleton ui를 없앨 수 있겠다 싶었고, 클라이언트에 저장된 값을 서버로 어떻게 보낼 수 있을지 고민했다

 

이 사이트에 접속하면 페이지 로드를 위해 요청을 보내므로 그 때 전송된 쿠키를 활용해 theme을 바로 설정하는 방식으로 구현해보기로 했다

쿠키 사용을 위해 js-cookie 라이브러리를 사용했다

 

// layout.tsx
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  const theme = cookies().get('theme')?.value || 'dark';

  return (
    <html
      lang='ko'
      data-theme={theme}
    >
      <body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
        <NextThemesProvider>{children}</NextThemesProvider>
      </body>
    </html>
  );
}

최상단 레이아웃에서 쿠키값을 받아와 data-theme을 설정한다

 

'use client';

import { useEffect } from 'react';
import Cookies from 'js-cookie';
import { ThemeProvider } from 'next-themes';

function NextThemesProvider({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  useEffect(() => {
    const cookieTheme = Cookies.get('theme');
    const localTheme = localStorage.getItem('theme');

    if (!cookieTheme && localTheme) {
      Cookies.set('theme', localTheme, { expires: 365 });
    }
  }, []);

  return (
    <ThemeProvider
      attribute='data-theme'
      defaultTheme='dark'
      enableColorScheme={false}
    >
      {children}
    </ThemeProvider>
  );
}

export default NextThemesProvider;

cookie 만료 등의 이유로 localStorage와 cookie에 설정된 theme이 다른 경우 동기화시켜준다

 

import { cookies } from 'next/headers';
import ThemeToggleButton from './ThemeToggleButton';

function ThemeResolver() {
  const theme = cookies().get('theme')?.value || 'dark';

  return <ThemeToggleButton initialTheme={theme} />;
}

export default ThemeResolver;

서버컴포넌트에서 쿠키에 저장된 theme을 확인해 클라이언트 컴포넌트인 ThemeToggleButton으로 전달한다

 

'use client';

import { useTheme } from 'next-themes';
import Cookies from 'js-cookie';

type ThemeToggleProps = { initialTheme: string };

export default function ThemeToggle({ initialTheme }: ThemeToggleProps) {
  const { theme, setTheme } = useTheme();

  const toggleTheme = (): void => {
    const newTheme = theme === 'dark' ? 'light' : 'dark';
    setTheme(newTheme);
    Cookies.set('theme', newTheme, { expires: 365 });
  };

  return (
    <button
      onClick={toggleTheme}
      className={`w-[120px] h-[40px] flex items-center justify-center rounded-md transition ${
        (theme ?? initialTheme) === 'dark'
          ? 'bg-gray-800 text-white hover:bg-gray-700'
          : 'bg-gray-200 text-black hover:bg-gray-300'
      }`}
    >
      {(theme ?? initialTheme) === 'dark' ? '라이트모드' : '다크모드'}
    </button>
  );
}

useTheme의 theme을 렌더링 조건에 활용하되 undefined인 경우에는 props로 받은 initialTheme을 사용하게 했다

버튼 이벤트에서도 쿠키에 저장된 theme을 업데이트 해준다

 

etc-image-3

테마 즉각 반영 + 깜빡임 없음 + 스켈레톤 UI 없음

원했던 조건이 모두 반영됐다!