javascriptcssreactjsreact-hooksreact-context

How do I set system preference dark mode in a react app but also allow users to toggle back and forth the current theme


I have a react web app with a theme toggle on the navigation. I have a ThemeProvider Context that has logic to auto detects a user's System theme preference and sets it. However, I feel a user should be able to toggle themes back and forth on the website despite their system preference. Here is the ThemeContext.js file with all the theme logic including the toggle method.

import React, { useState, useLayoutEffect } from 'react';

const ThemeContext = React.createContext({
    dark: false,
    toggle: () => {},
});

export default ThemeContext;

export function ThemeProvider({ children }) {
    // keeps state of the current theme
    const [dark, setDark] = useState(false);

    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)')
        .matches;
    const prefersLight = window.matchMedia('(prefers-color-scheme: light)')
        .matches;
    const prefersNotSet = window.matchMedia(
        '(prefers-color-scheme: no-preference)'
    ).matches;

    // paints the app before it renders elements
    useLayoutEffect(() => {
        // Media Hook to check what theme user prefers
        if (prefersDark) {
            setDark(true);
        }

        if (prefersLight) {
            setDark(false);
        }

        if (prefersNotSet) {
            setDark(true);
        }

        applyTheme();

        // if state changes, repaints the app
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [dark]);

    // rewrites set of css variablels/colors
    const applyTheme = () => {
        let theme;
        if (dark) {
            theme = darkTheme;
        }
        if (!dark) {
            theme = lightTheme;
        }

        const root = document.getElementsByTagName('html')[0];
        root.style.cssText = theme.join(';');
    };

    const toggle = () => {
        console.log('Toggle Method Called');

        // A smooth transition on theme switch
        const body = document.getElementsByTagName('body')[0];
        body.style.cssText = 'transition: background .5s ease';

        setDark(!dark);
    };

    return (
        <ThemeContext.Provider
            value={{
                dark,
                toggle,
            }}>
            {children}
        </ThemeContext.Provider>
    );
}

// styles
const lightTheme = [
    '--bg-color: var(--color-white)',
    '--text-color-primary: var(--color-black)',
    '--text-color-secondary: var(--color-prussianBlue)',
    '--text-color-tertiary:var(--color-azureRadiance)',
    '--fill-switch: var(--color-prussianBlue)',
    '--fill-primary:var(--color-prussianBlue)',
];

const darkTheme = [
    '--bg-color: var(--color-mirage)',
    '--text-color-primary: var(--color-white)',
    '--text-color-secondary: var(--color-iron)',
    '--text-color-tertiary: var(--color-white)',
    '--fill-switch: var(--color-gold)',
    '--fill-primary:var(--color-white)',
];

So when the page loads, show the user's system preferred them but also allow user to toggle themes by clicking a toggle button that fires the toggle function. In my current code, when toggle is called, it seems that state changes occur twice and therefore theme remains unchanged. How do I ensure the toggle method works correctly?

Here is the web app in question


Solution

  • The problem is that the whole block of useLayoutEffect runs every the dark value changes. So when the user toggles dark, the prefers... if statements run and setDark back to the system preference.

    To solve this you'll need to keep track of the user manually toggling the theme and then preventing the prefers... if statements from running.

    In your ThemeProvider do the following:

    const [userPicked, setUserPicked] = useState(false);
    
    const toggle = () => {
      console.log('Toggle Method Called');
    
      const body = document.getElementsByTagName('body')[0];
      body.style.cssText = 'transition: background .5s ease';
    
      setUserPick(true) // Add this line
      setDark(!dark);
    };
    
    useLayoutEffect(() => {
      if (!userPicked) { // This will stop the system preferences from taking place if the user manually toggles the them
        if (prefersDark) {
          setDark(true);
        }
    
        if (prefersLight) {
          setDark(false);
        }
     
        if (prefersNotSet) {
          setDark(true);
        }
      }
    
      applyTheme();
    }, [dark]);
    

    Your toggle component shouldn't have to change.

    Update:

    Sal's answer is a great alternative. Mine points out the flaw in existing code and how to add to it. This points out how to write your code more effectively.

    export function ThemeProvider({ children }) {
      const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    
      const [dark, setDark] = useState(prefersDark);
    
      useLayoutEffect(() => {
        applyTheme();
      }, [dark]);
    
      ...
    
    }