so I am trying to fetch images from Firebase storage and then display them in a React component. Frankly, I'm new to React/Javascript and I'm having a hard time understanding the asynchronous nature of React/JS and I'm running into the problem where my images are fetched but the component is not re-rendered upon completion of the fetch...
This is the code on my main page, in the useEffect function, I am trying to fetch the images and then store their download urls in an array and then set that array in state (upon page load i.e. only once). Since these are promises, its not happening synchronously and when I first load the page it displays no images. If I, however, click on a different component on the page, it re-renders my Pictures component and the images show up(??), so I know the fetch has worked.
let storageRef = firebase.storage().ref()
let calendarRef = React.createRef()
const position = props.location.state.name.indexOf("#")
const username = props.location.state.name.substring(0, position);
const [simpleDate, setSimpleDate] = useState(null)
const [selectedDate, setSelectedDate] = useState('')
const [showModal, setShowModal] = useState(false)
const [showCancelModal, setShowCancelModal] = useState(false)
const [expectedPeople, setExpectedPeople] = useState(null)
const [events, setEvents] = useState([])
const [helpModal, showHelpModal] = useState(false)
const [pictureURLs, setPictureURLs] = useState([])
useEffect(() => {
//load pictures
const fetchImages = async () => {
let urls = []
storageRef.child('cabinPictures').listAll().then((result) => {
result.items.forEach((imageRef) => {
imageRef.getDownloadURL().then(url => {
urls.push(url)
})
})
})
return urls;
}
fetchImages().then(urls => {
setPictureURLs(urls);
console.log("inside .then() " + pictureURLs)
})
//fetch reservations
firebase
.firestore()
.collection('reservations')
.onSnapshot(serverUpdate => {
const reservations = serverUpdate.docs.map(_doc => {
const data = _doc.data();
data['id'] = _doc.id;
return data;
});
let fetchedEvents = reservations.map(reservation => {
let date = reservation.reservationDate.toDate()
const month = ("0" + (date.getUTCMonth() + 1))
let dateString = date.getUTCFullYear() + "-" + ("0" + (date.getUTCMonth()+1)).slice(-2) + "-" + ("0" + date.getUTCDate()).slice(-2)
return {title: reservation.username + " - " + reservation.numPeople + " total", date: dateString, id: reservation.id, totalPeople: reservation.numPeople, month: month}
})
console.log(fetchedEvents)
setEvents(fetchedEvents)
});
}, [])
My Pictures component in the main page where the useEffect (above) is run. I pass the urls from state as a prop:
<div className="pictures-div-container">
<Pictures pictureURLs={pictureURLs}>
</Pictures>
</div>
The code for my Picture component:
import React, { useState, useEffect } from 'react'
import styles from "./styles.css"
const firebase = require('firebase');
const Pictures = (props) => {
const [uploadImage, setUploadImage] = useState(null)
const [progressValue, setProgressValue] = useState(0)
let storageRef = firebase.storage().ref()
let { pictureURLs } = props
const handleUpload = () => {
setProgressValue(0)
const uploadTask = storageRef.child(`cabinPictures/${uploadImage.name}`).put(uploadImage)
uploadTask.on('state_changed',
(snapshot) => {
//progress function
const progress = Math.round((snapshot.bytesTransferred / snapshot.totalBytes ) * 100)
setProgressValue(progress)
},
(error) => {
//error function
console.log(error)
},
() => {
//complete function
storageRef.child('cabinPictures').child(uploadImage.name).getDownloadURL().then(url => {
console.log(url)
} )
});
}
const handleFileSelect = (e) => {
if (e.target.files[0]) {
setUploadImage(e.target.files[0])
}
}
return (
<div className="pictures-container">
<h2>Upload a Picture!</h2>
<button className="upload-button" onClick={() => handleUpload()}>Upload</button>
<input type="file" onChange={(e) => handleFileSelect(e)}></input>
<progress value={progressValue} max="100"></progress>
<div className="pictures">
{
pictureURLs.map((url, index) => {
return <img className="picture" key={index} src={url}></img>
})
}
</div>
</div>
)
}
export default Pictures
So, can anyone help me understand why the Pictures component is not re-rendering automatically when the state is set after fetching the picture urls from firebase? I thought that when a prop changes in a component, the whole component is re-rendered?
EDIT:
So this is what I changed in my main page's useEffect function as per the answer's suggestions, and it works!
//fetch and load pictures
const fetchImages = async () => {
let result = await storageRef.child('cabinPictures').listAll();
let urlPromises = result.items.map(imageRef => imageRef.getDownloadURL())
return Promise.all(urlPromises)
}
const loadImages = async () => {
const urls = await fetchImages()
setPictureURLs(urls)
}
loadImages()
You have to let all the nested promises resolve before you return urls
I am not very current on firebase API so am not sure if result.items is an actual array or an object that has a foreach method. Following should work if it is a js array
Try something like:
//load pictures
const fetchImages = async() => {
let result = await storageRef.child('cabinPictures').listAll();
/// map() array of the imageRef.getDownloadURL() promises
let urlPromises = result.items.map(imageRef => imageRef.getDownloadURL());
// return all resolved promises
return Promise.all(urlPromises);
}
const urls = await fetchImages()
setPictureURLs(urls);
console.log("inside .then() ", urls)
Related
This question already has answers here:
The useState set method is not reflecting a change immediately
(15 answers)
Closed last month.
I'm new to react and I'm trying to make an app, where user can upload multiple images at once, and those images get sent to the firebase storage, and urls of those images get stored in an array inside a doc, so that later on they can be accessed in a different page.
Here is a snippet of my code:
function CreateCarParts({isAuth}) {
const [images, setImages] = useState(null);
const [imageUrls, setImageUrls] = useState([]);
let navigate = useNavigate();
const postsCollectionRef = collection(db, "CarParts")
useEffect(() => {
if (!localStorage.getItem('isAuth')){
navigate("/login");
};
}, []);
const uploadImages = async () => {
if(images === null) return;
let uniqueFolderName = new Date().getTime().toString();
const imageList = Array.from(images);
for(let i = 0; i < imageList.length; i++) {
const image = imageList[i];
const imageLinkName = `carParts/${uniqueFolderName}/${image.name}`;
const imageRef = ref(storage, imageLinkName);
const snapshot = await uploadBytes(imageRef, image);
const url = await getDownloadURL(snapshot.ref);
setImageUrls((prev) => [...prev, url]);
}
alert("Images Uploaded");
};
const createPost = async () => {
await uploadImages();
await addDoc(postsCollectionRef, {imageUrls});
navigate('/carparts');
};
return (
<>
<Form.Control onChange={(event) => {setImages(event.target.files);uploadImages();}} type="file" multiple />
<Button onClick={createPost}> Submit </Button>
</>
)
}
export default CreateCarParts;
The images get sent to the storage, and the doc is created successfully, however the imageUrls array is empty. I can't figure out why it's not working, I assume I'm using the useState() incorrectly.
imageUrls won't be updated in the same render cycle, so calling await addDoc(postsCollectionRef, {imageUrls}); right after await uploadImages(); won't work
I would store urls in a local array instead
function CreateCarParts({isAuth}) {
const [images, setImages] = useState(null);
let navigate = useNavigate();
const postsCollectionRef = collection(db, "CarParts")
useEffect(() => {
if (!localStorage.getItem('isAuth')){
navigate("/login");
};
}, []);
const uploadImages = async () => {
if(images === null) return;
let uniqueFolderName = new Date().getTime().toString();
const imageList = Array.from(images);
const urls = [];
for(let i = 0; i < imageList.length; i++) {
const image = imageList[i];
const imageLinkName = `carParts/${uniqueFolderName}/${image.name}`;
const imageRef = ref(storage, imageLinkName);
const snapshot = await uploadBytes(imageRef, image);
const url = await getDownloadURL(snapshot.ref);
urls.push(url);
}
alert("Images Uploaded");
return urls;
};
const createPost = async () => {
const imageUrls = await uploadImages();
await addDoc(postsCollectionRef, {imageUrls});
navigate('/carparts');
};
return (
<>
<Form.Control onChange={(event) => {setImages(event.target.files);uploadImages();}} type="file" multiple />
<Button onClick={createPost}> Submit </Button>
</>
)
}
export default CreateCarParts;
Question:
I am developing a small app that is a memory game of Formula One Drivers to practice React. It makes a call to an API to get the driver info then I have to make a second API call to Wikipedia to get the driver images. When I submit the year and click the button it will only load half the information Image 1 & getDrivers function. When I click the button again it will load the images Image 2 & getDriversImgs function / retrievingImgUrl.
I believe I am encountering a GOTCHA or doing something fundamentally wrong. I am not sure in my setDrivers call in the retrievingImgUrl() function if it isn't updating because it is a reference to an array even though I use map and it should be returning a new array?
Or is this something where I need to use useEffect or useCallback to have the code rerender in one go?
Any advice on how to fix the bug and if you could point me in a direction to possibly clean up these fetch calls or would you consider this clean code (like conceptually chaining fetch calls together in smaller functions or should I make it one big function)?
import { Fragment, useState, useEffect } from "react";
// Components
import Header from "./components/header/Header";
import CardList from "./components/main/CardList";
import Modal from "./components/UI/Modal";
// CSS
import classes from "./App.module.css";
function App() {
const [drivers, setDrivers] = useState([]);
const getDrivers = async (year) => {
const response = await fetch(
"https://ergast.com/api/f1/" + year + "/drivers.json"
);
const data = await response.json();
let driverInfo = [];
data.MRData.DriverTable.Drivers.map((driver) => {
driverInfo.push({
id: driver.code,
firstName: driver.givenName,
lastName: driver.familyName,
wikipedia: driver.url,
image: null,
});
});
setDrivers(driverInfo);
getDriversImgs();
};
async function getDriversImgs() {
console.log(drivers);
const responses = await Promise.all(
drivers.map((driver) => {
let wikiPageName = driver.wikipedia.split("/").slice(-1).toString();
let wiki_url =
"https://en.wikipedia.org/w/api.php?origin=*&action=query&titles=" +
wikiPageName +
"&prop=pageimages&format=json&pithumbsize=500";
return fetch(wiki_url);
})
);
const urls = await Promise.all(responses.map((r) => r.json())).then(
(json) => retrievingImgUrl(json)
);
setDrivers((prev) => {
return prev.map((item, idx) => {
return { ...item, image: urls[idx] };
});
});
}
const retrievingImgUrl = async (data) => {
console.log(data);
const strippingData = data.map((d) => {
return d.query.pages;
});
const urls = strippingData.map((d) => {
const k = Object.keys(d)[0];
try {
return d[k].thumbnail.source;
} catch {
return null;
}
});
return urls;
};
return (
<Fragment>
<Header getDrivers={getDrivers} />
<CardList drivers={drivers} />
</Fragment>
);
}
export default App;
Image 1 (clicked button once):
Image 2 (clicked button twice):
Object20Object error:
const Header = (props) => {
const driverYear = useRef();
const driverYearHandler = (e) => {
e.preventDefault();
console.log(driverYear);
const year = driverYear.current.value;
console.log(typeof year);
props.getDrivers(year.toString());
};
return (
<header className={classes.header}>
<Title />
<form onSubmit={driverYearHandler}>
{/* <label htmlFor="year">Enter Year:</label> */}
<input
type="text"
id="year"
ref={driverYear}
placeholder="Enter Year:"
/>
<button onClick={props.getDrivers}>Get Drivers</button>
</form>
</header>
);
};
export default Header;
Console Error:
UPDATED FETCH CALL
const getDrivers = async (year) => {
console.log("Running more than once??");
const url = "https://ergast.com/api/f1/" + year + "/drivers.json";
const response = await fetch(url);
const data = await response.json();
let driverInfo = [];
data.MRData.DriverTable.Drivers.map((driver) => {
driverInfo.push({
id: driver.code,
firstName: driver.givenName,
lastName: driver.familyName,
wikipedia: driver.url,
image: null,
});
});
getDriversImgs(driverInfo).then((data) => setDrivers(data));
console.log("Here is driver info", driverInfo);
};
const getDriversImgs = async (driverInfo) => {
const responses = await Promise.all(
driverInfo.map((driver) => {
let wikiPageName = driver.wikipedia.split("/").slice(-1).toString();
let wiki_url =
"https://en.wikipedia.org/w/api.php?origin=*&action=query&titles=" +
wikiPageName +
"&prop=pageimages&format=json&pithumbsize=500";
return fetch(wiki_url);
})
);
const urls = await Promise.all(responses.map((r) => r.json())).then(
(json) => retrievingImgUrl(json)
);
return driverInfo.map((item, idx) => {
return { ...item, image: urls[idx] };
});
};
const retrievingImgUrl = async (data) => {
const strippingData = data.map((d) => {
return d.query.pages;
});
const urls = strippingData.map((d) => {
const k = Object.keys(d)[0];
try {
return d[k].thumbnail.source;
} catch {
return null;
}
});
return urls;
};
This is likely happening because of a small misunderstanding with setState. You are calling getDriversImgs() just after setDrivers() is called, but any set state function is asynchronous. It is likely not done setting before you look for the driver's image.
The simplest solution in my opinion will be to not setDrivers until you've correlated an image to each driver. You already have all of your driverInfo in an array, so iterating through that array and finding the image for the driver should be quite straightforward.
After you've created a driverInfo array that includes the driver's image, then you can use setDrivers which will render it to the DOM.
I'm using React's context api to store an array of Favorite products.The favorites Array is filled with Boolean Value False and turned to true based on id of the products.There is collection page which displays productCards having an addtoFavorite button,Upon clicking the button disables but if the product is already present in favorites it has to disabled.
Now it works perfectly fine for the 1st Page , disabling only favorite products with the array containing values true and false based on index of the products but when navigated to another page it disables other products at the same index even though the favorites array is updated to have all values as false.If we Navigate Back or move to another page its value now remains false in the array.It looks as if UseContext updates the value of the array late or doesn't rerender on change.
I have tried implementing other stuffs but it still wouldn't re-render when the array was changed.
Here's the FavoritesContext:
const FavoritesContext = React.createContext({
addToFavorites: (id,index) => {},
favorites:[],
storedFavorites:(data) => {}
});
export const FavoritesContextProvider = (props) => {
const authCtx = useContext(AuthContext)
const token = authCtx.token;
const userId = authCtx.userId;
const [favorites,setFavorites] = useState([]);
// To retrieve stored favorites from FireBase
const retrieveStoredFavorites = (data) => {
let fav = new Array(data.length).fill(false);
setFavorites(fav);
let queryParams = '?auth=' + token + '&orderBy="userId"&equalTo="' + userId + '"';
axiosInstance.get('/Favorites.json' + queryParams)
.then((response) => {
let fetchProductData = [];
for (let key in response.data) {
fetchProductData.push({
...response.data[key],
productId: key,
});
}
let favoriteList = [];
//To find if the product is present in the Fetched Favorite products List
for(let i=0;i<data.length;i++){
let ids = data[i].id
let favoriteProducts = !!fetchProductData.find((product)=>product.id==ids)
favoriteList.push(favoriteProducts)
}
//console.log(favoriteList)
setFavorites(favoriteList)
});
}
//Add to Favorites
const addTofavoritesHandler = (Product,index) => {
axiosInstance
.post('Favorites.json?auth='+token,Product)
.then((response) => {
//console.log("SUCCESS")
})
.catch((error) => console.log(error));
let favoriteOnes = [...favorites];
favoriteOnes[index] = true;
setFavorites(favoriteOnes);
};
const contextValue = {
addToFavorites:addTofavoritesHandler,
favorites:favorites,
storedFavorites:retrieveStoredFavorites
};
return (
<FavoritesContext.Provider value={contextValue}>
{props.children}
</FavoritesContext.Provider>
);
};
export default FavoritesContext;
Now here is the Collection Page
const CollectionPage = () => {
const classes = useStyles();
const [products, setProducts] = useState([]);
const [filteredProducts, setFilteredProducts] = useState([]);
const [currentPage, setCurrentPage] = useState(1);
const [productsPerPage] = useState(9);
const [loading, setLoading] = useState(false);
const { enqueueSnackbar } = useSnackbar();
const authCtx = useContext(AuthContext);
const token = authCtx.token;
const userId = authCtx.userId;
const favoriteCtx = useContext(FavoritesContext)
const favorites = favoriteCtx.favorites
//To Display the Products in Main Content
const DisplayProductsHandler = (Data) => {
//Get value of FirstPageNumber and LastPageNumber
const indexOfLastPage = currentPage * productsPerPage;
const indexOfFirstPage = indexOfLastPage - productsPerPage;
//console.log("[Products]")
const productData = Data.slice(indexOfFirstPage, indexOfLastPage);
favoriteCtx.storedFavorites(productData)
//console.log(productData);
const updatedProductData = productData.map((product,index) => {
return (
<ProductCard
Link={`/Info/${product.id}`}
key={product.id}
Title={product.productName}
Image={product.productImage}
Value={product.price}
addToFavorites={() => addTofavoritesHandler(product,index)}
addToCart={() => addToCartHandler(product)}
disableFavoriteButton={favorites[index]}
/>
);
});
setProducts(updatedProductData);
};
//Display the Products from DisplayProductHandler
useEffect(() => {
setLoading(true);
//Scroll To Top When Reloaded
window.scrollTo(0, 0);
//To Display the Products
if (filteredProducts.length === 0) {
DisplayProductsHandler(ProductData);
} else {
DisplayProductsHandler(filteredProducts);
}
setLoading(false);
}, [currentPage, filteredProducts]);
//Add to Favorites Handler
const addTofavoritesHandler =(likedProduct,index) => {
setLoading(true);
let updatedLikedProduct = {
...likedProduct,
userId: userId,
};
favoriteCtx.addToFavorites(updatedLikedProduct,index)
//To Display ADDED TO FAVORITES Message using useSnackbar()
enqueueSnackbar("ADDED TO FAVORITES", { variant: "success" })
setLoading(false);
};
I need it to re-render every time the array in context is updated.
I am trying to implement load more button for my small project GiF generator. First I thought of appending next set of 20 response at the bottom, but failed to do.
Next, I thought of implementing loading the next set of 20 results by simply removing the current one. I tried to trigger a method on click of button, but I failed to do so. Its updating the state on second click of load more and then never updating it again.
Please help me find what I am missing, I have started learning React yesterday itself.
import React, { useEffect, useState } from 'react';
import './App.css';
import Gif from './Gif/Gif';
const App = () => {
const API_KEY = 'LIVDSRZULELA';
const [gifs, setGif] = useState([]);
const [search, setSearch] = useState('');
const [query, setQuery] = useState('random');
const [limit, setLimit] = useState(20);
const [pos, setPos] = useState(1);
useEffect(() => {
getGif();
}, [query])
const getGif = async () => {
const response = await fetch(`https://api.tenor.com/v1/search?q=${query}&key=${API_KEY}&limit=${limit}&pos=${pos}`);
const data = await response.json();
setGif(data.results);
console.log(data.results)
}
const updateSearch = e => {
setSearch(e.target.value);
}
const getSearch = e => {
e.preventDefault();
setQuery(search);
setSearch('');
}
const reload = () => {
setQuery('random')
}
const loadMore = () => { // this is where I want my Pos to update with 21 on first click 41 on second and so on
let temp = limit + 1 + pos;
setPos(temp);
setQuery(query);
}
return (
<div className="App">
<header className="header">
<h1 className="title" onClick={reload}>React GiF Finder</h1>
<form onSubmit={getSearch} className="search-from">
<input className="search-bar" type="text" value={search}
onChange={updateSearch} placeholder="type here..." />
<button className="search-button" type="submit">Search</button>
</form>
<p>showing results for <span>{query}</span></p>
</header>
<div className="gif">
{gifs.map(gif => (
<Gif
img={gif.media[0].tinygif.url}
key={gif.id}
/>
))}
</div>
<button className="load-button" onClick={loadMore}>Load more</button>
</div>
);
}
export default App;
Please, help me find, what I am doing wrong, As I know the moment I will update setQuery useEffect should be called with new input but its not happening.
Maybe try something like this:
// Fetch gifs initially and then any time
// the search changes.
useEffect(() => {
getGif().then(all => setGifs(all);
}, [query])
// If called without a position index, always load the
// initial list of items.
const getGif = async (position = 1) => {
const response = await fetch(`https://api.tenor.com/v1/search?q=${query}&key=${API_KEY}&limit=${limit}&pos=${position}`);
const data = await response.json();
return data.results;
}
// Append new gifs to existing list
const loadMore = () => {
let position = limit + 1 + pos;
setPos(position);
getGif(position).then(more => setGifs([...gifs, ...more]);
}
const getSearch = e => {
e.preventDefault();
setQuery(search);
setSearch('');
}
const updateSearch = e => setSearch(e.target.value);
const reload = () => setQuery('random');
Basically, have the getGifs method be a bit more generic and then if loadMore is called, get the next list of gifs from getGift and append to existing list of gifs.
I am making dummy app to test server side API.
First request returns nested JSON object with Product names and number of variants that it has. From there I extract Product name so I can send second request to fetch list of variants with product images, sizes etc.
Sometimes it will load and display variants from only one product but most of the times it will work correctly and load all variants from both dummy products.
Is there a better way of doing this to ensure it works consistently good. Also I would like to know if there is a better overall approach to write something like this.
Here is the code:
import React, { useEffect, useState } from "react";
import axios from "axios";
import ShirtList from "../components/ShirtList";
const recipeId = "15f09b5f-7a5c-458e-9c41-f09d6485940e";
const HomePage = props => {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
axios
.get(
`https://api.print.io/api/v/5/source/api/prpproducts/?recipeid=${recipeId}&page=1`
)
.then(response => {
let shirtList = [];
const itemsLength = response.data.Products.length;
response.data.Products.forEach((element, index) => {
axios
.get(
`https://api.print.io/api/v/5/source/api/prpvariants/?recipeid=${recipeId}&page=1&productName=${element.ProductName}`
)
.then(response => {
shirtList.push(response.data.Variants);
if (index === itemsLength - 1) {
setLoaded(shirtList);
}
});
});
});
}, []);
const ListItems = props => {
if (props.loaded) {
return loaded.map(item => <ShirtList items={item} />);
} else {
return null;
}
};
return (
<div>
<ListItems loaded={loaded} />
</div>
);
};
export default HomePage;
You are setting the loaded shirts after each iteration so you will only get the last resolved promise data, instead fetch all the data and then update the state.
Also, separate your state, one for the loading state and one for the data.
Option 1 using async/await
const recipeId = '15f09b5f-7a5c-458e-9c41-f09d6485940e'
const BASE_URL = 'https://api.print.io/api/v/5/source/api'
const fetchProducts = async () => {
const { data } = await axios.get(`${BASE_URL}/prpproducts/?recipeid=${recipeId}&page=1`)
return data.Products
}
const fetchShirts = async productName => {
const { data } = await axios.get(
`${BASE_URL}/prpvariants/?recipeid=${recipeId}&page=1&productName=${productName}`,
)
return data.Variants
}
const HomePage = props => {
const [isLoading, setIsLoading] = useState(false)
const [shirtList, setShirtList] = useState([])
useEffect(() => {
setIsLoading(true)
const fetchProductShirts = async () => {
const products = await fetchProducts()
const shirts = await Promise.all(
products.map(({ productName }) => fetchShirts(productName)),
)
setShirtList(shirts)
setIsLoading(false)
}
fetchProductShirts().catch(console.log)
}, [])
}
Option 2 using raw promises
const recipeId = '15f09b5f-7a5c-458e-9c41-f09d6485940e'
const BASE_URL = 'https://api.print.io/api/v/5/source/api'
const fetchProducts = () =>
axios.get(`${BASE_URL}/prpproducts/?recipeid=${recipeId}&page=1`)
.then(({ data }) => data.Products)
const fetchShirts = productName =>
axios
.get(
`${BASE_URL}/prpvariants/?recipeid=${recipeId}&page=1&productName=${productName}`,
)
.then(({ data }) => data.Variants)
const HomePage = props => {
const [isLoading, setIsLoading] = useState(false)
const [shirtList, setShirtList] = useState([])
useEffect(() => {
setIsLoading(true)
fetchProducts
.then(products) =>
Promise.all(products.map(({ productName }) => fetchShirts(productName))),
)
.then(setShirtList)
.catch(console.log)
.finally(() => setIsLoading(false)
}, [])
}
Now you have isLoading state for the loading state and shirtList for the data, you can render based on that like this
return (
<div>
{isLoading ? (
<span>loading...</span>
) : (
// always set a unique key when rendering a list.
// also rethink the prop names
shirtList.map(shirt => <ShirtList key={shirt.id} items={shirt} />)
)}
</div>
)
Refferences
Promise.all
Promise.prototype.finally
React key prop
The following should pass a flat array of all variants (for all products ) into setLoaded. I think this is what you want.
Once all the products have been retrieved, we map them to an array of promises for fetching the variants.
We use Promise.allSettled to wait for all the variants to be retrieved, and then we flatten the result into a single array.
useEffect(()=>(async()=>{
const ps = await getProducts(recipeId)
const variants = takeSuccessful(
await Promise.allSettled(
ps.map(({ProductName})=>getVariants({ recipeId, ProductName }))))
setLoaded(variants.flat())
})())
...and you will need utility functions something like these:
const takeSuccessful = (settledResponses)=>settledResponses.map(({status, value})=>status === 'fulfilled' && value)
const productURL = (recipeId)=>`https://api.print.io/api/v/5/source/api/prpproducts/?recipeid=${recipeId}&page=1`
const variantsURL = ({recipeId, productName})=>`https://api.print.io/api/v/5/source/api/prpvariants/?recipeid=${recipeId}&page=1&productName=${productName}`
const getProducts = async(recipeId)=>
(await axios.get(productURL(recipeId)))?.data?.Products
const getVariants = async({recipeId, productName})=>
(await axios.get(variantsURL({recipeId,productName})))?.data?.Variants