File Upload With Other Form Data - Question
Hey I love this library! It works super well. I do, however have one conundrum I've been struggling with for a while. I have a form that has a file upload input as well as a few other text inputs, because I want to submit all of this data to the server at once. I can't seem to figure out a way to do it. Any advice? I've tried everything from sending the file as submit data in useRemixForm to messing with stringifyAllValues, and many other things. No luck.
I'm using react dropzone for my file upload component. I have gotten the file upload to work without the use of remix-hook-form, but I really want to be able to validate my other form elements.
here's what my form looks like currently, though I've played with a million variations:
<Form method="post" onSubmit={handleSubmit} encType="multipart/form-data">
<Input
placeholder="Event Title"
errorMsg={errors.title?.message}
{...register("title")}
/>
<Textarea placeholder="Event Description"
errorMsg={errors.description?.message}
{...register("description")}
/>
<ImageUpload previewUrl={imagePreview}
minImageWidth={800}
minImageHeight={500}
onDrop={file => {
setValue('upload', file)
setImageFile(file)
setImagePreview(URL.createObjectURL(file))
}} onError={err => console.log(err)} onRemove={() => {
if (imagePreview != null) {
URL.revokeObjectURL(imagePreview);
setImageFile(null);
setImagePreview(null)
}
}
}/>
{errors.upload?.message && (<p className="text-red-500">{errors.upload?.message}</p>)}
<AddressSearchInput onAddressSelection={(item) => {
const loc = item.selectedItem
if (loc != null) {
setValue('location', {
...loc,
state: loc.stateCode,
lat: loc.latitude,
long: loc.latitude,
zip: loc.postalCode,
})
}
setLocation(item.selectedItem)
}} errorMsg={errors.location?.message}
addressData={addressData}
/>
<DateTimePicker
value={dateTime}
onChange={function (value: { date: Date; hasTime: boolean; }): void {
setValue('eventDateTime', value.date)
setDateTime(value)
}}
className="mt-3 bg-pingray-100 font-light focus:ring-2 focus:outline-none focus:ring-bg-pinblue-500"
/>
{errors.eventDateTime?.message != null && (
<p className="text-red-500">{errors.eventDateTime?.message}</p>
)}
<button
type="submit"
disabled={isSubmitting}
className="bg-pinblue-500 shadow hover:shadow-md w-full text-gray-100 p-2 rounded mt-8 font-['bree'] focus:ring-2 focus:outline-none focus:ring-bg-pinblue-500"
>
{isSubmitting ? 'Submitting...' : 'Create Event'}
</button>
</Form>
note I'm registering anything using setValue in a useEffect.
on the action side I've attempted everything from removing my upload value from zod all together to using remix's Multipart form parser and many other variations.
Should I maybe use multiple actions, and somehow split up my file upload from the other form data processing? And if so... how can I do that in the same component?
@alexwhb Hey Alex, thank you for the kind words! Before I answer this, what happens with the images after you upload them?
@AlemTuzlak Absolutely. This library is a huge help, and you also introduced me to Zod, which I've subsequently used on another project too.
I'll answer that in two ways, because I'm not 100% sure what way you mean.
1.) I'm currently uploading the file just to my local file system, but eventually I'll likely use S3. I've tested using S3 with S3rver locally using the image buffer and that also works when I'm not using Remix-Hook-Form.
2.) So when I attempt to upload an image this way either I get just a string with the image name in my request data in the action, or I get nothing in the action depending on how I configure the form. If I attempt to use the standard Remix multipart file upload method I get an error: TypeError: Could not parse content as FormData.
here's what my action code looks like in that case:
export const action: ActionFunction = async ({request}) => {
const directory = "/public/images"
const uploadHandler = unstable_composeUploadHandlers(
unstable_createFileUploadHandler({
directory,
maxPartSize: 5_000_000,
file: ({filename}) => {
const newFileName = `${uuidv4()}.${filename.split('.').pop()}`
prisma.image.create({
data: {
url: `/images/${newFileName}`
}
}).then(res => console.log(res))
return newFileName
},
}),
// parse everything else into memory
);
const formData = await unstable_parseMultipartFormData(
request,
uploadHandler
);
console.log(formData) // this never gets hit.
return {};
Let me know if you want any additional info. Happy to to provide it. And thank you for taking a look. Much appreciated.
Hi, I'm also having trouble with file upload mixed with other fields.
The setup you use in the documentation for "File upload" doesn't seem to work. I've encountered your problem @alexwhb, where the action says it could not parse as FormData. I resolved it but I don't remember how exactly (I'll try to remember).
My problem resides in the parsing of values not working correctly when mixing files and other content. The validateFormData function returns errors because my resolver expects an array and it encounters a string, as you can see here:
Entering action function:
Files: [{}]
{
files: {
message: 'Expected array, received string',
type: 'invalid_type',
ref: undefined
},
images: {
message: 'Expected array, received string',
type: 'invalid_type',
ref: undefined
},
published: {
message: 'Expected boolean, received string',
type: 'invalid_type',
ref: undefined
}
}
My action (down below) function is almost exactly like the example, however, I've tried countless variations. I've tried with stringifyAllValues true and false. I've even tried using a custom generateFormData.
const formData = await unstable_parseMultipartFormData(
request,
unstable_createMemoryUploadHandler(),
)
// The file will be there
console.log("Files: ", formData.get("files"))
// validate the form data
const { errors, data: product } = await validateFormData<Product>(
formData,
resolver,
)
if (errors) {
console.log(errors)
return json({ errors, product })
}
@alexwhb Sorry for the slow reply! So I wrote an article a while back where I upload images directly to supabase (which I think uploads it to an S3 bucket) so you could do it like this:
https://alemtuzlak.hashnode.dev/uploading-images-to-supabase-with-remix
What I usually would recommend with file uploads is to have validation where its:
z.instanceOf(File).or(z.string())
and when you upload it you parse it and return the url on the server so you can actually save the url to wherever and pass the validation.
When it comes to your problem @aknegtel did you try using the text decoder (you can refer to the same link I provided above) for parsing the rest of the values?
unstable_parseMultipartFormData relies on you parsing everything and giving it back to formData, so you need to parse the non file values as well and return them as text.
@AlemTuzlak Awesome! Thanks for that resource. I'll give it a try.
@alexwhb Sorry for the slow reply! So I wrote an article a while back where I upload images directly to supabase (which I think uploads it to an S3 bucket) so you could do it like this: https://alemtuzlak.hashnode.dev/uploading-images-to-supabase-with-remix What I usually would recommend with file uploads is to have validation where its:
z.instanceOf(File).or(z.string())and when you upload it you parse it and return the url on the server so you can actually save the url to wherever and pass the validation.When it comes to your problem @aknegtel did you try using the text decoder (you can refer to the same link I provided above) for parsing the rest of the values?
unstable_parseMultipartFormDatarelies on you parsing everything and giving it back to formData, so you need to parse the non file values as well and return them as text.
I wrote mine like this which works quite well 😄
image: zod.instanceof(File).refine((file) => {
return file && file.size <= MAX_FILE_SIZE
}, `Max image size is 5MB.`)
.refine(
(file) => file && ACCEPTED_IMAGE_TYPES.includes(file.type),
"Only .jpg, .jpeg, .png formats are supported."
).or(zod.string().url()).optional(),
Hi, I'm also having trouble with file upload mixed with other fields.
The setup you use in the documentation for "File upload" doesn't seem to work. I've encountered your problem @alexwhb, where the action says it could not parse as FormData. I resolved it but I don't remember how exactly (I'll try to remember).
My problem resides in the parsing of values not working correctly when mixing files and other content. The
validateFormDatafunction returns errors because my resolver expects an array and it encounters a string, as you can see here:Entering action function: Files: [{}] { files: { message: 'Expected array, received string', type: 'invalid_type', ref: undefined }, images: { message: 'Expected array, received string', type: 'invalid_type', ref: undefined }, published: { message: 'Expected boolean, received string', type: 'invalid_type', ref: undefined } }My action (down below) function is almost exactly like the example, however, I've tried countless variations. I've tried with stringifyAllValues true and false. I've even tried using a custom
generateFormData.const formData = await unstable_parseMultipartFormData( request, unstable_createMemoryUploadHandler(), ) // The file will be there console.log("Files: ", formData.get("files")) // validate the form data const { errors, data: product } = await validateFormData<Product>( formData, resolver, ) if (errors) { console.log(errors) return json({ errors, product }) }
Hi!
I had similar issued until I used parseFormData before calling validateFormData. That seems to do the trick with getting everything parsed correctly 😄
I also was able to get it working by following the various useful tips in the comments, this is the code in case anybody needs it for reference
async function decodeTextFields({ filename, data }) {
if (!filename) {
const chunks = [];
for await (const chunk of data) {
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
const textDecoder = new TextDecoder();
return textDecoder.decode(buffer);
}
// if it has a filename it's the file we want to upload, by returning undefined we delegate the processing of the file to
// the next uploadHandler, in this example the s3UploadHandler
return undefined;
};
export async function action({ request }: ActionFunctionArgs) {
const uploadHandler: UploadHandler = composeUploadHandlers(
decodeTextFields,
s3UploadHandler,
);
const formData = await parseMultipartFormData(request, uploadHandler);
const avatarUrl = formData.get("avatar");
const {
errors,
data,
receivedValues: defaultValues,
} = await getValidatedFormData<CreateEventSchema>(
formData as unknown as Request,
resolver,
);
if (errors) {
return json({ errors, defaultValues }, { status: 422 });
}
// do whatever you need to do with the data here, send it to the db I guess :)
}
@AlemTuzlak I have a couple of proposals:
- I would strongly advise updating the docs with a working example like this one so that whoever will have the same problem in the future will find the solution easily. I guess after updating the docs we could close the issue?
- I'm using
getValidatedFormDatadirectly instead ofparseFormDataandvalidateFormDataas @cbude suggested sincegetValidatedFormDatais already callingparseFormDatainternally, but Typescript was screaming at me cause I'm passing FormData instead of Request, when it actually works with a FormData as well. Do you think it would be correct to change the type fromRequesttoRequest | FormData(same asparseFormData)?
while doing all of this, I still get "Could not parse content as FormData.".
I added some console.log inside remix node_modules/@remix-run/server-runtime/dist/formData.js and I see that content type is application/x-www-form-urlencoded and not multipart/form-data as required. @AlemTuzlak do you change it somewhere?
my Form encType is multipart/form-data
while doing all of this, I still get "Could not parse content as FormData.". I added some console.log inside remix
node_modules/@remix-run/server-runtime/dist/formData.jsand I see that content type isapplication/x-www-form-urlencodedand notmultipart/form-dataas required. @AlemTuzlak do you change it somewhere?my Form encType is
multipart/form-data
adding
submitConfig: {
encType: "multipart/form-data",
},
to my useRemixForm fixed that issue, although I would expect the encType from the Form element to be used.
That's s good point, I forgot to mention it in my example. @AlemTuzlak sorry for pinging you again, but it would be nice to update the docs, I can open a PR but first I want to be sure you'll be up to review and eventually merge it
That's s good point, I forgot to mention it in my example. @AlemTuzlak sorry for pinging you again, but it would be nice to update the docs, I can open a PR but first I want to be sure you'll be up to review and eventually merge it
I think this is something to fix in the library and not something to document
I don't think so cause the library is using useSubmit under the hood and this is how useSubmit works
do you mean useSubmit changes the encType? so why have the encType prop on the form component anyway?
You don't need it on the form, when using useSubmit you are managing the form submission through the submit api and not through the Form component. You can check how it works on Remix documentation
You don't need it on the form, when using useSubmit you are managing the form submission through the submit api and not through the Form component. You can check how it works on Remix documentation
remix docs says to put it on the form. I don't use useSubmit directly
About to release a new release with the following improvements:
- Added example to readme
- remix-hook-form now respects either your Form props for encType and method or the submitConfig, submitConfig trumps Form props though
- getValidatedFormData now accepts both requests and formData
I'm closing this as completed with v5.0.0