vue.jsvuejs3pinia

VueJS 3.4 - Computed value to get from two sources but set in one


The logic in my app is to fetch some array of objects from API with @tanstack/vue-query, update some of those objects and send a patch request only for the updated objects.

So I created an empty array in Pinia store that will hold the updated objects and at the end send only those objects to the patch request.

Tried a few ways and this seemed like the simplest way to do it:

const store = useBlocksStore();
const { blocks } = useBlocksAPI();
const { activeBlock } = storeToRefs(store);

  const block = computed(
    {
      get() {
        const blockId = activeBlock.value ?? 0;
        if (store.blocks[blockId]) {
          return store.blocks[blockId];
        }
        return blocks.value[blockId];
      },
      set(value: Block) {
        const blockId = activeBlock.value ?? 0;
        store.$patch((state) => {
          state.blocks[blockId] = value;
        });
      },
    }
  );

<input v-model="block.properties.left" type="text" />

So my expected behaviour is the getter to check if the block with some ID is available in store, meaning it has already being modified after fetching from the API, return that value if not return the value from the API, and then the setter to always set the modifed values in the store.

But I get this error instead [Vue warn] Set operation on key "left" failed: target is readonly. Proxy(Object) {top: 25, bottom: 25, left: 0, right: 0} , so I guess it tries to modify the value from the API.

Any idea what am I missing here or not understand, or any suggestions to accomplish this with minimal repetitive code to be able to use this in many other components to update different block properties.

Sandbox


Solution

  • The problem is that block.properties.left is readonly data from the query, it needs to be cloned before it can be mutated. The whole logic to manage store data can be moved to the store. It's generally not a good practice to directly mutate data from the store in v-model without an action, this also needs to be taken into account.

    The store can either be filled with data lazily and fall back to data from the query, as was tried in the question. That there's a case for changing deeply nested data suggests there should be a separate action to change block fields:

    export const useBlocksStore = defineStore("blocks", {
      state: () => ({
        _query: markRaw(useBlocksQuery()),
        blocks: {},
      }),
      getters: {
        getBlock() {
          return (id) => {
            // Avoid reactivity bugs by eagerly accessing data to track it
            const readonlyBlock = this._query.data.value[id];
            return this.blocks[id] ?? readonlyBlock;
          };
        },
      },
      actions: {
        updateBlock(id, value) {
          this.$patch((state) => {
            const blockData =
              state.blocks[id] ?? lodash.cloneDeep(state._query.data.value[id]);
            state.blocks[id] = lodash.merge(blockData, value);
          });
        },
        updateBlockField(id, path, value) {
          const state = this.$state;
          const blockData =
            state.blocks[id] ?? lodash.cloneDeep(state._query.data.value[id]);
          state.blocks[id] = lodash.set(blockData, path, value);
        },
      },
    });
    

    Or the entire query data can be cloned to the store. This would require to diff data between the store and query before it can be sent back in PATCH request, which is more direct but still viable approach that is widely used.

    It makes sense to additionally cache the value of a parametrized getters/computed in a computed in a component:

    const block = computed(() => store.getBlock(props.blockId));
    

    And a writable computed is convenient to use with v-model and native elements:

    const blockWidthModel = computed({
      get() {
        return block.value.dimensions.width;
      },
      set(value) {
        store.updateBlockField(props.blockId, "dimensions.width", value);
      },
    });