File Inputs and Uploads
A file input cannot take v-model: read files from the change event, check type and size first, and report upload progress.
<input type="file" accept="image/*" @change="onChange" /><input type="file"> is the one form control v-model cannot bind. Vue's compiler rejects it outright. The reason is in the DOM: the input's value is a FileList, and a page cannot put files into it, because then any page could upload any file it could name. Selection is the user's act, and all code gets is a notification.
Reading the selection
Listen for change, take the element from event.target, and read input.files, which is a FileList or null. For a single file use input.files?.[0]; with the multiple attribute, Array.from(input.files) gives a real array. Each File carries name, size in bytes and type. Keep the file in your own ref: that is your state from then on.
Check before sending
The accept attribute filters the file picker but does not enforce anything, so validate type and size yourself and refuse early rather than after a 20 MB upload. For a preview, URL.createObjectURL(file) returns a string usable as an src; call URL.revokeObjectURL when you replace it or the component unmounts, or the blob stays in memory.
Sending it
Put the file in a FormData and post that; do not set a Content-Type header, as the browser adds the multipart boundary. fetch has no upload progress event, so a progress bar needs XMLHttpRequest and its upload object's progress event.
Example
<!-- src/components/AvatarPicker.vue -->
<script setup lang="ts">
import { onUnmounted, ref } from 'vue'
const maxBytes = 2 * 1024 * 1024
const file = ref<File | null>(null)
const previewUrl = ref('')
const error = ref('')
function revoke() {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
}
function onChange(event: Event) {
const input = event.target as HTMLInputElement
const picked = input.files?.[0] ?? null
revoke()
error.value = ''
file.value = null
if (picked && !picked.type.startsWith('image/')) {
error.value = 'Choose an image file'
} else if (picked && picked.size > maxBytes) {
error.value = 'Images must be under 2 MB'
} else if (picked) {
file.value = picked
previewUrl.value = URL.createObjectURL(picked)
}
input.value = ''
}
onUnmounted(revoke)
</script>
<template>
<div>
<label for="avatar">Avatar</label>
<input id="avatar" type="file" accept="image/*" @change="onChange" />
<img v-if="previewUrl" :src="previewUrl" alt="Selected avatar" width="96" />
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="file">{{ file.name }}</p>
</div>
</template>
<style scoped>
img {
border-radius: 50%;
}
</style>When to use it
- Let someone pick a profile photo and see it before uploading.
- Refuse a 40 MB video in the browser instead of after the upload fails.
- Show a progress bar for a slow upload on a phone connection.
- Accept both a drag and drop and a file picker with one code path.
More examples
Upload with progress
This is the reason upload code still uses `XMLHttpRequest`: `fetch` reports nothing while a request body is being sent, so there is no way to drive a percentage from it. Wrapping XHR in a promise keeps the component free of event plumbing; it awaits the call and passes a callback that writes a ref the progress bar reads.
// src/upload/uploadWithProgress.ts
export function uploadWithProgress(
url: string,
file: File,
onProgress: (percent: number) => void
): Promise<string> {
return new Promise((resolve, reject) => {
const body = new FormData()
body.append('file', file, file.name)
const request = new XMLHttpRequest()
request.open('POST', url)
request.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
onProgress(Math.round((event.loaded / event.total) * 100))
}
})
request.addEventListener('load', () => {
if (request.status < 400) resolve(request.responseText)
else reject(new Error(`Upload failed with status ${request.status}`))
})
request.addEventListener('error', () => reject(new Error('Upload failed')))
request.send(body)
})
}Several files, and a drop zone
A drop and a picker both hand over a `FileList`, from `event.dataTransfer.files` and `input.files`, so one `add` function covers both. `.prevent` on `dragover` and `drop` is required: without it the browser navigates away to display the dropped file. Always keep the real input as well, since dragging is not usable from a keyboard.
<!-- src/components/Attachments.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue'
const files = ref<File[]>([])
const dragging = ref(false)
const totalKb = computed(() =>
Math.round(files.value.reduce((sum, file) => sum + file.size, 0) / 1024)
)
function add(list: FileList | null) {
if (!list) return
files.value = [...files.value, ...Array.from(list)]
}
function onDrop(event: DragEvent) {
dragging.value = false
add(event.dataTransfer?.files ?? null)
}
</script>
<template>
<div
:class="{ over: dragging }"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
>
<input type="file" multiple @change="add(($event.target as HTMLInputElement).files)" />
<p>Or drop files here</p>
<ul>
<li v-for="file in files" :key="`${file.name}-${file.lastModified}`">{{ file.name }}</li>
</ul>
<p>{{ files.length }} files, {{ totalKb }} KB</p>
</div>
</template>
<style scoped>
.over {
outline: 2px dashed currentColor;
}
</style>The size and type rules on their own
Pulling the rules out of the component makes them testable without constructing a real `File` or mounting anything: the parameter type asks only for `type` and `size`. It also gives you one place to change the limit when the server's limit changes, instead of hunting through templates for the number.
// src/upload/checkFile.ts
const maxBytes = 2 * 1024 * 1024
type Candidate = Pick<File, 'type' | 'size'>
export function checkFile(file: Candidate): string | null {
if (!file.type.startsWith('image/')) return 'Choose an image file'
if (file.size > maxBytes) return 'Images must be under 2 MB'
return null
}
console.log(checkFile({ type: 'application/pdf', size: 1024 }))
// Output: Choose an image file
Discussion