*주의: 이글은 처음부터 끝까지 실패한 코드입니다.

hahaha

하하하..하..하..신나는 코딩ㅎㅎ..ㅎㅎㅎㅎ

댓글 테마를 변경하는 과정에서 기존 테마의 오류를 발견하였다.
기존 코드를 보며 실패한 이유를 알아보고, 제대로 고치는 과정을 남겨보려한다.

다시 말하지만, 이번 포스트는 모두 살짝 오류가 있는 실패한 코드다.
실패한 이야기가 안궁금하다면 바로 다크모드 만들기: 성공담🙆🏻으로...

localhost 아래에서는 잘 작동이 되는데, build 된 상황에서는 새로고침을 하면, 다크모드가 작동이 되지 않는다. 심지어 현재 테마를 관리하는 state 값에는 문제가 없는데 말이다...따흑

useContext를 이용하여 어디서든지 테마 state를 읽는 방법을 이용하였다. 그런데, gatsby build를 거치면서 문제가 생기는데...

기존 코드 살펴보기

실패한 코드를 요약하자면,

  1. 다크모드를 관리하는 state와 변경시키는 reducer를 context로 만들어 사용
  2. localStorage를 이용하여 재방문시 테마가 유지되도록 함
  3. 빌드시 window를 인식하지 못할때를 대비 (그 와중에 빌드를 하긴 함)
  4. 다크모드 state에 따라 layout 컴포넌트의 최상단 <div>className='light'className='dark'를 번갈아가며 적용
  5. CSS custom properties로 클래스에 맞춰 디자인 적용

Context 만들기

Context를 사용하여 테마 상태를 props로 일일이 넘겨주지 않고, 전체적으로 접근 가능하도록 한다. 이 블로그에선, 레이아웃 컴포넌트와 댓글 컴포넌트에서 바로 값을 알 수 있다.

  1. 개인적으로 boolean값으로 상태를 관리하는 것이 편하므로, isDarkMode으로 state를 관리하려한다.
  2. createContext(defaultState)로 context 만들고
  3. Provider로 전달!(value값으로 GlobalState()를 만들어 전달할 것이다.)
src/store/ThemeContext.js
import React, { createContext } from 'react';
import GlobalState from './GlobalState';

const defaultState = {
  isDarkMode: false,
};

const ThemeContext = createContext(defaultState);

const ThemeProvider = ({ children }) => {
  return (
    <ThemeContext.Provider value={GlobalState()}>
      {children}
    </ThemeContext.Provider>
  );
};

export default ThemeContext;

export { ThemeProvider };

GlobalState()

state를 설정하는 기본값과, state를 변화시키는 리듀서를 만든다.

  1. TOGGLE_MODE action일때 state 값을 반대로 변화시키는 reducer 함수를 작성한다.

  2. state의 초기값을 설정하는 함수를 만든다.
  3. 이때 if (typeof window !== 'undefined')로 build시 발생하는 "window is not defined" 에러를 방지한다.
  4. useReducer의 인자로 1,2를 이용하여 state와 dispatch를 리턴하는 GlobalState()완성한다.
src/store/GlobalState.js
import { useReducer } from 'react';

const reducer = (state, action) => {
  switch (action.type) {
    case 'TOGGLE_MODE':
      localStorage.setItem('isDarkMode', !state.isDarkMode);
      return {
        isDarkMode: !state.isDarkMode,
      };

    default: {
      return state;
    }
  }
};

const GlobalState = () => {
  let isWindow = () => {
    if (typeof window !== 'undefined') {
      return {
        isDarkMode: localStorage.getItem('isDarkMode')
          ? JSON.parse(localStorage.getItem('isDarkMode'))
          : false,
      };
    } else {
      return { isDarkMode: false };
    }
  };
  const [state, dispatch] = useReducer(reducer, isWindow());

  return { state, dispatch };
};

export default GlobalState;

layout에서 적용하기

state.isDarkMode로 테마를 확인하여, class로 테마를 적용한다.

src/layouts/index.js
import React, { useContext } from 'react';
import ThemeContext from '../store/ThemeContext';
...

