useDarkMode hook called multiple times onClick

Viewed 431

I'm trying to build an SSR compatible (flicker-free) dark mode using a custom hook. I want to call it from multiple components which should stay in sync by using an event bus (i.e. emitting custom events and registering corresponding listeners in useEffect).

The problem I'm having is that every time I trigger onClick={() => setColorMode(nextMode)}, it's called multiple times. In the screenshot below, only the first of the nine lines that appear inside the red box when clicking DarkToggle is expected. (The logs above the red box occur during initial page load.)

enter image description here

What's causing these extra calls and how can I avoid them?

An MVP of what I'm trying to build is on GitHub. Here's what the hooks look like:

useDarkMode

import { useEffect } from 'react'
import {
  COLORS,
  COLOR_MODE_KEY,
  INITIAL_COLOR_MODE_CSS_PROP,
} from '../constants'
import { useLocalStorage } from './useLocalStorage'

export const useDarkMode = () => {
  const [colorMode, rawSetColorMode] = useLocalStorage()

  // Place useDarkMode initialization in useEffect to exclude it from SSR.
  // The code inside will run on the client after React rehydration.
  // Because colors matter a lot for the initial page view, we're not
  // setting them here but in gatsby-ssr. That way it happens before
  // the React component tree mounts.
  useEffect(() => {
    const initialColorMode = document.body.style.getPropertyValue(
      INITIAL_COLOR_MODE_CSS_PROP
    )
    rawSetColorMode(initialColorMode)
  }, [rawSetColorMode])

  function setColorMode(newValue) {
    localStorage.setItem(COLOR_MODE_KEY, newValue)
    rawSetColorMode(newValue)

    if (newValue === `osPref`) {
      const mql = window.matchMedia(`(prefers-color-scheme: dark)`)
      const prefersDarkFromMQ = mql.matches
      newValue = prefersDarkFromMQ ? `dark` : `light`
    }

    for (const [name, colorByTheme] of Object.entries(COLORS))
      document.body.style.setProperty(`--color-${name}`, colorByTheme[newValue])
  }

  return [colorMode, setColorMode]
}

useLocalStorage

import { useEffect, useState } from 'react'

export const useLocalStorage = (key, initialValue, options = {}) => {
  const { deleteKeyIfValueIs = null } = options

  const [value, setValue] = useState(initialValue)

  // Register global event listener on initial state creation. This
  // allows us to react to change events emitted by setValue below.
  // That way we can keep value in sync between multiple call
  // sites to useLocalStorage with the same key. Whenever the value of
  // key in localStorage is changed anywhere in the application, all
  // storedValues with that key will reflect the change.
  useEffect(() => {
    let value = localStorage[key]
    // If a value isn't already present in local storage, set it to the
    // provided initial value.
    if (value === undefined) {
      value = initialValue
      if (typeof newValue !== `string`)
        localStorage[key] = JSON.stringify(value)
      localStorage[key] = value
    }
    // If value came from local storage it might need parsing.
    try {
      value = JSON.parse(value)
      // eslint-disable-next-line no-empty
    } catch (error) {}
    setValue(value)

    // The CustomEvent triggered by a call to useLocalStorage somewhere
    // else in the app carries the new value as the event.detail.
    const cb = (event) => setValue(event.detail)
    document.addEventListener(`localStorage:${key}Change`, cb)
    return () => document.removeEventListener(`localStorage:${key}Change`, cb)
  }, [initialValue, key])

  const setStoredValue = (newValue) => {
    if (newValue === value) return

    // Conform to useState API by allowing newValue to be a function
    // which takes the current value.
    if (newValue instanceof Function) newValue = newValue(value)

    const event = new CustomEvent(`localStorage:${key}Change`, {
      detail: newValue,
    })
    document.dispatchEvent(event)

    setValue(newValue)

    if (newValue === deleteKeyIfValueIs) delete localStorage[key]
    if (typeof newValue === `string`) localStorage[key] = newValue
    else localStorage[key] = JSON.stringify(newValue)
  }

  return [value, setStoredValue]
}
1 Answers

You have the below useEffect

useEffect(() => {
    const initialColorMode = document.body.style.getPropertyValue(
      INITIAL_COLOR_MODE_CSS_PROP
    )
    rawSetColorMode(initialColorMode)
  }, [rawSetColorMode])

Since this useEffect has a dependency on rawSetColorMode, the useEffect runs whenever rawSetColorMode changes.

Now rawSetColorMode internally calls setValue until somecondition inside rawSetColorMode results in setValue to not be called

Now reading by the variable names, it seems you only needed to all the above useEffect on initial render and hence you could simply write it as

useEffect(() => {
    const initialColorMode = document.body.style.getPropertyValue(
      INITIAL_COLOR_MODE_CSS_PROP
    )
    rawSetColorMode(initialColorMode)
  }, []) // empty dependency to make it run on initial render only

And that should fix your issue

Now you might get a ESLint warning for empty dependency, you can either choose to disable it like

useEffect(() => {
    const initialColorMode = document.body.style.getPropertyValue(
      INITIAL_COLOR_MODE_CSS_PROP
    )
    rawSetColorMode(initialColorMode);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

or go by the method of memoizing rawSetColorMode method using useCallback so that it is only created once, which might be difficult to do in your case since have multiple dependencies inside of it

Related