Remix.js Dark Theme 설정

2023. 2. 8. 21:15Tutorial & Training/JavaScript

728x90

Nuxt3의 nitro가 Cloudflare Pages Functions에서 GET 메소드 이외의 처리를 하지 않은 크나큰 충격으로 Vercel을 손절하고 갈아탄 Remix도 역시 나사가 몇가지씩 빠져있었습니다.

 

Vue Svelte Astro Nuxt2 Nuxt3 Sveltekit 등 다양한 Reactive Framework를 사용해왔지만, React는 JSX 문법이 싫어서 항상 꺼려왔는데 대부분의 프레임워크가 Vercel이 관여하고 있어서 결국 Remix로 돔황쳤습니다.

Nuxt3와 Astro의 강제 주입식 문법으로 인해서 React의 함수들이 어색하진 않았는데 

 

 

리믹스의 문제점은 크게 2가지가 나타났습니다.

- 뒤로가기 캐싱 안됨. 일부러 그런건지는 모르겠지만 이전페이지 데이터를 다시 불러옵니다. 이럴거면 굳이 Hydrate는 뭐하러 하나 싶을정도...

- Cookie 관리의 잘못된 예제

 

첫번째 문제는 리믹스 자체의 문제지만, 두번째는 사용자들의 잘못된 전달입니다. 이게 무적권 틀렸다 라고는 할 수 없지만 굳이 서버로 부하를 주지 않아도 될 요청을 함으로써 개발은 개발대로 불편해지기 때문에 오늘 기록해둘 내용은 클라이언트 위주의 쿠키 동작 방식입니다.

 

 

 

다크테마 예제 문제점

다크테마는 흔히 쿠키를 통해 저장해서 사용을 많이 합니다.

그런데 React 진영에는 확실히 Frontend 개발자분들의 생태계가 많다보니 서버로의 처리를 위임하는 듯한 예제가 좀 많았는데,

그 예시로 다크 테마의 예제가 있었습니다.

 

대표적으로 구글 검색시 제일 먼저 노출되는 예제 입니다.

https://www.mattstobbs.com/remix-dark-mode/

 

The Complete Guide to Dark Mode with Remix

Dark Mode can be surprisingly tricky to add with any framework. But Remix gives us some unique tools to deliver a fantastic user experience. In this post, we'll look at how Remix allows us to use the platform to provide a perfect theming solution.

www.mattstobbs.com

그 외에도 Codepen등 클라우드 코드 예시도 비슷한 양상이었습니다.

 

이러한 예제들의 프로세스 흐름은

1. loader를 이용해 request header로 부터 Cookie 응답을 반환

2. 1번의 쿠키를 state 또는 context 등을 통해 메모리에 적재

3. 테마 업데이트를 위해 action 으로 API 생성

4. fetcher.Form 으로 post 요청해 action API에 쿠키 변경을 적용

5. 1번 반복

 

 

2번까진 괜찮았는데 도대체 어떻게 생각을하면 서버로 요청을 보내서 쿠키를 적용시킬 생각을 하는지 신기합니다.

그렇다고 쿠키에 httponly secure samesite를 설정할것도 아니면서 말이죠...

 

 

아마 킹리적 갓심으로는 Remix에서 제공해주는 Cookie 와 Session 이 있기 때문에 그러한 발상이 있었을거라고 추측됩니다.

 

 

 

먼저 제가 리액트를 리믹스를 통해 처음써봤기 때문에 더 유연한 작성이 있을 수 있으니 그점은 양해해주세요 (밑밥까는중)

시작하기전 개선되는 프로세스를 나열해보면

 

1. loader에서 request Header에 있는 Cookie 정보를 읽어온 뒤 이를 json으로 반환

2. 1번에서 획득한 다크테마 정보를 state에 적재하고 context를 통해 global 상태로 적용

3. context 바인딩에서 획득한 다크테마 상태값과 토글 함수를 이용해 쿠키 적용 및 상태 적용

 

 

코드 작성 관점 순서

1. context / hook 작성

2. root.tsx에 적용 끝

 

 

 

 

1. Context 및 Custom Hook작성

저는 app/contexts 라는 폴더를 만들어 app/contexts/theme.ts 파일에 작성하였습니다.

폴더의 경로나 위치가 강제되지 않으니 유연하게 작성하시면 됩니다.

import { createContext, useState } from "react";

interface ThemeContextType {
    toggleTheme(): void
    isDarkMode: boolean
}

const DARK_MODE_KEY = 'REMIX:DARK'
const NEVER = 'Fri, 31 Dec 9999 23:59:59 GMT' // MDN:: https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie#example_3_do_something_only_once

function setCookie(name: string, value: string | boolean, ttl: number = 0) {
    const expire = new Date();
    expire.setDate(expire.getTime() + ttl);
    document.cookie = `${name}=${value}; expires=${ttl > 0 ? expire.toUTCString() : NEVER};`
}

