🔮

TS, Next.js, Styled Components, Redux Toolkitで実装するダークモード

2023/09/25に公開

よく実装する機能のひとつにダークモードがあります
実装方法は色々あると思いますが、個人的によくやる方法を記事にまとめましたのでよかったら見ていってください!

また、ライトモード/ダークモードのみでなく他にカラーテーマが増えた場合でもできるだけ対応できるような機能を目指して実装してゆきます💪

(DEV.toにも同じ内容で記事を出しています↓)
https://dev.to/koyablue/dark-mode-with-nextjs-typescript-styled-components-and-redux-toolkit-3863

使用技術

  • TypeScript
  • Next.js
  • Styled Components
  • Redux Toolkit
    • 現在選択されているカラーテーマーのstateをグローバルに扱うために使用します
    • 広く普及しているのでRTKを選択していますが、別の状態管理方法でも大丈夫です
  • cookies-next
    • 今回はこのライブラリを使用しますが、cookieが扱えれば特に何を使っても(何も使わなくても)問題ありません

実装

コンフィグ、型、定数etc

実装に必要な定数や型などを定義します。

const/colorTheme.ts
// Types of available color themes
export const colorThemeNames = [
  'light',
  'dark',
] as const;

// Can't use type ColorThemeName because of circular dependency
export const defaultColorThemeName: typeof colorThemeNames[number] = 'light';

// Cookie key for color theme
export const colorThemeCookieName = 'myAppColorTheme';
types/colorTheme.ts
import { colorThemeNames } from '../const/colorTheme';

export type ColorThemeStyle = {
  colors: {
    text: string
    background: string
    componentBackground: string
    border: string
    info: string
    infoBg: string
    danger: string
    dangerBg: string
  },
};

export type ColorThemeName = typeof colorThemeNames[number];

/**
 * Type guard for ColorThemeName
 *
 * @param {unknown} val
 * @return {*}  {val is ColorThemeName}
 */
export const isColorThemeName = (val: unknown): val is ColorThemeName => (
  colorThemeNames.includes(val as ColorThemeName)
);

Styled ComponentsのDefaultThemeも変更しておきます。

styled.d.ts
import 'styled-components';
import { ColorThemeStyle } from './types/colorTheme';

declare module 'styled-components' {
  export interface DefaultTheme extends ColorThemeStyle {}
}

また、使用する色の変数を定義しておくと便利です。変数の命名にはこちらのAPIを使用しました。

const/styles/colors.tsx
export const dryadBark = '#37352f'; // light theme string color
export const white = '#ffffff'; // light theme component color
export const errigalWhite = '#f6f6f9'; // light theme background color
export const gainsboro = '#d9d9d9'; // light theme border color
export const coralRed = '#f93e3d'; // common danger color
export const translucentUnicorn = '#fcecee';
export const softPetals = '#e9f6ef';
export const vegetation = '#48cd90'; // common info color
export const stonewallGrey = '#c3c2c1';
export const astrograniteDebris = '#3b414a'; // dark theme border color
export const aswadBlack = '#141519'; // dark theme background color
export const washedBlack = '#202528'; // dark theme component background color

themeのオブジェクトを定義します。Styled ComponentsのTheme Providerのthemeプロパティに渡されるものです。

config/styles/colorTheme.ts
import { ColorThemeStyle, ColorThemeName } from '../../types/colorTheme';

// colors
import {
  dryadBark,
  white,
  errigalWhite,
  gainsboro,
  coralRed,
  vegetation,
  astrograniteDebris,
  aswadBlack,
  washedBlack,
  softPetals,
  translucentUnicorn,
} from '../../const/styles/colors';

export const defaultColorThemeName: ColorThemeName = 'light';

export const lightTheme: ColorThemeStyle = {
  colors: {
    text: dryadBark,
    background: errigalWhite,
    componentBackground: white,
    border: gainsboro,
    info: vegetation,
    infoBg: softPetals,
    danger: coralRed,
    dangerBg: translucentUnicorn,
  },
};

export const darkTheme: ColorThemeStyle = {
  colors: {
    text: white,
    background: aswadBlack,
    componentBackground: washedBlack,
    border: astrograniteDebris,
    info: vegetation,
    infoBg: softPetals,
    danger: coralRed,
    dangerBg: translucentUnicorn,
  },
};

