Search code examples
javascriptreactjsreact-hooksreact-componentreact-custom-hooks

React custom hook's return value changes and yet the component uses the initial return value


I have created a custom hook that dynamically detects and returns the system color theme. The custom hook correctly detects every change and sets the value accordingly. But the component that uses the custom hook always shows the initial value returned by the hook although it re-renders on every theme change.

I would really appreciate if someone can explain why this happens, and can suggest a suitable solution.
Thanks in advance.

useThemeDetector.js

import { useState, useEffect } from 'react';

const useThemeDetector = () => {

  // media query
  const mq = window.matchMedia("(prefers-color-scheme: dark)");

  const [ theme, setTheme ] = useState(mq.matches ? 'dark' : 'light');

  const themeListener = e => {
    setTheme(
      e.matches
        ? 'dark'
        : 'light'
    );
  };

  useEffect(() => {
    mq.addListener(themeListener);
    return () => { mq.removeListener(themeListener); };
  }, [theme]);

  // debug output, shows correct value
  console.log(`theme: ${theme}, from hook`);

  return theme;
};

export default useThemeDetector;

App.js

import Board from './components/Board';
import { useState } from 'react';
import { ThemeContext } from './Context';
import useThemeDetector from './customHooks/useThemeDetector';

const themes = {
  'light': {
    'bgColor': "#fff",
    'fgColor': "#000"
  },
  'dark': {
    'bgColor': "#282c34",
    'fgColor': "#61dafb"
  }
};

function App() {

  const sysTheme = useThemeDetector();

  const [ theme, setTheme ] = useState(sysTheme);
  const [ bgColor, setBgColor ] = useState(themes[theme]['bgColor']);
  const [ fgColor, setFgColor ] = useState(themes[theme]['fgColor']);

  // debug output, shows initial value on every render
  console.log(`theme: ${theme}, from App`);

  const toogleTheme = () => {
    if (theme === 'light') {
      setTheme('dark');
      setBgColor(themes['dark']['bgColor']);
      setFgColor(themes['dark']['fgColor']);
    }
    else {
      setTheme('light');
      setBgColor(themes['light']['bgColor']);
      setFgColor(themes['light']['fgColor']);
    }
  };

  const style = {
    // styles...
  };


  return (
    <ThemeContext.Provider value={{ theme, toogleTheme }}>
      <div
        className="App"
        style={style}
      >
        <Board />
      </div>
    </ThemeContext.Provider>
  );
}

export default App;

Solution

  • The problem is you are effectively using useState twice. There is already a state variable inside the custom hook:

    const [ theme, setTheme ] = useState(mq.matches ? 'dark' : 'light');
    

    But then in App.js you are adding another, distinct, state variable:

    const [ theme, setTheme ] = useState(sysTheme);
    

    That second variable is different to the one inside the custom hook, and so it just gets initialised to whatever the value of sysTheme was when it was initialised.

    Instead, you can do something like this:

    • Get rid of this line const [ theme, setTheme ] = useState(sysTheme);
    • In the custom hook, return the [theme, setTheme] tuple
    • In App.js do const [theme, setTheme] = useThemeDetector(); ...and leave the rest of the code as it is as it is referring to those names.

    There may be other issues though with your custom hook. Looks like it will add more and more event listeners each time the theme value changes. You should probably only add an event listener if there is none already there. I'm not 100% sure about this, but I would certainly test it if I were you.

    UPDATE: I believe you should make your useEffect hook depend on setTheme and not theme.