Search code examples
reactjsreact-hooksuse-effect

UseEffect viewPort custom hook creates memory leak. Can anyone see why?


index.js:1 Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.

import { breakpoints } from "helpers/breakpoints"
import { useWindowSize } from "hooks/useWindowSize"
import { useEffect, useState } from "react"
import { Viewport } from "./types/useViewportTypes"

const useViewport = () => {
  const [viewport, setViewport] = useState<Viewport | undefined>(undefined)

  const windowWidth: number | undefined = useWindowSize().width

  useEffect(() => {
    let vp: Viewport | undefined = undefined
    const width = windowWidth ? windowWidth : window.innerWidth

    if (width) {
      const isZeroAndUp = width >= 0
      const isMobileAndUp = width >= breakpoints.mobileAndUp
      const isTabletAndUp = width >= breakpoints.tabletAndUp
      const isDesktopAndUp = width >= breakpoints.desktopAndUp
      const isLargeDesktopAndUp = width >= breakpoints.largeDesktopAndUp
      const isMaxWidthAndUp = width >= breakpoints.maxWidthAndUp

      vp = {
        isZeroAndUp,
        isMobileAndUp,
        isTabletAndUp,
        isDesktopAndUp,
        isLargeDesktopAndUp,
        isMaxWidthAndUp,
      }
    }

    setViewport(vp)
  }, [windowWidth])

  return viewport
}

export default useViewport

ViewPort is also using more hooks to check for window size.

import { useState, useEffect } from "react"
import { debounce } from "helpers/helpers"

// Hook
export function useWindowSize() {
  const [windowSize, setWindowSize] = useState({
    width: undefined,
    height: undefined,
  })

  useEffect(() => {
    // Handler to call on window resize
    function handleResize() {
      // Set window width/height to state
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight,
      })
      const vh = (window.top || window).innerHeight * 0.01
      document.documentElement.style.setProperty("--vh", `${vh}px`)
    }
    const debouncedHandleResize = debounce(handleResize, 300)

    // Add event listener
    window.addEventListener("resize", debouncedHandleResize)

    // Call handler right away so state gets updated with initial window size
    debouncedHandleResize()

    // Remove event listener on cleanup
    return () => window.removeEventListener("resize", debouncedHandleResize)
  }, [])

  return windowSize
}

And the debounce function.

// Debounce function

export function debounce<U = unknown, V = void>(
  fn: (args: U) => V,
  wait: number
) {
  let timer: ReturnType<typeof setTimeout>

  return (args: U): Promise<V> =>
    new Promise((resolve) => {
      if (timer) clearTimeout(timer)
      timer = setTimeout(() => resolve(fn(args)), wait)
    })
}

Solution

  • The issue is that with a debounced or otherwise deferred function, it's possible for setViewport (or any other state setter) to get called after the component itself is gone (and doing setViewport will not affect anything, as there could be nothing to read that state).

    That's why React is telling you there could be a memory leak in your app. In practice, in this case it doesn't really matter; the debounced/deferred call is an one-shot.

    If you really want to quiesce the warning, then you'd need something like an useIsMounted hook:

    function useIsMounted() {
      const mountedRef = useRef(false);
      const get = useCallback(() => mountedRef.current, []);
      useEffect(() => {
        mountedRef.current = true;
        return () => mountedRef.current = false;
      }, []);
      return get;
    }
    
    // ...
    
    function MyComponent() {
      const isMounted = useIsMounted();
      // ... in your handler...
        if(isMounted()) setViewport(...);
    }
    

    so you don't call the setter if the component has already unmounted.