Async function overwrites array state when it should update only one item - javascript

I have a file upload component. The behavior is simple: I send one upload request to the back-end per file and as the upload progress increase, I have a bar that should increase with it.
I have a state that holds every selected file and their respective progress, as such:
interface IFiles {
file: File;
currentProgress: number;
}
const [selectedFiles, setSelectedFiles] = useState<IFiles[]>([]);
And when the user clicks the upload button, this function will be triggered and call uploadFile for each file in my array state.
const sendFilesHandler = async () => {
selectedFiles.map(async (file) => {
const fileType = file.file.type.split('/')[0];
const formData = new FormData();
formData.append(fileType, file.file);
formData.append('filename', file.file.name);
await uploadFile(formData, updateFileUploadProgress);
});
};
Here is what the uploadFile function looks like.
const uploadFile = async (body: FormData, setPercentage: (filename: string, progress: number) => void) => {
try {
const options = {
onUploadProgress: (progressEvent: ProgressEvent) => {
const { loaded, total } = progressEvent;
const percent = Math.floor((loaded * 100) / total);
const fileName = body.get('filename')!.toString();
if (percent <= 100) {
setPercentage(fileName, percent)
}
}
};
await axios.post(
"https://nestjs-upload.herokuapp.com/",
body,
options
);
} catch (error) {
console.log(error);
}
};
As you can see, when uploadProgress is triggered it should inform call setPercentage function, which is:
const updateFileUploadProgress = (fileName: string, progress: number) => {
console.log('Entrada', selectedFiles);
const currentObjectIndex = selectedFiles.findIndex((x) => fileName === x.file.name);
const newState = [...selectedFiles];
newState[currentObjectIndex] = {
...newState[currentObjectIndex],
currentProgress: progress,
};
setSelectedFiles(newState);
console.log('Saída', newState);
};
And this function should only update the object of my state array where the filenames match. However, it is overriding the whole thing. The behavior is as follows:
So it seems that everything is fine as long as I am updating the same object. But in the moment onUploadProgress is triggered to another object, selectedFiles states becomes its initial state again. What am I missing to make this work properly?

I am not sure what is the exact reason behind this behaviour but I came up with the below unexpectedly simple solution after spending 3 hours straight on it.
const updateFileUploadProgress = (fileName: string, progress: number) => {
setSelectedFiles(prevState => {
const currentObjectIndex = prevState.findIndex((x) => fileName === x.file.name);
const newState = [...prevState];
newState[currentObjectIndex] = {
...newState[currentObjectIndex],
currentProgress: progress,
};
return newState;
})
};
I was able to mimic your problem locally, and solved it with the above approach. I think it was because the function (for some reason) still referenced the initial state even after rerenders.

Related

How implement types properly?

