Too many re-renders when using custom hook

Viewed 516

I have the following errors:

Uncaught Error: Too many re-renders. React limits the number of renders to prevent an infinite loop.

How is this happening even though I prevent it from doing this in useEffect, as shown below

//component

function LiveStreaming({ title }) {
  const [arContent, status] = useContent("story");

  useEffect(() => {
    if (arContent && arContent !== null) {
      
      console.log("arContent", arContent);
    }
  }, [arContent]);
}


//useContent

import { useState, useEffect } from "react";


const localCache = {};
async function requestArContent() {
  console.log("pinging server for content");
  const res = await fetch(`${BASE_URL}/api/books`);
  const json = (await res.json()) || {}; //{} so app doesn't crash if no network connection
  localCache[title] = json;
}


    export function useContent(title) {
      const [arContent, setArContent] = useState({});
      const [status, setStatus] = useState("unloaded"); //unloaded, loading, loaded
    
      useEffect(() => {
        if (!title) {
          setArContent({});
        } else if (localCache[title]) {
          setArContent(localCache[title]);
        } else {
          setStatus("loading");
          requestArContent();
        }
      }, [title]);
    
      setArContent(localCache[title]);
      setStatus("loaded");
    
      return [arContent, status];
    }
3 Answers

The main issue is unconditionally enqueueing state updates from the hook body, creating the render looping.

An additional issue is that title is undefined in requestArContent in the useContent hook definition/file, it's only defined within the useContent hook function body. This results in checks where localCache[title] isn't equal to localCache[undefined] set in requestArContent and so the final else is taken each time and the status state toggles/flips between 'loaded' and 'loading'.

const localCache = {};

async function requestArContent() {
  console.log("pinging server for content");
  const res = await fetch(`${BASE_URL}/api/books`);
  const json = (await res.json()) || {}; //{} so app doesn't crash if no network connection
  localCache[title] = json; // <-- title undefined
}


export function useContent(title) {
  const [arContent, setArContent] = useState({});
  const [status, setStatus] = useState("unloaded"); //unloaded, loading, loaded

  useEffect(() => {
    if (!title) {
      setArContent({});
    } else if (localCache[title]) { // <-- title defined, localCache[title] undefined
      setArContent(localCache[title]);
    } else {
      setStatus("loading");
      requestArContent();
    }
  }, [title]);

  setArContent(localCache[title]); // <-- triggers infinite loop
  setStatus("loaded");             // <-- triggers infinite loop

  return [arContent, status];
}

To resolve pass title as an argument to requestArContent so the cache is updated with the title key. Convert the useEffect callback body into an async function such that you can await requestArContent fetching content.

const localCache = {};

async function requestArContent(title) { // <-- consume title arg
  console.log("pinging server for content");
  try {
    const res = await fetch(`${BASE_URL}/api/books`);
    const json = (await res.json()) || {};
    localCache[title] = json; // <-- cache by title
  } catch(error) {
    // any error handling, logging, etc...
  }
}


export function useContent(title) {
  const [arContent, setArContent] = useState({});
  const [status, setStatus] = useState("unloaded"); //unloaded, loading, loaded

  useEffect(() => {
    (async () => {
      if (!title) {
        setArContent({});
      } else if (localCache[title]) {
        setArContent(localCache[title]);
      } else {
        setStatus("loading");
        await requestArContent(title); // <-- pass title as arg and wait
        setStatus("loaded");
      }
    })();
  }, [title]);

  return [arContent, status];
}

Edit too-many-re-renders-when-using-custom-hook

This is what happens :

  • Your LiveStreaming function calls useContent
  • useContent calls setArContent
  • this triggers an update of arContent through the useState hook
  • this triggers the useContent hook to run second time (after the current useContent and the current render are finished)
  • this triggers a re-render (which sends you back to step one, sending you in an infinite loop)

The solution to this issue is to update the state in useContent only when an update is actually needed, for example like that

export function useContent (title) {
  const [arContent, setArContent] = useState({})
  const [status, setStatus] = useState('unloaded') //unloaded, loading, loaded

  useEffect(() => {
    if (!title) {
      setArContent({})
    } else if (localCache[title]) {
      setArContent(localCache[title])
    } else {
      setStatus('loading')
      requestArContent().then(
        () => {
          setArContent(localCache[title])
          setStatus('loaded')
        },
        err => {
          console.error(err)
        }
      )
    }
  }, [title])

  return [arContent, status]
}

I keep it that way to illustrate the solution to the core issue in a simpler way, but it still lacking two things in my opinion:

  • The callback of useEffect needs to return an callback to "cancel" the update of the state (otherwise, you will have warnings when the component is unmounted during the async operation)
  • The effect is missing dependencies which is never a good thing. Even if you don't know t the current time why (I don't), it will always bite later

enter many times to useEffect and update the status every time that is the wrong, to solve you need to use another useState to know is already executed.

const [executed, setExecuted] = useState(false); 
const [status, setStatus] = useState("unloaded"); 
    
      useEffect(() => {
       const getData =() =>{
        if (!title) {
          setArContent({});
        } else if (localCache[title]) {
          setArContent(localCache[title]);
        } else {
          setStatus("loading");
          requestArContent();
        }
        setExecuted(true)
       }
       if(!executed){
         getData()
       }
      }, [title]);
Related