I am creating a multistep register form in which I provide an avatar upload. Because it is a multistep form, I want to store the data in a Pinia store until the form finally gets submitted. Everything works fine so far. But I want to be able to delete the value that contains the Blob URL for the avatar, so the user can choose a different image. What I am trying to do is this userRegisterStore.cardOwner.avatar = ''
cause the initial state of that value is just an empty string. But I get this error message:
runtime-core.esm-bundler.js:218 Uncaught TypeError: 'set' on proxy: trap returned falsish for property 'avatar'
I also use cropperjs and vue-cropperjs. But I think that's irrelevant in this case. I Googled all day and found nothing. So, I hope someone here can help.
[EDIT] I created a codesandbox.io I hope it works. The first file you should see is RegisterFormFive.vue. To view it, you need to go to this link or use the integrated preview in codesandbox: https://n9dfv3-5173.preview.csb.app/register. Then upload an image, crop it (orange button beneath the image), and then try to delete it (red button)
Here's my code:
// RegisterDataStore.js
export const useRegisterDataStore = defineStore('RegisterDataStore', {
state: () => ({
imgReady: false,
cardOwner: reactive({
firstName: '',
lastName: '',
email: '',
password: '',
agbAccepted: false,
dsgvoAccepted: false,
title: '',
companyName: '',
companyPublic: false,
position: '',
positionPublic: false,
avatar: '',
addresses: [],
contacts: [],
links: [],
}),
}),
// Cropper part
<Cropper
v-if="registerDataStore.cardOwner.avatar && !registerDataStore.imgReady"
class="mx-auto max-h-[350px] max-w-[350px] overflow-hidden rounded-lg border-2 border-skin-primary bg-skin-primary"
ref="cropper"
alt="User avatar"
drag-mode="move"
:src="registerDataStore.cardOwner.avatar"
:aspect-ratio="1 / 1"
:crop-box-movable="false"
:crop-box-resizable="false"
:auto-crop-area="0.6"
:guides="false"
:movable="true"
:scalable="true"
:zoomable="true"
:zoo-on-touch="true"
:max-canvas-width="350"
:max-canvas-height="350"
:zoom-on-wheel="true"
:rotate-on-drag="false"
:rotatable="false"
:background="false"
:modal="true"
:initial-aspect-ration="1 / 1"
:view-mode="1"
></Cropper>
// Conponent script
<script setup>
import HeaderNav from '@/components/HeaderNav.vue'
import HeaderTitle from '@/components/HeaderTitle.vue'
import { useRegisterDataStore } from '@/stores/RegisterDataStore'
import Cropper from 'vue-cropperjs'
import 'cropperjs/dist/cropper.css'
import { ref } from 'vue'
import { useObjectUrl } from '@vueuse/core'
name: 'RegisterFormFive'
const registerDataStore = useRegisterDataStore()
const avatarInput = ref(null)
const cropper = ref(null)
const fileChanged = (event) => {
const file = event.target.files[0] || e.dataTrtansfer.files[0]
const reader = new FileReader()
reader.onload = (e) => {
registerDataStore.cardOwner.avatar = e.target.result
}
reader.readAsDataURL(file)
}
const deleteAvatar = (event) => {
registerDataStore.cardOwner.avatar = null
registerDataStore.imgReady = false
}
</script>
// The button that tiggers the storage
<div class="mt-4 flex justify-center">
<button
v-if="!registerDataStore.imgReady"
@click.prevent="
cropper.getCroppedCanvas().toBlob((blob) => {
registerDataStore.cardOwner.avatar = useObjectUrl(blob)
registerDataStore.imgReady = true
})
"
type="button"
class="hover:bg-skin-primary-dark inline-flex items-center rounded-md border border-transparent bg-skin-primary px-4 py-2 text-sm font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-skin-primary focus:ring-offset-2"
>
// The file input field
<input
type="file"
ref="avatarInput"
accept=".jpg,.png"
@change="fileChanged"
:style="{ display: 'none' }"
/>
// The button that should "delete" the value
<button
v-if="registerDataStore.imgReady"
@click.prevent="deleteAvatar"
type="button"
class="hover:bg-skin-primary-dark inline-flex items-center rounded-md border border-transparent bg-red-700 px-4 py-2 text-sm font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-skin-primary focus:ring-offset-2"
>
<IconWarning
class="mr-2 h-5 w-5 fill-current text-skin-primary"
aria-hidden="true"
/>
Bild löschen
</button>
Nested reactive
isn't needed in state
, Pinia state is already reactive. @click.prevent
handler doesn't need to be created in a template, it doesn't affect how it works but makes debugging harder.
VueUse useObjectUrl
composable is the problem. Due to how Vue reactive API works, refs are unwrapped inside reactive object. Since useObjectUrl
returns readonly ref, it makes cardOwner.avatar
property readonly and prevents from reassigning a value. Changing it would require the whole object to be reassigned:
registerDataStore.cardOwner = { ...registerDataStore.cardOwner, avatar: ... }
The actual problem is that useObjectUrl
is misused. Since blob
value doesn't change in the scope of then
function, it can't benefit from being reactive. The composable should be replaced with the actual thing that it does:
registerDataStore.cardOwner.avatar = URL.createObjectURL(newObject)