I am trying to create a multi-step form using React + Formik + Yup, I am done with most of the logic but this one part where I am supposed to process a csv file. I created a separate CSVFileUploadField
component and used the useField()
hook of Formik
.
Formik nor Yup validate file data. I need to first ensure that the csv file uploaded by the user is valid and does not contain any weird formatting issues. I am using papaparse
to parse the file, and I am able to detect errors and update the states. I intend to use this error field to determine whether the file can be validated further (e.g. checking for the number of rows and columns), but the csvFileError
state is always undefined. Any hints/help is much appreciated.
CSVFileUploadField.tsx
import { useField } from "formik";
import React, { InputHTMLAttributes, useEffect, useRef, useState } from "react";
import Papa from "papaparse";
/* ...truncated import */
function CSVFileUploadField({ color: _, ...props }: TCSVFileUploadField) {
const [field, { error, value }, { setValue, setError }] = useField(props);
const dispatch: Dispatch = useDispatch();
const [csvFileError, setCsvFileError] = useState<string>(undefined);
const [fileName, setFileName] = useState<string>("");
const [csvData, setCsvData] = useState<unknown[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const classes = useStyle();
// if the user goes to another part of the form and comes back, need to reinitialize the filename
useEffect(() => {
setFileName((previousValue) => (value ? value.name : "No file selected"));
}, [value]);
const synchronizeErrors = (message: string) => {
setError(message); // form field error, but this gets overriden by further yup validation
setCsvFileError((prevMessage) => message); // component state
dispatch(taskFileErrors(message)); // redux state which is used to disable Next/Submit button in case of error
};
const processChunk = function ({ data, errors, meta }, parser) {
// this is supposed to callback after reading every 1 MB data
if (csvFileError) {
parser.abort();
}
if (errors.length > 0) {
// just use the first parsing error
synchronizeErrors(errors[0].message);
} else {
setCsvData((previousData) => {
let newData = previousData;
data.forEach((row) => newData.push(row));
return newData;
});
}
};
const parseCsv = (file: File) => {
const papaParseOptions: Papa.ParseConfig = {
chunkSize: 1024 * 1024 * 1, // 1 MB
chunk: processChunk,
};
Papa.parse(file, papaParseOptions);
};
const handleFileChange = function (
event: React.ChangeEvent<HTMLInputElement>,
) {
const fileObj = event.target.files[0];
if (fileObj) {
// // clear any previous errors
synchronizeErrors(undefined);
setValue(fileObj);
setFileName((previousName) => fileObj.name);
parseCsv(fileObj); // this is supposed to update the error states
console.log(csvFileError); // <-- this is always undefined, even with setTimeout(() => console.log(csvFileError), 5000)
}
};
return (
<FormControl error={!!error} className={classes.formControl}>
<FormLabel htmlFor={field.name}>{props.label}</FormLabel>
<input
name={field.name}
onChange={handleFileChange}
{...props}
accept={props.accept || ".csv, .tsv"}
id={field.name}
type="file"
ref={fileInputRef}
style={{ display: "none" }}
/>
<Box mt={2} display="flex" alignItems="center">
<Button
onClick={(_) => {
fileInputRef.current.click();
}}
className={classes.button}
>
Choose CSV File
</Button>
<Typography className={classes.fileNameLabel}>{fileName}</Typography>
</Box>
{(error || csvFileError) && (
<FormHelperText error={!!error || !!csvFileError}>
{error || csvFileError}
</FormHelperText>
)}
</FormControl>
);
}
export default CSVFileUploadField;
EDIT:
I created a simplified codesandbox demo here. Look into the console after uploading a csv file that is malformed. The error is rendered as HTML but it is undefined in the console. I want to use the error to decide whether I want to further validate this file against some other condition.
Turns out my monkey brain had forgotten about stale states.
useEffect()
.In my case, parsing the CSV file is a browser only task. So, I had to change it up a bit.
function CSVFileUploadField({ color: _, ...props }: TCSVFileUploadField) {
const [field, { error, value }, { setValue, setError }] = useField(props);
const dispatch: Dispatch = useDispatch();
const [csvFileError, setCsvFileError] = useState<string>(undefined);
const [fileName, setFileName] = useState<string>("");
const [fileReading, setFileReading] = useState<boolean>(false);
const [numRows, setNumRows] = useState<number>(0);
const [numCols, setNumCols] = useState<number>(0);
const [progress, setProgress] = useState<number>(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const classes = useStyle();
useEffect(() => {
setFileName((previousValue) => (value ? value.name : "No file selected"));
}, [value]);
useEffect(() => {
// once file reading has ended, reset progress
if (!fileReading) {
setProgress(0);
}
// once reading is done we have concrete values
if (value && numRows > 0 && numCols > 0 && !error && !csvFileError) {
// no errors, send the row and column numbers to redux to use them in the next step of form
dispatch(taskFileSelected(numRows, numCols));
}
}, [fileReading]);
const synchronizeErrors = (message: string) => {
setError(message); // form field error, but this gets overriden by further yup validation
setCsvFileError((prevMessage) => message); // component state
dispatch(taskFileErrors(message)); // redux state which is used to disable Next/Submit button in case of error
};
const parseCsv = (fileObj: File) => {
const fileSize = fileObj.size;
const parseConfig: Papa.ParseConfig = {
worker: true, // use worker thread
chunkSize: 1024 * 1024 * 15, // 15 MB
chunk: ({ data, errors, meta }, parser) => {
if (errors.length > 0) {
synchronizeErrors(errors[0].message);
} else {
setNumRows((prevValue) => prevValue + data.length);
setNumCols(data[0].length);
setProgress((prevValue) =>
Math.round((meta.cursor / fileSize) * 100),
);
}
},
complete: (results, fileObj) => {
setFileReading((prevValue) => false);
},
};
// when using worker, parse function is async
// https://www.papaparse.com/faq#workers
Papa.parse(fileObj, parseConfig);
};
const handleFileChange = function (
event: React.ChangeEvent<HTMLInputElement>,
) {
const fileObj = event.target.files[0];
if (fileObj) {
// clear any previous errors
synchronizeErrors(undefined);
// reset state
setNumCols(0);
setNumRows(0);
setValue(fileObj);
setFileName((previousName) => fileObj.name);
setFileReading((prevValue) => true);
parseCsv(fileObj);
}
};
return (
<FormControl error={!!error} className={classes.formControl}>
<FormLabel htmlFor={field.name}>{props.label}</FormLabel>
<input
name={field.name}
onChange={handleFileChange}
{...props}
accept={props.accept || ".csv, .tsv"}
id={field.name}
type="file"
ref={fileInputRef}
style={{ display: "none" }}
/>
<Box mt={2} display="flex" alignItems="center">
<Button
onClick={(_) => {
fileInputRef.current.click();
}}
className={classes.button}
>
Choose CSV File
</Button>
<Typography className={classes.fileNameLabel}>{fileName}</Typography>
</Box>
{fileReading && <LinearProgressBarWithLabel value={progress} />}
{(error || csvFileError) && (
<FormHelperText error={!!error || !!csvFileError}>
{error || csvFileError}
</FormHelperText>
)}
</FormControl>
);
}
export default CSVFileUploadField;
I ended up adding a Progress Bar too, so, yay!.
On a separate note, I will probably end up refactoring this with useReducer()
, simply because there are too many interrelated states.