typescriptvue.jsvuejs3nuxt.jsvuetify.js

How to keep the reactivity of data when passing it into a function?


Suppose my component looks like this:

<script setup lang="ts">
interface FileMetadata {
  owner: string,
  location: string,
  size: number,
  // And around 50 mores...
}

interface File {
  fileName: string,
  metadata: FileMetadata
}

const file = defineModel<File>({ required: true });

const buildControls = (metadata: FileMetadata) => {
  return [
    {
      label: 'Owner',
      model: metadata.owner,  // Correct so?
      type: 'text'
    },
    {
      label: 'Location',
      model: metadata.location,  // Correct so?
      type: 'text'
    },
    {
      label: 'Size',
      model: metadata.size,  // Correct so?
      type: 'number'
    },
    // And around 50 mores...
  ];
};

const controls = buildControls(file.value.metadata);  // Value is already unpack here
</script>

<template>
  <div v-for=(item, idx) in controls>
    <v-text-field v-model="item[idx].model" :label="item[idx].label" :type="item[idx].type" />
  </div>
</template>

This component takes a File object via its v-model, renders the view to allow users to edit its metadata. Whenever the model changes (another file was passed), the values of the text fields should be changed accordingly. And whenever users change values in the text fields, its corresponding values in the model must be changed as well. So, it's a two-way binding.

The view is rendered fine, but I cannot change values of the textboxes (it always changes back to its initial values when it loses its focus). I suppose because when the buildControls() function is called, the file is already unpacked, which makes it lose the reactivity. But if I do not unpack it (by accessing its .value), I cannot access its metadata attribute.

If I make the controls reactive by doing

const controls = reactive(buildControls(file.value.metadata));

then I can change the values in text fields, but the file model is not updated accordingly.

In my idea, the value of the model key should be a Ref object. But somehow I cannot achieve it.

A possible solution is to store the key name of the FileMetadata only instead of the object in the model key. Then, it can be access later in the template as file.metadata[item.model]. However, I do not like this approach. Why can't I just simply store an object instead?

What did I do wrong here? What is the correct way to keep the reactivity of the data?


Solution

  • controls is not reactive, this needs to be changed:

    const controls = ref();
    ...
    controls.value = buildControls(...);
    

    Or:

    const controls = reactive(buildControls(...));
    

    But the problem is that controls is intended for two-way synchronization with file, its structure makes it hard to implement. This is a general problem with storing mixed mutable and static data, there are not enough reasons to keep it together.

    If the component is supposed to emit file changes to a parent based on the changes that v-model="item[idx].model"` there will be problems with tracking specific changes.

    There should be a list of metadata keys that both static and mutable data could use. The suggested way is to keep static field data as a constant, so the types could derive from it for extra type safety (ValidateKeys can be implemented like shown here):

    const fileMetaFields = {
      owner: {
        label: 'Owner',
        type: 'text'
      },
      ...
    }
    
    type FileMetaKeys = keyof typeof fileMetaFields;
    
    type FileMeta = ValidateKeys<FileMetaKeys, {
      owner: string,
      ...
    }
    

    Additional utility types could be written to automatically map type: 'text' to string type.

    The component may not need own state if it's supposed to update parent's model. v-model in v-text-field limits the ways in which the model could be updated, so it needs to be desugared:

      function updateModelField({ key, value }) {
        emit('update:modelValue', {
          ...file.value,
          metadata: {
            ...file.value.metadata
            [key]: value
          },
        });
      }
    
      ...
    
      <div v-for=(field, key) in fileMetaFields>
        <v-text-field
          :label="field.label"
          :modelValue="file.value.metadata[key]" />
          @update:modelValue="updateModelField({ key, value: $event })"
          ... 
    

    This indicates the problem with file. In order to prevent model prop from being mutated in a child and do this in a parent like Vue two-way binding requires, the whole object needs to be cloned to change one field. This is inefficient and can be avoided by providing a child with only data it needs (metadata instead of file) and updating file in a parent with custom event rather than v-model.

    So it would be in the child:

      const props = defineProps<{ data: FileMeta }>();
    
      function updateModelField({ key, value }) {
        emit('update:field', { key, value });
      }
    

    In a parent:

    <FileMetaForm
      :data="file.metadata"
      @update:field="file.metadata[$event.key] = $event.value"
    >