I'm new to Typescript and have been doing a refactor a colleague code, I'm currently doing a typecheck and removing all any types. The goal is to make an MSGraph API call and return the a JSON file that translated into BirthdayPerson with a name, birthday date and a ID
I've been trying to a assign a type instead of any in the following code, but whether I assign number, string or any other type a different error will show up.
Perhaps I'm not tackling the solution correctly:
graph.ts
* #param accessToken
* #param endpoint url to call from MS Graph
*/
async function callMsGraph(accessToken: string, endpoint: string) {
const headers = new Headers();
const bearer = `Bearer ${accessToken}`;
headers.append('Authorization', bearer);
const options = {
method: 'GET',
headers,
};
try {
return fetch(endpoint, options);
} catch (error) {
console.error(error);
throw error;
}
}
export const callMsGraphWithoutPagination = async (
accessToken: string,
url: string,
dataToReturn: any[] = []
): Promise<any[]> => {
try {
const data = await callMsGraph(accessToken, url);
const dataJson = await data.json();
const newData = dataToReturn.concat(dataJson.value);
if (dataJson['#odata.nextLink']) {
const NEXT_URL = dataJson['#odata.nextLink'].split('/v1.0')[1];
return await callMsGraphWithoutPagination(
accessToken,
process.env.REACT_APP_GRAPH_URL + NEXT_URL,
newData
);
}
return dataToReturn.concat(dataJson.value);
} catch (error) {
/* eslint-disable no-console */
console.error(error);
/* eslint-enable no-console */
throw error;
}
};
export default callMsGraph;
useUsers.tsx
export const useUsers = () => {
const token = useToken();
const [users, setUsers] = React.useState<BirthdayPerson[]>([]);
React.useEffect(() => {
if (token) {
callMsGraphWithoutPagination(token, graphConfig.usersEndpoint).then(async (data: any) => {
const processedData: any[] = await Promise.all(
data.map(async (element: any) => {
const user = await callMsGraph(token, graphConfig.userBirthdayEndpoint(element.id));
const userJson = await user.json();
const image = await callMsGraph(token, graphConfig.userPhotoEndpoint(element.id));
const blob = await image.blob();
const returnElement: BirthdayPerson = {
displayName: element.displayName,
birthday: userJson.value,
id: element.id,
};
if (blob !== null) {
window.URL = window.URL || window.webkitURL;
returnElement.picture = window.URL.createObjectURL(blob);
}
return returnElement;
})
);
setUsers([].concat(...processedData));
});
}
}, [token]);
return users;
};
helpers.ts
interface IUpdateData {
slideCount: number;
}
const sortAndFilterBirthdays = (people: BirthdayPerson[], daysToGet: number) =>
people
.sort((firstEl, secondEl) => sortDate(firstEl.birthday, secondEl.birthday))
.filter(({ birthday }) => filterByAmountOfDays({ date: birthday, daysAfter: daysToGet }));
const getBirthdays: any = (people: BirthdayPerson[], daysToGet: number) => {
const validBirthdays = people.filter((element: any) => {
const year = moment(element.birthday).year();
return year !== 0;
});
const result = sortAndFilterBirthdays(validBirthdays, daysToGet);
// if it's okay
if (result.length > 1 && daysToGet <= 30) {
return result;
}
// if not okay, filters by future dates, concats with 'next year' dates, returns 2 dates
const fallbackResult = validBirthdays
.sort((firstEl, secondEl) => sortDate(firstEl.birthday, secondEl.birthday))
.filter((person: BirthdayPerson) => {
const currentYear = moment().year();
const date = moment(person.birthday, DATE_FORMAT).set('years', currentYear);
return moment().diff(date, 'days') <= 0;
});
return fallbackResult.concat(validBirthdays).splice(0, 2);
};
Any help or indication would be great!
From all the changes I've done another object will complain that Type 'x' is not assignable to type 'string'
Firstly you need to somehow type responses from API, because right now, as you have seen, call to .json() on Response object returns unknown, which make sense because no one knows what response the server returns. You may know what response it is expected to return, but not what it actually does.
Ideally therefore you need a parser that will verify that the response has correct structure and throws an error otherwise. There are libraries such as superstruct, yup, joi and others which you can use for this. Of course this is a lot of work and will need refactoring. Or if you don't care enough you can just cast the response object to appropriate type, but then if server returns something unexpected and the application cannot handle it, it's your fault.
Example with response parsing using superstruct
import {string, number, create, Infer} from 'superstruct'
// I assume `BirthdayUser` is just a string, but you can type more complex objects as well
const BirthdayUser = string()
// This is so that you don't have to list fields twice: once
// for the parser and once for typescript
type BirthdayUser = Infer<typeof BirthdayUser>
// Then use the parser
const response = await callMsGraph(acessToken, url)
const userJson = await response.json()
// user variable has inferred appropriate type, and if the response doesn't
// comply with the definition of `BirthdayUser` an error will be thrown
// Also I assume MS graph doesn't just return plain value but wraps it in an object with `value` field, so writing it here
const user = create(userJson, object({ value: BirthdayUser }))
Example of "I don't care enough" solution
type BirthdayUser = string
const response = await callMsGraph(accessToken, url)
// Same thing with wrapping into object with `value` field
const userJson = (await response.json()) as {value: BirthdayUser}
This is a bit awkward, because API call returns Response object and not the actual data. It might be easier to work with if you move parsing and casting logic inside of callMsGraph. It's not obligatory of course, but I still provide an example because after that you need to type callMsGraphWithoutPagination and it will use the same idea
import {object, create, Struct} from 'superstruct'
async function callMsGraphParsed<T>(
accessToken: string,
url: string,
// Since we need information about object structure at runtime, just making function
// generic is not enough, you need to pass the parser structure as an argument
struct: Struct<T>
) {
// ...
const response = await fetch(...)
const json = await response.json()
// Same, verifies that response complies to provided structure, if it
// does returns type object (of type `T`), otherwise throws an error
return create(json, object({ value: struct }))
}
async function callMsGraphLazy<T>(accessToken: string, url: string) {
// ...
const response = await fetch(...)
const json = await response.json()
return json as {value: T}
}
However I only call .json() here, if you want to use this solution, you will then need either a different function or another argument if you also want it to call .blob() for some API calls.
Now you type callMsGraphWithoutPagination using in the same way:
export const callMsGraphWithoutPaginationParsed = async <T>(
accessToken: string,
url: string,
dataToReturn: T[] = [],
struct: Struct<T>,
): Promise<T[]> => {
// If you rewrote `callMsGraph` to use parsing
const dataJson = await callMsGraph(accessToken, url, struct);
const newData = dataToReturn.concat(dataJson.value);
// ...
}
export const callMsGraphWithoutPaginationLazy= async <T>(
accessToken: string,
url: string,
dataToReturn: T[] = [],
): Promise<T[]> => {
// If you left `callMsGraph` as is
const data = await callMsGraph(accessToken, url);
const dataJson = (await data.json()) as {value: T}
const newData = dataToReturn.concat(dataJson.value);
// ...
}
Then use it
// Not sure if you are requesting `BirthdayUser` array here or some other entity, so change it to whatever you expect to receive
callMsGraphWithoutPagination<BirthdayUser>(token, graphConfig.usersEndpoint).then(async (data) => {
// "data" is inferred to have type BirthdayUser[]
data.map(element => {
// "element" is inferred to have type BirthdayUser
})
})
Also everywhere I wrote "I assume" and "Not sure" is missing info that you should probably have provided in the question. It didn't turn out to be relevant for me, but it could have. Good luck!