const Layout = ({ children }) => {
  const { state } = useContext(ThemeContext);

  ...

  return (
    <div className={state.isDarkMode ? `dark` : `light`}>
      <Header/>
      <main>{children}</main>
      <Footer/>
    </div>
  );
};

export default Layout;

어떤 문제인가?

테마 변경 후 새로고침을 하면 디폴트 테마(light)로 적용되어버린다. 근데 또 완전히 안되는 건 아니고...🤯 새로고침하지 않으면 잘 작동된다. 하지만 다크모드로 변경 후 새로고침을 하면 (테마의 state는 다크모드를 가리키는데)라이트모드로 변환된다. (심지어 이 시점에서 토글되는 버튼에 오류가 생겨서, 첫 클릭에는 작동하지 않고, 한두번 더 클릭해야 테마가 변경된다.) 그런데 이 상태에서 다른 페이지로 이동을 하면, 다크모드가 적용되어 다시 렌더링된다.

또한 localStorage를 비롯한 테마를 console.log해보면 문제 없이 찍힌다.

theme-fail

dark모드에서 Reload하면 그 페이지는 light로 돌아오는데, 이후 페이지에선 dark모드가 적용된다.

왜 이런 문제가 발생하는가?

React의 hydration의 특성을 가져온 Gatsby의 Hydration 특성과 연관이 있다.

hydrate, rehydrate, hydration 모두 비슷한 의미로 쓰인다. hydrate란 서버와 클라이언트 간의 차이를 줄여주는 것이다. Gatsby에 의해 사이트가 한번 구축되고 웹 브라우저에서 로드되면, 클라이언트 측 자바스크립트 assets이 다운로드 될것이고, 사이트는 DOM을 조작할 수 있는 완전한 리액트 어플리케이션으로 변화한다. 이것을 re-hydrate라고 부른다.

빌드시 정적파일을 먼저 생성하므로 기본테마가 적용되어 웹 브라우저에 렌더링되고, 이후 localStorage등의 클라이언트 측 자바스크립트에서 변화 내용을 읽어와 그 차이를 보충하기위해 hydrate하게된다. 그래서 다음 페이지부터는 적용이 되는 것이다.

그럼 어떻게 고칠 수 있을까?(이것도 실패)

꾸역꾸역 찾은 해결책바로 다시 렌더링하는 것이다. 새로고침 후 첫페이지를 제외하고는 다 제대로 적용을 하기 때문이다.
다시 렌더링하기 위해서 useState와 useEffect를 이용할 수 있다. 클래스가 적용되는 부분에서 (무의미한) 업데이트를 실행하여 다시 렌더링 시키는 것이다.

const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
  if (!hasMounted) {
    setHasMounted(true);
  }
}, []);

그러나 이 방법 또한 완벽하지 않다(= 실패).
React 재조정(Reconciliation)에 의해 기본테마에서 변경되는 모습이 빠르게 보이게 되거나, 최악의 경우(레이아웃 컴포넌트에서 건들면) 모든 페이지에서 한 번씩 다시 렌더를 하게 되기도 한다... 뜨악...

마무리

context로 접근하여 테마를 어디서나 접근할 수 있도록 했는데, 빌드과정을 고려하지 않아서 여러 문제가 생겼다. 억지로 해결책을 찾긴했으나 이건 완벽한 해결책이 아니었다. 바로 성공적인 코드를 작성하지 못해서 아쉽지만, hydrate에 대해 알게되면서 렌더링까지 생각을 하게되는 시간이었다. 앞으로 해결하는 과정에서도 더 많은 것을 배울 수 있다고 믿는다.

Gatsby의 빌드과정을 살펴보면서 표면적인 해결이 아닌 근본적인 해결책을 찾아야 할 것이다. 꼭 오류없이 작동하는 테마 코드를 완성하여 포스팅해야지.

다음 포스트 다크모드 만들기: 성공담🙆🏻 기대해주세요!

Reference & Learn More

React docs Context
React docs useReducer
React docs Reconciliation
React docs ReactDOM.hydrate()
Gatsby docs Hydration
Gatsby docs Understanding React Hydration
Gatsby docs Adding App and Website Functionality
Google Rendering on the Web
MDN setItem
MDN getItem
Need a way to prevent component render on client side initial load #8017

Comments