vue.jsreferencevuejs3

how to watch a ref in a composable for updates with Vue 3?


I’m trying to understand a specific issue. I have a composable in Vue 3 that needs to watch a reference, which gets updated in the view through a text field. How can I set up a watcher on this variable to trigger the watch?

 export function useFieldValidationHelper3<T>(fieldValue: Ref<T>) {
 const value = fieldValue;

watch(
 value.value,
 (newValue) => {
   newValue.value = value;
   console.log("Field value updated in the composable", newValue);
 },
 { deep: true }
);

const errorMessage: Ref<string | undefined> = ref("");
const field = ref();
console.log(fieldValue)
// Method to manually update the value if needed
function updateValue(newValue: T) {
 console.log("VALIDATE IS UPDATING THE HOUSE NUMBER", newValue);
 field.value = newValue;
} 

const getValue = computed(() => {
 return value;
});


return {
 fieldValue,
 errorMessage,
 updateValue,
 getValue
};
}

The view

import { useFieldValidationHelper3   } from "@/composables/FieldValidationHelper3";

const testValue = ref({
      houseNumber: '1'
    });

const { fieldValue, errorMessage, updateValue, getValue } = useFieldValidationHelper3(testValue.value.houseNumber);

// This is not the preferred way
const updateText = () => {
  updateValue(getValue.value+"1");
}

... Some more HTML
<v-text-field hide-details="auto" label="House Number" v-model="testValue.houseNumber" class="noBorders"></v-text-field>
{{ fieldValue, errorMessage, updateValue, getValue }} 
<button @click="updateText">Test </button>

I’m confident that I’m nearing a solution.

Best, Ido


Solution

  • There are several problems.

    A value is being instantly read in the composable, this defies the purpose of using a ref and it couldn't be made reactive this way in JavaScript.

    It should be either:

    watch(() => value.value, ...)
    

    Or, since watch specifically supports refs for this use:

    watch(value, ...)
    

    There's a problem with variable naming, because value isn't a value but value reference. If a parameter is allowed to be both a value and a ref, it could be fieldValueOrRef for clarity.

    At the place where a value is consumed it can be:

    const fieldValue = toValue(fieldValueOrRef);
    

    In the case of a computed or watcher this should happen inside a function, but it doesn't serve a good purpose to set up a watcher for non-reactive value, so in this case a watcher should've been made optional depending on the provided value.

    Instantly reading a value like useFieldValidationHelper3(testValue.value.houseNumber) is the same as useFieldValidationHelper3(1), and again, it cannot be made reactive this way. In order to do this, the argument should be a ref (could be a computed in this case). But to the associated boilerplate, it can also be a getter function that is commonly used for computed, etc to lazily read a value. This is the reason toValue accepts both refs and getter functions, this allows to unify the usage in an efficient way. A getter function prevents useFieldValidationHelper3 from exposing its own model as single source of truth, but this shouldn't be a problem because a model already exists in the context where useFieldValidationHelper3 is used, this makes getValue and updateValue not useful.

    So a composable with external model could be:

    function useFieldValidationHelper3<T>(fieldGetterOrRef: Ref<T> | () => T) {
    
      if (isRef(fieldGetterOrRef) or typeof fieldGetterOrRef === 'function') {
        watch(() => toValue(fieldGetterOrRef), value => ...)
      } else {
        // composable doesn't do anything useful
      }
      ...
    

    With a possible use:

    const { errorMessage } = useFieldValidationHelper3(() => testValue.value.houseNumber);
    const updateText = () => {
      testValue.value.houseNumber = 2
    }
    

    And a composable that accepts an initial value and has optional internal model could be:

    function useFieldValidationHelper3<T>(fieldValueOrRef: MaybeRef<T>) {
    
      let fieldRef: Ref<T>;
    
      if (isRef(fieldValueOrRef)) {
        // external model
        if (isReadonly(fieldValueOrRef))
          throw new Error()
        fieldRef = fieldValueOrRef;
      } else {
        // internal model
        fieldRef = ref(fieldValueOrRef);
      }
    
      watch(fieldRef, value => ...);
      ...
      return { fieldRef, ... }
    }
    

    With a possible use:

    const houseNumberRef = ref(1);
    
    const testValue = ref({ houseNumber: houseNumberRef });
    
    const { errorMessage, fieldRef } = seFieldValidationHelper3(houseNumberRef);
    const updateText = () => {
      fieldRef.value = 2 // or houseNumberRef
    }
    

    Either way, no need for separate getValue and updateValue, this is what reactive objects do. And fieldRef that a composable returns is redundant when there's external model.