Cache manegment in recoil js

I decided to try using recoil instead of redux and ran into the problem of caching mutable collections. To be more precise, the problem is not in the response caching itself, but in the further data management, without re-requesting. For example, I requested an array of free time slots that will be displayed in the calendar, the user can change them (unblock or block). After the request (unblock or block) I can use useRecoilRefresher_UNSTABLE to reset the selector cache and request updated slots again, this works as expected, but why do this if you can remember the result after the first request and then just update it. My code is:
export const _cacheFreeSlotsA = atom<SlotsArray | null>({
key: '_cacheFreeSlotsA',
default: null,
});
export const freeSlotsS = selector<SlotsArray>({
key: 'freeSlotsS',
get: async ({ get }) => {
const cache = get(_cacheFreeSlotsA);
if (cache) {
return cache;
}
const res = await slotsAPI.getFreeSlots();
return res.slots;
},
});
export const useUpdateFreeSlotsS = () => {
const setFreeSlots = useSetRecoilState(_cacheFreeSlotsA);
const currentSlots = useRecoilValue(freeSlotsS);
return async (req: ChangeSlotsRequest) => {
await slotsAPI.changeSlots(req);
switch (req.operation) {
case 'open':
setFreeSlots([...currentSlots, ...req.slots]);
break;
case 'close':
setFreeSlots(difference(currentSlots, req.slots));
break;
}
};
};
// export const useUpdateFreeSlotsS = () => {
// const refresh = useRecoilRefresher_UNSTABLE(freeSlotsS);
// return async (req: ChangeSlotsRequest) => {
// await slotsAPI.changeSlots(req);
// refresh();
// };
// };
It works but look like workaround. Is there a more elegant and clear way to implement this behavior? (it would be just perfect if inside the freeSlotsS get method I could get access to the set method, but unfortunately it is not in the arguments)

