Gravatar of Jeroen Jeroen Pelgrims

Solving v-model Binding Issues with Vue Query and Vue 3's Reactivity

Posted on in software-development, vue

When working with Vue Query (TanStack Query) and Vue 3's reactivity system, you might encounter a subtle but frustrating issue with model bindings.
For some reason you can't use v-model on a copy of your vue-query's result's properties.
This blog post will describe the issue in detail and propose a workaround and a proper fix for this issue.

If you want to dive straight into the code there's a github repository available here: https://github.com/jeroenpelgrims/vue-query-v-model-binding-issues

The setup§

Imagine you have a parent component that fetches product data using a Vue Query hook:

<script setup lang="ts">
const product = useProduct();
</script>

<template>
  <div v-if="product.isLoading.value">Loading...</div>
  <ProductEditor v-else :product="product.data.value!" />
</template>

The product object is the result of a useQuery hook with properties like data, isLoading, etc.
In your child component (ProductEditor), you want to create a local editable copy:

<script setup>
const props = defineProps<{
  product: Product;
}>();
const editedProduct = ref<Product>(props.product);
</script>

The problem§

When you try to use v-model bindings on the product properties:

<template>
  <input v-model="editedProduct.name" />
</template>

You'll encounter this warning in your console:

[Vue warn] Set operation on key "name" failed: target is readonly.

And your object won't update as expected.

The Workaround (Not Ideal)§

One workaround is to manually handle the update by overwriting the product with a new object:

<template>
  <input 
    :value="editedProduct.name" 
    @input="editedProduct = {...editedProduct, name: $event.target.value}" 
  />
</template>

This works, but it's verbose and cumbersome, especially when dealing with multiple fields.

The Root Cause§

The issue occurs because Vue Query returns readonly reactive objects to prevent accidental mutations of the cached data. More details about that in this github issue: https://github.com/TanStack/query/issues/4750

When you create a ref with ref<Product>(props.product), you're making a shallow copy of the data that vue-query returns and thus keep this readonly status.

The Solution§

The proper solution is to create a clone of the object's properties:

<script setup>
const props = defineProps<{
  product: Product;
}>();

const editedProduct = ref<Product>({ ...props.product });
</script>

Note: The spread operator ({...props.product}) only creates a shallow clone. If your product object contains nested objects that also need to be editable, use cloneDeep from lodash to ensure all nested properties are mutable:

<script setup>
import { cloneDeep } from 'lodash';

const props = defineProps<{
  product: Product;
}>();

const editedProduct = ref<Product>(cloneDeep(props.product));
</script>

Now your v-model bindings will work correctly:

<template>
  <input v-model="editedProduct.name" />
</template>

This post is written by Jeroen Pelgrims, an independent software developer who runs Digicraft.eu.

Hire Jeroen