Search code examples
javascriptarraysreactjsuse-effectuse-state

setState() does not stop rendering


I want to update useState array values by calling a function that maps through an array (called from the database) and the useState array will be updated for each item in the (database array) so I have tried the following approach:

const [snapshots, setSnapshots] = useState();
const [items, setItems] = useState([]);

// ***  get from the database ***** //

useEffect(()=> {
    db.collection("users").doc("4sfrRMB5ROMxXDvmVdwL").collection("basket")
     .get()
     .then((snapshot) => {
      setSnapshots(snapshot.docs)            
     }
    ) ; 
}, []);

  // ***  get from the database ***** //

  // ***  update items value ***** //
  return <div className="cart__items__item">
            {snapshots && snapshots.map((doc)=>(
            setItems([...items, doc.data().id]),
            console.log(items)
               ))
             }
          </div>   
   // ***  update items value ***** //

but the following error appears:

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

I have tried to console.log the result to see the check the issue and the Items array was logged in the console continuously I have tried to include the code in a useEffect but it did not work as well .


Solution

  • Never call a state setter at the top level of your component function. With function components, the key thing to remember is that when you change state, your function will get called again with the updated state. If your code has a state change at the top level of the function (as yours does in the question), every time the function runs, you change state, causing the function to run, causing another state change, and so on, and so on. In your code:

    const initialArray  = [];                        // *** 1
    const [Items, setItems] = useState(initialArray) // *** 2
    initialArray.push("pushed item")
    setItems(initialArray)                           // *** 3
    
    1. Creates a new array every time
    2. Only uses the first one to set the initial value of Items when the component is created
    3. Sets new array in state, causing the function to be called again

    Instead, you should be setting state only in response to some change or event, such as a click handler, or some other state changing, etc.

    Also note that you must not directly modify an object (including an array) that you have in state. Your code doesn't technically do that (since there's a new initialArray every time), but it looks like what you meant to do. To add to an array in state, you copy the array and add the new entry at the end.

    An example of the above:

    function Example() {
        const [items, setItems] = useState([]);
        const clickHandler = e => {
            setItems([...items, e.currentTarget.value]);
        };
        return <div>
            <div>
                {items.map(item => <div key={item}>{item}</div>)}
            </div>
            <input type="button" value="A" onClick={clickHandler} />
            <input type="button" value="B" onClick={clickHandler} />
            <input type="button" value="C" onClick={clickHandler} />
        </div>;
    }
    

    (Slightly odd UI just to keep the code example simple.)

    Note that conventionally Items would be called items.


    Re your update:

    • That code calls setItems at the top level of the function, so it has the problem above. Instead, you do that work in the useEffect querying the database.
    • There's no reason to call setItems repeatedly during the map operation.
    • The code should handle the component unmounting while the DB operation is outstanding
    • The code should actually render something in the map in the JSX
    • The code should handle errors (rejections)

    E.g., something like this:

    const [snapshots, setSnapshots] = useState();
    const [items, setItems] = useState();   // *** If you're going to use `undefined`
                                            // as the initial state of `snapshots`,
                                            // you probably want to do the same with
                                            // `items`
    
    useEffect(()=> {
        let cancelled = false;
        db.collection("users").doc("4sfrRMB5ROMxXDvmVdwL").collection("basket")
        .get()
        .then((snapshot) => {
            // *** Don't try to set state if we've been unmounted in the meantime
            if (!cancelled) {
                setSnapshots(snapshot.docs);
                // *** Create `items` **once** when you get the snapshots
                setItems(snapshot.docs.map(doc => doc.data().id));
            }
        })
        // *** You need to catch and handle rejections
        .catch(error => {
            // ...handle/report error...
        });
        return () => {
            // *** The component has been unmounted. If you can proactively cancel
            // the outstanding DB operation here, that would be best practice.
            // This sets a flag so that it definitely doesn't try to update an
            // unmounted component, either because A) You can't cancel the DB
            // operation, and/or B) You can, but the cancellation occurred *just*
            // at the wrong time to prevent the promise fulfillment callback from
            // being queued. (E.g., you need it even if you can cancel.)
            cancelled = true;
        };
    }, []);
    
    // *** Use `items` here
    return <div className="cart__items__item">
        {items && items.map(id => <div>{id}</div>)/* *** Or whatever renders ID */}
    </div>;
    

    Note that that code assumes that doc.data().id is a synchronous operation.