I am building a file upload page in NextJs.
I Utilised the useActionState
alongside initialState
.
The issue
If a validation error happend in the backend after submitting, the data retrurned to the frontend except the files. so the user have to re-select them all.
To avoid this behaviour i saved the files in a state (not anymore in the input itslef), this was the best approach since i can’t re-assign the data to the input field of type file.
However i don’t know how to attach them to the request going to the serverAction.
What did I try
I have tried to do it like this:
const handleFormSubmit = (e: React.FormEvent) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
files.forEach((file) => formData.append("proof", file));
formAction(formData);
};
But i get this error after submitting, even tho everything works as expected:
An async function was passed to useActionState, but it was dispatched outside of an action context. This is likely not what you intended. Either pass the dispatch function to an `action` prop, or dispatch manually inside `startTransition`
I have tried to understand this error, but wasn’t able to.
Goal / Expectation
-
As mentioned above, i want to keep the files until they are successfully uploaded to the cloud.
-
On success i want to reset the states and remove the files from the state.
My Component
"use client";
import { useActionState, useCallback, useEffect } from "react";
import submit from "./action";
import { twJoin } from "tailwind-merge";
import Main from "@/components/Main";
import { useLocale, useTranslations } from "next-intl";
import Dropzone from "./Dropzone";
import PageHeader from "@/components/PageHeader";
import HugeRoundedButton from "@/components/RoundedButton";
import { useState } from "react";
import { FileRejection } from "react-dropzone";
const initialState = {
zodErrors: null,
message: null,
success: false,
data: {
summary: "",
proof: [],
note: "",
},
};
interface PreviewFile extends File {
preview: string;
}
const CostTracker = () => {
// States
const [formState, formAction, pending] = useActionState(submit, initialState);
const [files, setFiles] = useState<PreviewFile[]>([]);
const [rejectedFiles, setRejectedFiles] = useState<FileRejection[]>([]);
const onDrop = useCallback(
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
if (acceptedFiles.length > 0) {
setFiles((previousFiles) => [
...previousFiles,
...acceptedFiles.map((file) =>
Object.assign(file, {
preview: URL.createObjectURL(file),
})
),
]);
}
if (rejectedFiles.length > 0) {
setRejectedFiles((previousFiles) => [
...previousFiles,
...rejectedFiles,
]);
}
},
[]
);
const removeFile = (name: string) => {
// Revoke the data uris to avoid memory leaks
URL.revokeObjectURL(
files.find((file) => file.name === name)?.preview as string
);
setFiles((files) => files.filter((file) => file.name !== name));
};
const removeRejectedFile = (name: string) => {
setRejectedFiles((rejectedFiles) =>
rejectedFiles.filter(({ file }) => file.name !== name)
);
};
// Runs when the component unmounts
useEffect(() => {
// Make sure to revoke the data uris to avoid memory leaks, will run on unmount
return () => files.forEach((file) => URL.revokeObjectURL(file.preview));
}, [files]);
// Locale
const locale = useLocale();
const translations = useTranslations("cost_tracking_page");
const dir = locale === "ar" ? "rtl" : "ltr";
// Form State
const { data, zodErrors, message, success } = formState;
// Handle form submission
const handleFormSubmit = (e: React.FormEvent) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
files.forEach((file) => formData.append("proof", file));
formAction(formData);
};
return (
<Main dir={dir} className="flex-grow gap-6">
<PageHeader
title={translations("title")}
subTitle={translations("subTitle")}
/>
<form
onSubmit={handleFormSubmit}
// action={formAction}
className="flex flex-col gap-8 bg-primary rounded-3xl px-4 py-8"
dir={dir}
>
<div className="flex gap-2 flex-col">
<input
step=".01"
name="summary"
type="number"
defaultValue={data?.summary}
placeholder={translations("summary_placeholder")}
className="outline-none p-3 border border-secondary bg-background text-foreground w-full rounded-xl"
/>
{zodErrors?.summary && (
<p aria-live="polite" className="text-red-500">
{zodErrors.summary}
</p>
)}
</div>
<div className="flex gap-2 flex-col">
<Dropzone
files={files}
onDrop={onDrop}
onRemoveFile={removeFile}
rejectedFiles={rejectedFiles}
onRemoveRejectedFile={removeRejectedFile}
/>
</div>
<div className="flex gap-2 flex-col">
<textarea
name="note"
defaultValue={data?.note}
placeholder={translations("note_placeholder")}
className="outline-none p-3 border border-secondary bg-background text-foreground w-full rounded-xl min-h-24"
/>
{zodErrors?.note && (
<p aria-live="polite" className="text-red-500">
{zodErrors.note}
</p>
)}
</div>
<p
aria-live="assertive"
className={twJoin(success ? "text-green-500" : "text-red-500")}
>
{message}
</p>
<HugeRoundedButton
disabled={pending}
text={
pending ? translations("loading") : translations("submit_btn_text")
}
className="bg-blue-500 text-white"
/>
</form>
</Main>
);
};
export default CostTracker;
Any Idea on how to achive this?
Thank all.