DEV Community

koyablue
koyablue

Posted on

Dark Mode with Next.js, TypeScript, Styled Components and Redux Toolkit🔯🔮

Hi 👋
Thank you for taking time to read this article!

One of the most common features we implement is dark mode. However, after implementing dark mode once in a project, it's easy to forget how it was implemented because there's no opportunity to implement it for a while.
So I decided to put together an article on the method I use most often so that everyone can check it anytime!

Table of Contents

 1. Tech stack
 2. Step-by-step implementation

       2.1. Configs, constants and types for color themes

       2.2. Set up Redux Toolkit

       2.3. Utility functions to handle color theme cookie

       2.4. Implement useColorTheme custom hook

       2.5. Configure RTK and Styled Components in _app.tsx
 3. Let's implement dark mode toggle switch

Tech stack

The language, libraries and frameworks used for the implementation are as follows:

  • Next.js
  • TypeScript
  • Styled Components
  • Redux Toolkit
  • cookies-next

Step-by-step implementation

Configs, constants and types for color themes

First, let's define the constants and types needed to manage the color theme.
in const/colorTheme.ts

// 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';

Enter fullscreen mode Exit fullscreen mode

and

in types/colorTheme.ts

// 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)
);

Enter fullscreen mode Exit fullscreen mode

Also we need to modify DefaultTheme type in styled.d.ts like this.

// styled.d.ts

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

declare module 'styled-components' {
  export interface DefaultTheme extends ColorThemeStyle {}
}
Enter fullscreen mode Exit fullscreen mode

It's convenient to create variables for the colors we will use.

// 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

Enter fullscreen mode Exit fullscreen mode

(I used this library to name these variables.)

Then declare color theme objects, default 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];

Enter fullscreen mode Exit fullscreen mode

Set up Redux Toolkit

The state for dark mode must be managed globally. So let's use Redux Toolkit to manage global state.
We are going to create colorThemeSlice, typed useDispatch, typed useSelector and configure store.

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;

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

Utility functions to handle color theme cookie

Dark mode must persists even when the page is refreshed. To achieve this, we are going to use cookie.

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 : '';
};

Enter fullscreen mode Exit fullscreen mode

Implement useColorTheme custom hook

In the implementation, we'll need to switch color themes or retrieve the current color theme. Let's put those logics into a custom hook so that they can be called in every components.

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;

Enter fullscreen mode Exit fullscreen mode

Configure RTK and Styled Components in _app.tsx

We've implemented so many functions and logic up to this point, but as it stands, we can't use them yet.
Let's edit _app.tsx to make sure Redux Toolkit and Styled Components are available for use.

pages/_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;

Enter fullscreen mode Exit fullscreen mode

Let's implement dark mode toggle switch

With the implementation up to this point, we've completed the necessary preparations for switching the color theme.
Now, let's use useColorTheme to implement DarkModeToggleSwitch component (we'll skip detailed styling for now).

globalstyles.tsx

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

...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

That's all.
How was it? I hope this article was helpful for you.

There are many ways to implement color theme switching besides this article. If you have any recommended methods or articles, please share them in the comments and let me know!

And if you found this article helpful, please share it on social media!

Top comments (1)

Collapse
 
smahmoodh profile image
S.M.H

Hello koyablue
ColorTheme and currentColorThemeState are not known in the code snippet below and the error Cannot find name 'ColorTheme'.ts(2304) and Cannot find name 'currentColorThemeState'. Did you mean 'getCurrentColorThemeState'? ts(2552) is given.

const getCurrentColorThemeState = (): ColorTheme => (
currentColorThemeState
);