TS, Next.js, Styled Components, Redux Toolkitで実装するダークモード
よく実装する機能のひとつにダークモードがあります
実装方法は色々あると思いますが、個人的によくやる方法を記事にまとめましたのでよかったら見ていってください!
また、ライトモード/ダークモードのみでなく他にカラーテーマが増えた場合でもできるだけ対応できるような機能を目指して実装してゆきます💪
(DEV.toにも同じ内容で記事を出しています↓)
使用技術
- TypeScript
- Next.js
- pagesディレクトリです。公式リポジトリのexamplesをそのまま使用しています
- https://github.com/vercel/next.js/tree/canary/examples/with-styled-components
- Styled Components
- Redux Toolkit
- 現在選択されているカラーテーマーのstateをグローバルに扱うために使用します
- 広く普及しているのでRTKを選択していますが、別の状態管理方法でも大丈夫です
- cookies-next
- 今回はこのライブラリを使用しますが、cookieが扱えれば特に何を使っても(何も使わなくても)問題ありません
実装
コンフィグ、型、定数etc
実装に必要な定数や型などを定義します。
// 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';
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
も変更しておきます。
import 'styled-components';
import { ColorThemeStyle } from './types/colorTheme';
declare module 'styled-components' {
export interface DefaultTheme extends ColorThemeStyle {}
}
また、使用する色の変数を定義しておくと便利です。変数の命名にはこちらのAPIを使用しました。
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
プロパティに渡されるものです。
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で使う時の設定は公式ドキュメントの方法に従います。
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;
// 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;
// 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
というライブラリを使用します。
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の実装
カラーテーマの切り替えや現在のカラーテーマを取得する処理をカスタムフックにまとめます。
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でアプリケーション全体を囲みます。
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
を編集します。
...
body {
...
background-color: ${({ theme }) => theme.colors.background};
color: ${({ theme }) => theme.colors.text};
...
}
...
ダークモードスイッチのコンポーネントです。(細かいスタイリングは省きます)
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