Vue's reactivity system is one of the better fits for file upload state management. Progress percentages, file lists, error states — these are all inherently reactive values, and Vue's ref() and computed() handle them without ceremony. But wiring Resumable.js into a Vue component correctly still takes care. You need to initialize the instance at the right lifecycle point, tear it down cleanly, and make sure DOM refs are available before you bind drop zones or browse buttons. Get that wrong and you'll chase silent failures.
This guide builds a reusable <ChunkedUploader> component using Vue 3's Composition API. We'll extract the core logic into a composable, wire up reactive progress tracking, and add pause/resume controls. The result is a component you can drop into any Vue app with minimal configuration.
The Composable: useResumableUpload
Separating Resumable.js logic into a composable keeps your component template clean and makes the upload behavior reusable across views. The composable owns the Resumable instance, exposes reactive state, and handles cleanup.
// composables/useResumableUpload.js
import { ref, onMounted, onUnmounted, computed } from 'vue'
import Resumable from 'resumablejs'
export function useResumableUpload(options = {}) {
const resumable = ref(null)
const files = ref([])
const status = ref('idle') // idle | uploading | paused | complete | error
const errors = ref([])
const overallProgress = computed(() => {
if (!resumable.value) return 0
return Math.floor(resumable.value.progress() * 100)
})
function init(browseEl, dropEl) {
const r = new Resumable({
target: options.target || '/api/upload',
chunkSize: options.chunkSize || 5 * 1024 * 1024,
simultaneousUploads: options.simultaneousUploads || 3,
testChunks: options.testChunks ?? true,
...options.resumableOptions
})
r.assignBrowse(browseEl)
if (dropEl) r.assignDrop(dropEl)
r.on('fileAdded', (file) => {
files.value = [...resumable.value.files.map(mapFile)]
})
r.on('fileProgress', (file) => {
files.value = files.value.map(f =>
f.uniqueIdentifier === file.uniqueIdentifier
? { ...f, progress: Math.floor(file.progress() * 100) }
: f
)
})
r.on('fileSuccess', (file, message) => {
files.value = files.value.map(f =>
f.uniqueIdentifier === file.uniqueIdentifier
? { ...f, progress: 100, status: 'complete' }
: f
)
if (resumable.value.progress() === 1) {
status.value = 'complete'
}
})
r.on('fileError', (file, message) => {
errors.value.push({
fileName: file.fileName,
message: message || 'Upload failed'
})
files.value = files.value.map(f =>
f.uniqueIdentifier === file.uniqueIdentifier
? { ...f, status: 'error' }
: f
)
})
r.on('uploadStart', () => { status.value = 'uploading' })
r.on('complete', () => { status.value = 'complete' })
r.on('pause', () => { status.value = 'paused' })
resumable.value = r
}
function mapFile(file) {
return {
uniqueIdentifier: file.uniqueIdentifier,
fileName: file.fileName,
size: file.size,
progress: Math.floor(file.progress() * 100),
status: file.isComplete() ? 'complete' : 'uploading'
}
}
function upload() { resumable.value?.upload() }
function pause() { resumable.value?.pause() }
function resume() { resumable.value?.upload() }
function cancel() {
resumable.value?.cancel()
files.value = []
status.value = 'idle'
errors.value = []
}
function removeFile(uniqueIdentifier) {
const file = resumable.value?.files.find(
f => f.uniqueIdentifier === uniqueIdentifier
)
if (file) {
resumable.value.removeFile(file)
files.value = files.value.filter(
f => f.uniqueIdentifier !== uniqueIdentifier
)
}
}
onUnmounted(() => {
if (resumable.value) {
resumable.value.cancel()
// Resumable.js doesn't have a formal destroy method,
// but cancelling stops all in-flight requests
resumable.value = null
}
})
return {
init,
files,
status,
errors,
overallProgress,
upload,
pause,
resume,
cancel,
removeFile
}
}
A few things to note. The init function takes DOM elements, not refs — this is deliberate. Resumable.js needs actual DOM nodes for assignBrowse and assignDrop, so the component calls init after mount when the template refs have resolved. The onUnmounted hook cancels any in-flight uploads and nulls the instance. Without this, navigating away from a view mid-upload leaks XHR connections.
The files ref is a mapped array of plain objects rather than raw Resumable file instances. Vue's reactivity proxy and Resumable's internal state don't mix well — proxying the Resumable file objects can cause subtle issues. Mapping to plain objects on every state change sidesteps this entirely.
The Component
With the composable handling all the Resumable.js interaction, the component itself is mostly template and styling:
<!-- components/ChunkedUploader.vue -->
<template>
<div class="uploader">
<div
ref="dropZoneRef"
class="drop-zone"
:class="{ 'drag-over': isDragOver }"
@dragover.prevent="isDragOver = true"
@dragleave="isDragOver = false"
@drop.prevent="isDragOver = false"
>
<p>Drag files here or</p>
<button ref="browseRef" type="button">Browse</button>
</div>
<div v-if="files.length" class="file-list">
<div v-for="file in files" :key="file.uniqueIdentifier" class="file-row">
<span class="file-name">{{ file.fileName }}</span>
<span class="file-size">{{ formatSize(file.size) }}</span>
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: file.progress + '%' }"
:class="{
complete: file.status === 'complete',
error: file.status === 'error'
}"
/>
</div>
<span class="file-progress">{{ file.progress }}%</span>
<button @click="removeFile(file.uniqueIdentifier)" title="Remove">×</button>
</div>
</div>
<div v-if="files.length" class="controls">
<button
v-if="status === 'idle' || status === 'paused'"
@click="upload"
>
{{ status === 'paused' ? 'Resume' : 'Upload' }}
</button>
<button v-if="status === 'uploading'" @click="pause">Pause</button>
<button v-if="status !== 'idle'" @click="cancel">Cancel</button>
<span class="overall">Overall: {{ overallProgress }}%</span>
</div>
<div v-if="errors.length" class="error-list">
<p v-for="(err, i) in errors" :key="i" class="error">
{{ err.fileName }}: {{ err.message }}
</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useResumableUpload } from '../composables/useResumableUpload'
const props = defineProps({
target: { type: String, required: true },
chunkSize: { type: Number, default: 5 * 1024 * 1024 },
simultaneousUploads: { type: Number, default: 3 },
testChunks: { type: Boolean, default: true }
})
const browseRef = ref(null)
const dropZoneRef = ref(null)
const isDragOver = ref(false)
const {
init, files, status, errors, overallProgress,
upload, pause, resume, cancel, removeFile
} = useResumableUpload({
target: props.target,
chunkSize: props.chunkSize,
simultaneousUploads: props.simultaneousUploads,
testChunks: props.testChunks
})
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1048576).toFixed(1) + ' MB'
}
onMounted(() => {
init(browseRef.value, dropZoneRef.value)
})
</script>
The component exposes configuration via props — target, chunkSize, simultaneousUploads, and testChunks. This makes it reusable across different upload endpoints in the same app. Using it is straightforward:
<ChunkedUploader
target="/api/upload"
:chunk-size="10 * 1024 * 1024"
:simultaneous-uploads="4"
/>
Pause and Resume
The pause/resume flow deserves a closer look. When you call resumable.pause(), Resumable.js aborts the currently in-flight XHR requests for active chunks. When you later call resumable.upload(), it restarts from the chunks that didn't complete. If testChunks is enabled, it will send a GET request to the server first to verify which chunks are already stored, so even if chunks completed between the pause and resume, they won't be re-sent. This is the same mechanism that powers resuming after a page refresh.
In the component, the button text toggles between "Upload" and "Resume" based on whether the status is idle or paused. Both map to the same upload() call under the hood — Resumable.js handles the distinction internally.
Handling Reactivity Edge Cases
Vue 3's reactivity proxy wraps objects in a Proxy, which can interfere with libraries that rely on internal property checks or instanceof. Resumable.js mostly avoids these issues, but there's one pattern to watch: don't store the Resumable instance inside reactive(). Using ref() and accessing via .value is safer because ref only proxies the .value access, not the object itself (for object values, ref does use reactive internally, but Resumable.js properties are primarily methods and primitives that survive proxying).
If you run into edge cases, shallowRef() is the escape hatch — it opts out of deep reactivity entirely:
import { shallowRef } from 'vue'
const resumable = shallowRef(null)
This stores the Resumable instance without wrapping its internals. You lose deep reactivity on the instance, which is fine — you're managing state through your own ref() values anyway.
Extending the Component
The composable pattern makes it simple to add features without bloating the core. A few useful extensions:
File type filtering — pass fileType as an array through props and map it to Resumable's fileTypeErrorCallback and allowedFileTypes configuration options.
Auto-upload — add an autoUpload prop and watch the files ref. When a new file appears and autoUpload is true, call upload() immediately.
Upload events for parent components — emit custom events (@complete, @error, @progress) from the component using defineEmits, letting the parent react to upload lifecycle changes without reaching into the composable.
const emit = defineEmits(['complete', 'error', 'progress'])
// Inside the composable callbacks:
r.on('complete', () => {
status.value = 'complete'
emit('complete')
})
Comparison with React
If you're coming from the React integration example, the mental model is similar but the mechanics differ. React uses useEffect for initialization and cleanup; Vue uses onMounted and onUnmounted. React's useState setter triggers re-renders; Vue's ref triggers targeted reactivity updates. Vue's approach is slightly more efficient for frequent progress updates because it doesn't re-render the entire component tree — only the parts of the template that depend on the changed ref actually update.
The basic uploader example covers the vanilla JavaScript fundamentals if you need to understand what's happening beneath the framework abstraction. The events reference documents every Resumable.js event used in the composable.
Cleanup Matters
One mistake that surfaces in production but not in development: forgetting to cancel uploads when the component unmounts. In a single-page app, navigating away from the upload view doesn't stop in-flight requests. Those XHRs continue, the callbacks fire, and they try to update refs that belong to a now-destroyed component instance. Vue 3 handles this more gracefully than Vue 2 (it won't throw), but you're still wasting bandwidth and server resources on uploads the user abandoned.
The onUnmounted hook in the composable handles this. If you need even more control — say, prompting the user before navigating away during an active upload — use Vue Router's onBeforeRouteLeave guard:
import { onBeforeRouteLeave } from 'vue-router'
onBeforeRouteLeave((to, from, next) => {
if (status.value === 'uploading') {
const leave = window.confirm('Upload in progress. Leave anyway?')
if (leave) cancel()
next(leave)
} else {
next()
}
})
This is a small detail that makes the difference between a demo and a production upload component.
