0

I'm trying to append array which is react state:

  const [ products, setProducts ] = useState([])

  useEffect(() => {
    config.categories.forEach(category => {
      service.getCategory(category.name).then(data => {
        const copy = JSON.parse(JSON.stringify(products))
        copy[category.id] = data
        setProducts(copy)
      })
    })
  },[])

service.getCategory() fetches data over HTTP returning array. products is nested array, or at least it's suppose to be. config.category is defined as:

    categories: [
    {
        name: 'product1',
        id: 0
    },
    {
        name: 'product2',
        id: 1
    },
    {
        name: 'product3',
        id: 2
    }]
}

Eventually products should be appended 3 times and it should contain 3 arrays containing products from these categories. Instead products array ends up including only data from last HTTP fetch, meaning the final array looks something like this

products = [null, null, [{},{},{},..{}]].

I hope someone knows what's going on? Been tinkering with this for a while now.

Tuki
  • 143
  • 1
  • 3
  • 1
    `JSON.parse(JSON.stringify(products))` is a slow and lossy way to clone objects. See https://stackoverflow.com/questions/122102/what-is-the-most-efficient-way-to-deep-clone-an-object-in-javascript. – T.J. Crowder Jan 13 '21 at 17:01

1 Answers1

1

The problem is that your fulfillment handlers close over a stale copy of products (the empty array that's part of the initial state). In a useEffect (or useCallback or useMemo, etc.) hook, you can't use any state items that aren't part of the dependency array that you provide to the hook. In your case, you just want to get the data on mount, so an empty dependency array is correct. That means you can't use any state items in the callback.

What you can do instead is use the callback form of the state setter:

const [ products, setProducts ] = useState([]);

useEffect(() => {
    config.categories.forEach(category => {
        service.getCategory(category.name).then(data => {
            setProducts(products => {          // Use the callback form
                const copy = products.slice(); // Shallow copy of array
                copy[category.id] = data;      // Set this data
                return copy;                   // Return the shallow copy
            });
        });
    });
}, []);

Or more concisely (but harder to debug!) without the explanatory comments:

const [ products, setProducts ] = useState([]);

useEffect(() => {
    config.categories.forEach(category => {
        service.getCategory(category.name).then(data => {
            setProducts(products => Object.assign([], products, {[category.id]: data}));
        });
    });
}, []);

Those both use the same logic as your original code, but update the array correctly. (They also only make a shallow copy of the array. There's no need for a deep copy, we're not modifying any of the objects, just the array itself.)

But, that does a state update each time getCategory completes — so, three times in your example of three categories. If it happens that the request for id 2 completes before the request for id 1 or 0, your array will look like this after the first state update:

[undefined, undefined, {/*data for id = 2*/}]

You'll need to be sure that you handle those undefined entries when rendering your component.

T.J. Crowder
  • 1,031,962
  • 187
  • 1,923
  • 1,875