Alex Aung
Alex Aung

Reputation: 3159

React Hook Form not detecting changes in File input field

I'm using react-hook-form in my React application to manage form state, and I'm having trouble with a File input MuiFileInput. When a user selects a file, the form doesn't recognize that the file has changed, so the "Save" button remains disabled.

Here is the relevant part of my form:

/* eslint-disable no-console */
import { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import _ from 'lodash';
import { Controller, useForm } from 'react-hook-form';
import Box from '@mui/system/Box';
import FuseLoading from '@fuse/core/FuseLoading';
import FuseSvgIcon from '@fuse/core/FuseSvgIcon';
import { Button, CircularProgress } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { useQuery, useMutation } from '@apollo/client';
import { sanitizeInputAgainstDefaults, urlToFile } from 'app/utils';
import { SUBMISSION_STATUS, SUPPORTED_SONG_FORMAT } from 'app/constants';
import { Song, SongQueryResponse, CreateOneSongResponse, UpdateOneSongResponse } from 'app/types';
import history from '@history';
import { yupResolver } from '@hookform/resolvers/yup';
import { useAppSelector } from 'app/store/hooks';
import { selectUser } from 'src/app/auth/user/store/userSlice';
import { showMessage } from '@fuse/core/FuseMessage/fuseMessageSlice';
import { MuiFileInput } from 'mui-file-input';
import { CREATE_SONG, DELETE_SONG, UPDATE_SONG } from '../../../../graphql/mutations';
import { GET_SONG, GET_SONG_VARIABLES, GET_SONGS, GET_SONGS_VARIABLES } from '../../../../graphql/queries';
import SongModel from './models/SongModel';
import { schema } from './models/schema';

export default function SongForm() {
    const navigate = useNavigate();
    const dispatch = useDispatch();
    const { artistId, albumId, songId } = useParams();
    const { t } = useTranslation('songsPage');
    const handleNavigation = () => navigate(`/apps/songs/${artistId}/${albumId}`);

    const user = useAppSelector(selectUser);

    const isNewForm = songId === 'new';

    const {
        data: songData,
        loading: songLoading,
        error
    } = useQuery<SongQueryResponse>(GET_SONG, {
        variables: GET_SONG_VARIABLES(songId),
        skip: isNewForm
    });

    const [submissionStatus, setSubmissionStatus] = useState<
        (typeof SUBMISSION_STATUS)[keyof typeof SUBMISSION_STATUS]
    >(SUBMISSION_STATUS.IDLE);

    const [files, setFiles] = useState({
        mp3FileUrl: {
            originalMP3URL: '',
            originalHLSURL: '',
            selectedFile: null as File | null,
            hasChanged: false
        }
    });

    const fileMappings = [
        {
            key: 'mp3FileUrl',
            formMP3Key: 'mp3_file_url',
            formHLSKey: 'hls_url',
            folder: 'songs'
        }
    ];

    const { control, watch, reset, handleSubmit, formState, trigger, setValue } = useForm<Song>({
        mode: 'all',
        resolver: yupResolver(schema)
    });

    const { isDirty, isValid, dirtyFields, errors } = formState;
    const form = watch();

    useEffect(() => {
        const fetchDataAndSetState = async () => {
            if (isNewForm) {
                reset(SongModel({}));
            } else if (!songLoading && songData && songData.song) {
                const sanitizedInput = sanitizeInputAgainstDefaults(songData.song, SongModel({}));

                setFilesFromSanitizedInput(sanitizedInput);

                if (songData.song.mp3_file_url) {
                    const file = await urlToFile(songData.song.mp3_file_url);
                    console.log('Converted File:', file); // Log the file details

                    sanitizedInput.file = file;
                }

                // setSong(sanitizedInput);
                reset({ ...sanitizedInput });
                // trigger();
            }
        };

        const setFilesFromSanitizedInput = (input: Partial<Song>) => {
            setFiles((prevFiles) => ({
                ...prevFiles,
                mp3FileUrl: {
                    ...prevFiles.mp3FileUrl,
                    originalMP3URL: input.mp3_file_url || '',
                    originalHLSURL: input.hls_url || ''
                }
            }));
        };

        fetchDataAndSetState();
    }, [isNewForm, songId, songLoading, songData, reset]);

    const onSubmit = async () => {};

    const handleRemoveSong = async () => {};

    if (error && songId !== 'new') {
        setTimeout(() => {
            handleNavigation();
            dispatch(showMessage({ message: 'NOT FOUND' }));
        }, 0);

        return null;
    }

    if (_.isEmpty(form) || (!isNewForm && songLoading)) {
        return <FuseLoading className="min-h-screen" />;
    }

    const handleChange = async (file: File) => {
        setFiles((prevFiles) => ({
            ...prevFiles,
            mp3FileUrl: {
                ...prevFiles.mp3FileUrl,
                selectedFile: file || null,
                hasChanged: true
            }
        }));
        setValue('file', file, { shouldValidate: true, shouldDirty: true, shouldTouch: true });

        await trigger();
    };

    return (
        <>
            <div className="relative flex flex-col flex-auto items-center px-24 sm:px-48">
                <Controller
                    name="file"
                    control={control}
                    render={({ field, fieldState }) => (
                        <MuiFileInput
                            className="mt-32"
                            {...field}
                            label={t('FILE_URL_LABEL')}
                            placeholder={t('FILE_URL_PLACEHOLDER')}
                            variant="outlined"
                            required
                            fullWidth
                            helperText={fieldState.error ? fieldState.error.message : 'Accepted formats: .mp3'}
                            error={fieldState.invalid}
                            inputProps={{
                                accept: SUPPORTED_SONG_FORMAT
                            }}
                            onChange={handleChange}
                        />
                    )}
                />
            </div>

            <Box
                className="flex items-center mt-40 py-14 pr-16 pl-4 sm:pr-48 sm:pl-36 border-t"
                sx={{ backgroundColor: 'background.default' }}
            >
                {songId !== 'new' && (
                    <Button
                        color="error"
                        onClick={handleRemoveSong}
                        disabled={submissionStatus === SUBMISSION_STATUS.SUBMITTING}
                    >
                        {t('DELETE')}
                    </Button>
                )}
                <Button
                    className="ml-auto hover:bg-gray-400"
                    variant="contained"
                    onClick={() => history.back()}
                    disabled={submissionStatus === SUBMISSION_STATUS.SUBMITTING}
                    startIcon={<FuseSvgIcon>heroicons-outline:x-circle</FuseSvgIcon>}
                >
                    {t('CANCEL')}
                </Button>
                <Button
                    className="ml-8"
                    variant="contained"
                    color="secondary"
                    disabled={_.isEmpty(dirtyFields) || !isValid || submissionStatus === SUBMISSION_STATUS.SUBMITTING}
                    onClick={handleSubmit(onSubmit)}
                >
                    {submissionStatus === SUBMISSION_STATUS.SUBMITTING ? (
                        <>
                            <CircularProgress
                                size={24}
                                className="text-secondary mr-10"
                            />

                            <span className="text-black">{t('SUBMITTING')}</span>
                        </>
                    ) : (
                        <>
                            <FuseSvgIcon className="mr-10">heroicons-outline:save</FuseSvgIcon>
                            <span>{t('SAVE')}</span>
                        </>
                    )}
                </Button>
            </Box>
        </>
    );
}

yup schema

import { SUPPORTED_SONG_FORMAT } from 'app/constants';
import * as yup from 'yup';

export const schema = yup.object().shape({
    title: yup.string().required('Title is required'),
    description: yup.string().optional(),
    sort_order: yup.number().positive().integer().required(),
    file: yup
        .mixed<File>()
        .required('File is required')
        .test('fileFormat', 'Unsupported Format', (value) => {
            if (value instanceof File) {
                return SUPPORTED_SONG_FORMAT.includes(value.type);
            }

            return false; // Fails validation if value is not a File
        }),
    active: yup.boolean().optional()
});

Problem:

When the user selects a new file, the File object is correctly updated in the form values. However, the Save button remains disabled because react-hook-form doesn't recognize the change in the File field. I believe this is due to how react-hook-form performs shallow comparisons, and since File is a reference type, it may not detect the change.

What I've Tried:

Using setValue with shouldValidate and shouldDirty options. Manually triggering validation with trigger('file').

Question:

Why is react-hook-form not detecting changes in the File input field, and how can I ensure that the form recognizes when a file has been selected or changed, enabling the "Save" button when the form is valid?

Upvotes: 1

Views: 75

Answers (1)

cgontijo
cgontijo

Reputation: 536

I believe the issue is due to how you are updating the file field.

Instead of using setValue in the handleChange function, try using the field.onChange handler provided by the Controller.

Example:

    const handleChange = async (file: File) => {
        setFiles((prevFiles) => ({
            ...prevFiles,
            mp3FileUrl: {
                ...prevFiles.mp3FileUrl,
                selectedFile: file || null,
                hasChanged: true
            }
        }));
    };

                        <MuiFileInput
                            className="mt-32"
                            {...field}
                            label={t('FILE_URL_LABEL')}
                            placeholder={t('FILE_URL_PLACEHOLDER')}
                            variant="outlined"
                            required
                            fullWidth
                            helperText={fieldState.error ? fieldState.error.message : 'Accepted formats: .mp3'}
                            error={fieldState.invalid}
                            inputProps={{
                                accept: SUPPORTED_SONG_FORMAT
                            }}
                            onChange={(file) => {
                              handleChange(file)
                              field.onChange(file)
                            }}
                        />

Upvotes: 0

Related Questions