export const themeNameStyleMap: { [key in ColorThemeName]: ColorThemeStyle } = {
  light: lightTheme,
  dark: darkTheme,
};

export const defaultColorThemeStyle = themeNameStyleMap[defaultColorThemeName];

RTKのセットアップ

カラーテーマのstateはグローバルなstateである方が何かと便利です。Redux Toolkitでカラーテーマのstateをアプリケーション全体で取り扱えるようにします。
RTKをTSで使う時の設定は公式ドキュメントの方法に従います。
https://redux-toolkit.js.org/tutorials/typescript

stores/slices/colorThemeSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

// "type" is needed. If no "type", circular dependency error arise
// https://stackoverflow.com/questions/63923025/how-to-fix-circular-dependencies-of-slices-with-the-rootstate
import type { RootState } from '../store';
import { ColorThemeName } from '../../types/colorTheme';
import { defaultColorThemeName } from '../../const/colorTheme';

type ColorThemeState = {
  theme: ColorThemeName
};

const initialState: ColorThemeState = {
  theme: defaultColorThemeName,
};

const colorThemeSlice = createSlice({
  name: 'colorTheme',
  initialState,
  reducers: {
    updateColorTheme: (state, action: PayloadAction<ColorThemeName>) => {
      state.theme = action.payload;
    },
  },
});

// selectors
export const selectColorTheme = (state: RootState) => state.colorTheme.theme;
export default colorThemeSlice.reducer;

// actions
export const {
  updateColorTheme,
} = colorThemeSlice.actions;

stores/store.ts
// https://redux-toolkit.js.org/tutorials/typescript

import { configureStore } from '@reduxjs/toolkit';

// reducers
import colorThemeReducer from './slices/colorThemeSlice';

