Search code examples
reactjsreact-hooksformikyuppapaparse

React + Formik + Papaparse: cannot access updated state value and instead get undefined


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.


Solution

  • Turns out my monkey brain had forgotten about stale states.

    • If you are running a code that is "browser-only", the component states will be stale values. This type of code should be inside useEffect().
    • If you have code that will actually render something to the DOM using the state, you will get the correct value there.

    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.