React Dropzone - async function in onDrop callback doesn't update state

I'm trying to use react-dropzone onDrop with useCallback function to update the array of files dropped onto the area and trigger the upload. My code looks like this:
const [files, setFiles] = useState([]);
...
const onDrop = useCallback((acceptedFiles) => {
setFiles(acceptedFiles);
handleFileUpload();
}, []);
const {
getRootProps,
getInputProps,
isDragActive,
} = useDropzone({ onDrop });
handleFileUpload is an asynchronous function, which uses Fetch API. When I try to log the files array inside this method, it appears to be empty, even though I updated the state before running this function.
const handleFileUpload = async () => {
console.log(files); // <- returns empty array
}
I also tried to set different variables to indicate if the upload has started, etc. but changing the values within this method doesn't update the state of my component at all. Is it even possible to use an async function in a callback like this? Should I trigger the file upload somewhere else? I feel like I don't quite understand the concept here.
Your state will not update until after your code exits and/or relinquishes control. i.e. after you call setFiles() the files variable will still be STALE until the code returns to the event loop.
Just pass the accepted files into the function.
const onDrop = useCallback((acceptedFiles) => {
setFiles(acceptedFiles); // <-- that won't update right away
handleFileUpload(acceptedFiles);
}, []);
UPDATE
Here are snippets from one of my projects. I just stuff the dropped file into state, as you do. That causes the component to re-render, and when it does, I catch the updated file state in a hook, async parse the file, and return the data - which is then consumed by the component. The component shows UI states - importing if fileToImport is set and dataToImport is not yet available, etc...
// MyComponent.tsx
const MyComponent = () => {
const [fileToImport, setFileToImport] = useState<File | undefined>()
const [dataToImport, dataToImportError] = useReadFileData(fileToImport)
const onDrop = useCallback(acceptedFiles => {
setFileToImport(acceptedFiles[0])
}, [])
//useREadFileData.ts hook
import { useEffect, useState } from 'react'
const useReadFileData = (file: File | undefined): [any[], string | undefined] => {
const [rows, setRows] = useState<unknown[]>([])
const [error, setError] = useState<string>()
useEffect(() => {
async function parseFile(file: File) {
try {
// snip
setRows(rows)
} catch (error: any) {
if (typeof error === "object") {
setError(error?.message ?? "Error parsing file")
}
}
}
// only if we have a file to parse
if (file) {
parseFile(file)
}
}, [file, setError, setRows])
return [rows, error]
}
export default useReadFileData

Context is undefined in translation module

I tried to add a call to an endpoint in order to get translation. I have like this :
const loadLocales = async () => {
const context = require.context('./locales', true);
const data = await ApiService.post(`${translationToolUrl}/gateway/translations`, { project: 'myProject' });
const messages = context.keys()
.map((key) => ({ key, locale: key.match(/[-a-z0-9_]+/i)[0] }))
.reduce((msgs, { key, locale }) => ({
...msgs,
[locale]: extendMessages(context(key)),
}), {});
return { context, messages };
};
const { context, messages } = loadLocales();
i18n = new VueI18n({
locale: 'en',
fallbackLocale: 'en',
silentFallbackWarn: true,
messages,
});
if (module.hot) {
module.hot.accept(context.id, () => {
const { messages: newMessages } = loadLocales();
Object.keys(newMessages)
.filter((locale) => messages[locale] !== extendMessages(newMessages[locale]))
.forEach((locale) => {
const msgs = extendMessages(newMessages[locale]);
messages[locale] = msgs;
i18n.setLocaleMessage(locale, msgs);
});
});
}
I added this request : ApiService.post. But I have the error TypeError: context is undefined droped at this line module.hot.accept(context.id.... Have you an idea how I can solve that ? My scope was to add this request in order to get translations from database and from .json files for now. I want to do a merge between both for now, in the feature I will get only from database but this will be done step by step.
The problem is, that you trying to declare multiple const in the wrong way, independently of trying to declaring them twice. This shows in:
const { context, messages } = loadLocales();
This would couse context and messages to be undefined. This won´t give an error, as I replicated in a small example:
const {first, second} = 'Testing'
console.log(first)
console.log(second)
Both, first and second, will be undefined. If you try to declare multiple const at once, you need to do it this way:
const context = loadLocales(), messages = loadLocales();

How to properly map data from multiple nested queries into desired object. React/Firebase

I am trying to map out my schema from Firebase Firestore. I used Flamelink as a headless CMS to make things easier, however I am facing a limitation.
The image storage location is separate from the data that i'm pulling in from my schema. To remedy this I created some nested Queries to find the correct path of the storage location and generate the URL for the image. I need that image URL to be placed back into the respected object somewhere.
Currently it's just adding it to the overall array not the objects already within it. The first console log is the file reference in the correct order, the second console log is the correct URL's I need into the original object. However, these seem to not be in the correct order. I am fairly new to react and ES6 so I could be over complicating things. Essentially I just want the output of the second Console.log into my ThumbnailURL position for each object in the respective place.
I have tried to just create a new Array of objects and map them in my front end, however that causes issues because they are not in the same object.
const storage = firebase.storage().ref('flamelink/media/sized/240/')
class ContentUpdates extends React.Component {
constructor(){
super();
this.ref = firebase.firestore().collection('fl_content').where('_fl_meta_.schema', '==', 'collateral');
this.unsubscribe = null;
this.state = {
collateral: [],
}
}
onCollectionUpdate = (querySnapshot) => {
const collateral = [];
querySnapshot.forEach((doc) => {
const { name, image, thumbnail, thumbnailURL, img} = doc.data();
collateral.push({
key: doc.id,
doc, // DocumentSnapshot
name,
image,
img,
thumbnail: image[0].id,
thumbnailURL: firebase.firestore().collection(`fl_files`).where(`id`, '==', `${image[0].id}`).get().then((querySnapshot) => {
querySnapshot.forEach((doc) => {
const { file } = doc.data();
console.log('this is in the correct order', `${file}`);
storage.child(`${file}`).getDownloadURL().then((url) => {
const fileurl = url
console.log('this is the value I need mapped to thumbnailURL', fileurl)
collateral.push({thumbnailURL: fileurl})
})
})
})
});
});
this.setState({
collateral
});
}
componentDidMount() {
this.unsubscribe = this.ref.onSnapshot(this.onCollectionUpdate);
}
I want each objected in collateral to go through the necessary queries to return the fileurl back to it's respected collateral object.
UPDATE: I cleaned up the function a bit. Issue now is that it's going through two loops and not returning the values but only a promise. Very close to solving it however. Will update soon.
onCollectionUpdate = (querySnapshot) => {
const collateral = [];
querySnapshot.forEach((doc) => {
const { name, image, thumbnail, thumbnailURL, img} = doc.data();
const thumbnailImg = this.getThumbnailUrl(image[0].id);
let obj = {
key: doc.id,
doc, // DocumentSnapshot
name,
image,
img,
thumbnail: image[0].id,
thumbnailURL: thumbnailImg
}
collateral.push(obj);
});
this.setState({
collateral
});
}
async getThumbnailUrl(imgRef) {
console.log('your in', imgRef)
let snapshot = await firebase.firestore().collection(`fl_files`).where(`id`, '==', imgRef).get();
console.log(snapshot)
let snapVal = snapshot.forEach( async(doc) => {
const { file } = doc.data();
console.log('this is in the correct order', `${file}`);
let downloadURL = await storage.child(`${file}`).getDownloadURL()
console.log('this is the value I need mapped to thumbnailURL', downloadURL)
return downloadURL;
})
return snapVal;
}
componentDidMount() {
this.unsubscribe = this.ref.onSnapshot(this.onCollectionUpdate);
}

Categories

Resources