export const store = configureStore({
  reducer: {
    colorTheme: colorThemeReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;

export type AppDispatch = typeof store.dispatch;
stores/hooks.ts
// https://redux-toolkit.js.org/tutorials/typescript

import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux';
import { RootState, AppDispatch } from './store';

export const useAppDispatch: () => AppDispatch = useDispatch;

export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

カラーテーマ用cookieを扱うための関数

ページがリロードされても選択中のカラーテーマが持続するよう、cookieを使用します。今回はcookies-nextというライブラリを使用します。

utils/cookie/colorTheme.ts
import { getCookie, setCookie } from 'cookies-next';
import { OptionsType } from 'cookies-next/lib/types';

import { colorThemeCookieName } from '../../const/colorTheme';

import { ColorThemeName, isColorThemeName } from '../../types/colorTheme';

/**
 * Set color theme cookie to persist color theme config
 *
 * @param {ColorThemeName} value
 * @param {OptionsType} [options]
 */
export const setColorThemeCookie = (value: ColorThemeName, options?: OptionsType) => {
  setCookie(colorThemeCookieName, value, options);
};

/**
 *
 *
 * @param {OptionsType} [options]
 * @return {string}  {string}
 */
export const getColorThemeCookie = (options?: OptionsType): string => {
  const colorThemeCookie = getCookie(colorThemeCookieName, options);
  return isColorThemeName(colorThemeCookie) ? colorThemeCookie : '';
};

useColorThemeの実装

カラーテーマの切り替えや現在のカラーテーマを取得する処理をカスタムフックにまとめます。

hooks/useColorTheme.ts
import { defaultColorThemeName } from '../const/colorTheme';

import { getColorThemeCookie, setColorThemeCookie } from '../utils/cookie/colorTheme';

import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { selectColorTheme, updateColorTheme } from '../stores/slices/colorThemeSlice';

import { themeNameStyleMap } from '../config/styles/colorThemes';

import { ColorThemeName, ColorThemeStyle, isColorThemeName } from '../types/colorTheme';

/**
 * Custom hook for handling color themes
 *
 */
const useColorTheme = () => {
  const dispatch = useAppDispatch();
  const currentColorTheme = useAppSelector(selectColorTheme);

  /**
   * Set color theme cookie and state
   *
   * @param {ColorThemeName} colorThemeName
   */
  const setColorTheme = (colorThemeName: ColorThemeName) => {
    setColorThemeCookie(colorThemeName);
    dispatch(updateColorTheme(colorThemeName));
  };

  /**
   * Initialize color theme cookie and state
   *
   * @return {void}
   */
  const initColorTheme = () => {
    const currentColorThemeCookie = getColorThemeCookie();

    if (!currentColorThemeCookie || !isColorThemeName(currentColorThemeCookie)) {
      setColorTheme(defaultColorThemeName);
      return;
    }

    dispatch(updateColorTheme(currentColorThemeCookie));
  };

  /**
   *
   *
   * @return {*} ColorTheme
   */
  const getCurrentColorThemeState = (): ColorTheme => (
    currentColorThemeState
  );

  /**
   *
   *
   * @return {*}  {ColorThemeStyle}
   */
  const getCurrentColorThemeStyle = (): ColorThemeStyle => (
    themeNameStyleMap[currentColorTheme]
  );

  return {
    setColorTheme,
    initColorTheme,
    getCurrentColorThemeState,
    getCurrentColorThemeStyle,
  };
};

export default useColorTheme;

RTKとStyled Componentsが使用できるよう_app.tsxを修正

RTKとStyled Componentsが使用できるようProviderでアプリケーション全体を囲みます。

_app.tsx
import { useEffect, ReactElement, ReactNode } from 'react';

// Next
import { NextPage } from 'next';
import { Router } from 'next/router';
import type { AppProps } from 'next/app';

// Libraries
import { Provider } from 'react-redux';
import { ThemeProvider } from 'styled-components';

import { store } from '../stores/store';

import GlobalStyle from '../components/globalstyles';

import useColorTheme from '../hooks/useColorTheme';

// Layout configuration doc
// https://nextjs.org/docs/pages/building-your-application/routing/pages-and-layouts#with-typescript

export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
  getLayout?: (page: ReactElement) => ReactNode;
};

type AppPropsWithLayout = AppProps & {
  Component: NextPageWithLayout
  router: Router // Error if this property doesn't exist
};

/**
 *
 *
 * @param {AppPropsWithLayout} { Component, pageProps }
 * @return {*} JSX.Element
 */
const WithThemeProviderComponent = ({ Component, pageProps }: AppPropsWithLayout) => {
  const { initColorTheme, getCurrentColorThemeStyle } = useColorTheme();

  useEffect(() => {
    initColorTheme();
  }, []);

  return (
    <ThemeProvider theme={getCurrentColorThemeStyle()}>
      <GlobalStyle />
      <Component {...pageProps} />
    </ThemeProvider>
  );
};

const App = ({ Component, pageProps, router }: AppPropsWithLayout) => {
  const getLayout = Component.getLayout ?? ((page) => page);

  return (
    <Provider store={store}>
      {getLayout(
        <WithThemeProviderComponent
          Component={Component}
          pageProps={pageProps}
          router={router}
        />,
      )}
    </Provider>
  );
};
export default App;

ロジックの実装は以上です!
それではダークモードの切り替えスイッチを実装してゆきましょう

ダークモードスイッチの実装

ここまでで実装した機能を使ってダークモードon/offのスイッチを実装してみます。
まず、bodyにカラーテーマが適用されるようにglobalstyles.tsxを編集します。

globalstyles.tsx
...
  body {
    ...
    background-color: ${({ theme }) => theme.colors.background};
    color: ${({ theme }) => theme.colors.text};
    ...
  }

...

ダークモードスイッチのコンポーネントです。(細かいスタイリングは省きます)

components/DarkModeToggleSwitch.tsx
import { useColorTheme } from '../../../hooks/useColorTheme'


/**
 * Dark mode <-> light mode toggle switch
 * Update cookie value and global state
 *
 * @return {*} JSX.Element
 */
const DarkModeToggleSwitch = () => {
  const { setColorTheme, getCurrentColorThemeState } = useColorTheme()

  const currentColorTheme = getCurrentColorThemeState()

  const isDark = currentColorTheme === 'dark'

  const toggleDarkTheme = () => {
    isDark ? setColorTheme('light') : setColorTheme('dark')
  }

  return (
    <>
      ...
      <input type='checkbox' checked={isDark} onChange={toggleDarkTheme} />
      ...
    </>
  )
}

export default DarkModeToggleSwitch

以上となります。
ダークモードの実装方法は色々あると思いますので、おすすめの方法があればぜひコメント欄で教えてください!
お読みいただきありがとうございました

Discussion