Guidance on Multiple File Uploads with Alt Text #913
-
Hey there, I’m building a Remix application using @conform-to/react and @conform-to/zod to create a form where users can upload multiple image files, each associated with a description (alt text). Here's the schema: const schema = z.object({
images: z
.array(
z.object({
file: z.instanceof(File),
altText: z
.string({ required_error: 'Description cannot be empty' })
.min(1),
}),
)
.refine((images) => images.length > 0, {
message: 'At least one image is required',
}),
}) Because this is an array, the form needs to extend as the user selects more images. The challenge is that the browser doesn't allow setting a value on an I've got this working, but it does feel hacky and unidiomatic. I’d appreciate guidance or examples on how to achieve this effectively with Conform. import { getFormProps, getInputProps, useForm } from '@conform-to/react'
import { parseWithZod } from '@conform-to/zod'
import { redirect, type ActionFunctionArgs } from '@remix-run/node'
import { useActionData, useFetcher } from '@remix-run/react'
import { type ChangeEvent, type FormEvent, useRef, useState } from 'react'
import { flushSync } from 'react-dom'
import { z } from 'zod'
// Define a schema for your form
const schema = z.object({
images: z
.array(
z.object({
file: z.instanceof(File),
altText: z
.string({ required_error: 'Description cannot be empty' })
.min(1),
}),
)
.refine((images) => images.length > 0, {
message: 'At least one image is required',
}),
})
// Optional: Server action handler
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData()
const submission = parseWithZod(formData, { schema })
// Send the submission back to the client if the status is not successful
if (submission.status !== 'success') {
return submission.reply()
}
return redirect('/')
}
// Client form component
export default function LoginForm() {
// Grab the last submission result if you have defined a server action handler
// This could be `useActionData()` or `useFormState()` depending on the framework
const lastResult = useActionData<typeof action>()
const fetcher = useFetcher()
const formRef = useRef<HTMLFormElement>(null)
const [form, fields] = useForm({
// Configure when each field should be validated
shouldValidate: 'onBlur',
// Optional: Required only if you're validating on the server
lastResult,
// Optional: Client validation. Fallback to server validation if not provided
onValidate({ formData }) {
return parseWithZod(formData, { schema })
},
onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
console.log('submit')
// Append images to FormData
const formData = new FormData(event.currentTarget)
images.forEach((image, index) => {
formData.append(`images[${index}].file`, image)
// altText is already included in Conform’s fields
})
// Submit to server
fetcher.submit(formData, {
method: 'POST',
encType: 'multipart/form-data',
})
// Reset form
formRef.current?.reset()
},
})
const [images, setImages] = useState<File[]>([])
const addPhotoButtonRef = useRef<HTMLButtonElement>(null)
const handleChangeImages = (e: ChangeEvent<HTMLInputElement>) => {
e.preventDefault()
if (e.target.files && e.target.files.length > 0) {
handleImages(e.target.files)
e.target.value = '' // Reset input value after processing
}
}
const handleImages = (files: FileList) => {
const newPhotos = Array.from(files).filter((file) =>
file.type.startsWith('image/'),
)
setImages((prev: any) => [...prev, ...newPhotos])
newPhotos.forEach(() => {
// Flush sync ensures the photos array updates synchronously after every button click
flushSync(() => {
addPhotoButtonRef.current?.click()
})
})
}
const imagesField = fields.images.getFieldList()
return (
<form method="post" {...getFormProps(form)} ref={formRef}>
<div className="flex flex-1 flex-col gap-4">
<label htmlFor={fields.images.id}>Images</label>
<input
type="file"
accept="image/*"
multiple
onChange={handleChangeImages}
/>
<div>{JSON.stringify(fields.images.errors)}</div>
<button
ref={addPhotoButtonRef}
{...form.insert.getButtonProps({
name: fields.images.name,
defaultValue: {
altText: '',
file: null,
},
})}
className="hidden"
>
Add image
</button>
{imagesField.length > 0 && (
<div className="space-y-4">
<h3 className="text-lg font-medium">
Uploaded Photos ({imagesField.length})
</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
{imagesField.map((image: any, index: number) => {
const imageFields = image.getFieldset()
return (
<div key={image.key} className="overflow-hidden">
<div className="bg-muted relative aspect-square">
<div className="absolute inset-0 flex items-center justify-center">
{images[index] ? (
<img
src={URL.createObjectURL(images[index])}
alt={
imageFields.altText.value ||
`Car photo ${index + 1}`
}
className="h-full w-full object-cover"
/>
) : (
<p>No image</p>
)}
</div>
<button
className="absolute right-2 top-2 h-8 w-8"
{...form.remove.getButtonProps({
name: fields.images.name,
index,
})}
onClick={() =>
setImages((prev: any) =>
prev.filter((_: any, i: number) => i !== index),
)
}
>
X
</button>
</div>
<div className="p-3">
<input
{...getInputProps(imageFields.altText, {
type: 'text',
})}
placeholder="Description"
className="mt-2 text-xs"
/>
<div>{JSON.stringify(imageFields.altText.errors)}</div>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
<div>{JSON.stringify(form.errors)}</div>
<button>Submit</button>
</form>
)
} |
Beta Was this translation helpful? Give feedback.
Replies: 3 comments
-
Hi @andrecasal, Thanks for the question! File input is indeed a tricky one.
That was my understanding as well. But someone suggest that the DataTransfer API might work, which I think can be used to set the files through a ref callback: <input
type="file"
name={imageFields.file.name}
ref={element => {
const image = images[index]
if (element && image) {
const fileList = new DataTransfer()
fileList.items.add(image)
// Set the files to the input
element.files = fileList.files;
}
}}
/> Can you give it a try and let me know how it goes? As a side notes, if your are fine with having the user to upload the file one by one instead of a multiple file input, the Note form in the Epic Stack might be a good reference. Hope this helps! |
Beta Was this translation helpful? Give feedback.
-
Ah the DataTransfer API is interesting, thanks for putting that on my radar. I'll give it a try and let you know how it goes. |
Beta Was this translation helpful? Give feedback.
-
The DataTransfer API worked beautifully ✨ It might be useful to add this to the documentation 👍 |
Beta Was this translation helpful? Give feedback.
Hi @andrecasal,
Thanks for the question! File input is indeed a tricky one.
That was my understanding as well. But someone suggest that the DataTransfer API might work, which I think can be used to set the files through a ref callback:
Can you give it a try and let me know how it goes?
As a side notes, i…