export const ThemeContext = createContext<ThemeContextType>({
  isDarkMode: false, toggleTheme: () => { }
})

export function useTheme(defaultValue: boolean = false): [boolean, ()=> void] {
    const [darkTheme, setDarkTheme] = useState(defaultValue);

    function toggleTheme(): void {
        setCookie(DARK_MODE_KEY, !darkTheme)
        setDarkTheme(!darkTheme)
    }

    return [darkTheme, toggleTheme]
}

go언어에서는 소문자 시작의 함수명은 private 해서 대문자로 쓰고싶지만... 리액트 공식문서에서 useFunction 형식을 사용하라고 권고해서 어쩔수없이 useTheme 입니다.

 

Context API 예시들을 보면, Reducer를 따로 작성하시는 분들이 보이더군요.

지금은 사장되서 잘 안쓰이는 Vue2 시절 Vuex의 Mutation Action을 작성하는 느낌이었는데, 오히려 장황해보이는 것 같기도하고

useState를 이용하면 굳이 필요없길래 그냥 대충 만들었습니다.

지금 이글을 보고 따라하시는 여러분도 걍 복붙해서 쓰시면 짧고 굵고 얼마나 좋나여

 

딱히 코드 설명할게 없어서 패스...

 

 

 

2. root.tsx 적용

// root.tsx
import { json, LoaderArgs } from "@remix-run/cloudflare";
import { ThemeContext, useTheme } from "./contexts/theme";

export const loader = async ({ request }: LoaderArgs) => {
  let isDarkMode = false
  const cookies = request.headers.get('cookie')?.split('; ')
  const index = cookies?.findIndex(str => str.includes(DARK_MODE_KEY)) || -1;
  if (index > -1 && cookies) {
    const val = cookies[index].split('=')[1]
    isDarkMode = val === 'true' ? true : false
  }
  return json({ isDarkMode })
}

export default function App() {
  const { isDarkMode } = useLoaderData<typeof loader>();
  const [darkTheme, toggleTheme] = useTheme(isDarkMode);

  return (
    <html lang="ko">
      <head>
        <Meta />
        <Links />
      </head>
      <StateContext.Provider value={{isDarkMode: darkTheme, toggleTheme}}>
        <body className={darkTheme ? 'dark' : ''}>
          // ... 하위 컴포넌트
          <ScrollRestoration />
          <Scripts />
          <LiveReload />
        </body>
      </StateContext.Provider>
    </html>
  );
}

root에서는 useLoaderData 를 통해 loader 함수에서 cookie 정보를 읽고, 이를 JSON으로 반환하게 하였습니다.

위 과정을 통해 페이지 첫 접근(새로고침 포함) 다크테마활성상태인지 비 활성상태인지 구분할 수 있습니다.

 

useTheme 는 위에서 받아온 정보를 기본값으로 넣어주고, darkTheme 및 toggleTheme를 context 로 전달합니다.

 

별도의 버튼이 있다면, 어디서든 toggleTheme() 를 호출하면 body태그 class에 dark 가 적용되는 것을 볼 수 있습니다.

 

 

Remix의 loader가 하위 요청으로 발생하는지 SSR 접근시 동시에 먼저 처리하고 UI를 렌더링하는지 직접 디스코드에서 질문하였는데 1분만에 답변을 받았습니다.

결론은 하위 요청은 발생하지 않고, SSR 접근시 먼저 처리후 UI를 렌더링 한다고 합니다.

 

 

 

이렇게 짧게 끝날걸 예제들은 뭔 꼴랑 다크테마 설정하는 쿠키를 가지고 어떻게해서든 기어코 죽어라 프레임워크 내장함수좀 써보겠다고 api action 만들고 fetcher 선언해서 fetcher.Form 으로 또 요청해가지고 괜히 불러왔던 데이터까지 다 새로 요청하고 요청이랑 서버 부하만 늘리고 있는 이상한 예제가 많아서 작성해보았습니다.

사실 나중에 프로젝트 생성할때마다 복붙하기 귀찮은 면도 없잖아 있는 것 같군요.

 

 

물론 서버로 요청해서 처리해야하는 쿠키들도 존재합니다. httponly secure samesite 등의 설정을하여 악의적인 사용자가 javascript를 이용해 세션이나 타 사이트에서 접근하지 못하게 하는 요청의 경우는 다른 예제들처럼 서버로 요청해서 쿠키를 설정하는 것이 올바른 선택지입니다. 

하지만 보안 설정해도 매우 쉽게 돚거할 수 있어서 그냥 ㅈ밥거르기 용으로 설정할때 써먹는 방법입니다.

 

 

 

 

 

 

728x90