Search code examples
reactjsuse-effect

How to (get and) compare previous state value with a new value inside useEffect hook?


I put the simplified version of my case above. I'm trying to compare the new network result with the previous version of it. I store fetched data in the state as 'notifications'. I don't want to setNotifications if there is no change.

In useEffect dependency array, eslint yells and wants me to put the notifications array into the dep array. But I'm changing notifications in useEffect. If I'm not wrong, it will be an endless loop. Because notifications are an object array and they change in each render (even if there is no real change in data)

Could you explain which mental model should I follow when using useEffect in this scenario?

const NotificationBox = () => {
  const [notifications, setNotifications] = useState([])

  useEffect(() => {
    client.get('notification?count=10').then((newResult) => {
      if (!_.isEqual(notifications, newResult)) {
        //notifications is stale
        setNotifications(newResult)
      }
    })
  }, [iHaveSomethingDifferentThatTriggersThisEffect]) 
  //Also, ESLint: React Hook useEffect has a missing dependency: 'notifications'. Either include it or 
    remove the dependency array.(react-hooks/exhaustive-deps)
}

export default NotificationBox

Solution

  • My guess is that your code will work fine... almost all the time.

    If notifications is updated and the fetch is triggered at the same time your hook runs the risk of running with a stale value.

    I think you can do better. You could for example make a custom hook to encapsulate the behaviour.

    const notificationsCache = {
        current: []
    }
    
    export function useNotifications() {
        const [notifications, setNotifications] = useState(notificationsCache.current)
    
        const update = useCallback(() => {
            client.get('notification?count=10').then((newResult) => {
                if (!_.isEqual(notificationsCache.current, newResult)) {
                    notificationsCache.current = newResult
                    setNotifications(notificationsCache.current)
                }
            })
        }, [])
    
        return [notifications, update]
    }
    

    This uses and exernal "ref" to cache the result, so all the component using the hook will share a cache. If that's not desired, you can just move the cache into the component with a useRef instead and every component will have an independent cache.

    export function useNotifications() {
        const notificationsCache = useRef([])
        const [notifications, setNotifications] = useState(notificationsCache.current)
    
        const update = useCallback(() => {
            client.get('notification?count=10').then((newResult) => {
                if (!_.isEqual(notificationsCache.current, newResult)) {
                    notificationsCache.current = newResult
                    setNotifications(notificationsCache.current)
                }
            })
        }, [])
    
        return [notifications, update]
    }
    

    Then we just can

    const [notifications, update] = useNotifications()
    

    and use update when we want to trigger a re-fetch.

    There is room to improve this more. What if you have multiple components that use the hook and are mounted at the same time. If you updates the value, the other hooks state should ideally also get updated.

    A perfect time to use the new useId hook.

    const notificationsCache = {
        current: []
    }
    
    const listeners = {}
    
    export function useNotifications() {
        const id = useId()
        const [notifications, setNotifications] = useState(notificationsCache.current)
    
        useEffect(() => {
            listeners[id] = setNotifications
    
            return () => delete listeners[id]
        }, [])
    
        const setState = useCallback((newNotifications) => {
            notificationsCache.current = newNotifications
            for (const id in listeners) {
               listeners[id](notificationsCache.current)
            }
        }, [])
    
        const update = useCallback(() => {
            client.get('notification?count=10').then((newResult) => {
                if (!_.isEqual(notificationsCache.current, newResult)) {
                    setState(newResult)
                }
            })
        }, [])
    
        return [notifications, setState, update]
    }