Search code examples
vue-componentvuejs3nuxt3.js

Vue3 dynamically watching child component data


I'm working in Nuxt3 and I've got a slightly unusual setup trying to watch or retrieve data from child components in a complex form that is structured as a multi-step wizard. It's obviously Vue underneath and I'm using the composition API.

My setup is that I have a page the wizard component is on, and that component has a prop that is an array of steps in the wizard. Each of these steps is some string fields for titles and labels and then a component type for the content. This way I can reuse existing form blocks in different ways. The key thing to understand is that the array of steps can be any length and contain any type of component.

Ideally, I'd like each child component to be unaware of being in the wizard so it can be reused elsewhere in the app. For example, a form that is one of the steps should handle its own validation and make public its state in a way the wizard component can read or watch.

The image below explains my basic setup.

                             enter image description here

The page includes this tag:

<Wizard :steps="steps" :object="project" @submit="createProject"/>

The Wizard loops over the steps to create each component.

<div v-for="(step) in steps" :key="step.name">
  <component v-if="step.status === 'current'" :is="step.content.component" />
</div>

The data to setup the component with the right props for the wizard itself and the child component props.

const steps = ref([
{ 
    name: 'overview',
    title: t('overview'),
    subTitle: t('projectCreateOverviewDescription'),
    status: 'current',
    invalid: true,
    content: {
        component: Overview,
        props: null,
        model: {}
    }
},
{ 
    name: 'members',
    title: t('members'),
    subTitle: t('projectCreateMembersDescription'),
    status: 'upcoming',
    invalid: false,
    content: {
        component: ThumbnailList,
        props: {
            objects: users,
            title: t('users'),
            objectNameSingular: t('user'),
            objectNamePlural: t('users'),

So far I've tried to dynamically create references in the wizard component to watch the state of the children but those refs are always null. This concept of a null ref seems to be the accepted answer elsewhere when binding to known child components, but with this dynamic setup, it doesn't seem to be the right route.

interface StepRefs {
   [key: string]: any
}

let stepRefs: StepRefs = {}

props.steps.forEach(step => {
    stepRefs[step.name] = ref(null)

    watch(() => stepRefs[step.name].value, (newValue, oldValue) => {
        console.log(newValue)
        console.log(oldValue)
    }, { deep: true })
})

Can anyone direct me to the right approach to take for this setup? I have a lot of these wizards in different places in the app so a component approach is really attractive, but if it comes to it I'll abandon the idea and move that layer of logic to the pages to avoid the dynamic aspect.


Solution

  • To handle changes in child components I'd recommend to use events. You can have the children emit an event on change or completion, and the wizard is listening to events from all children and handling them respectively.

    On the wizard subscribe to the event handler of the step component, and process the data coming from each step on completion (or whatever stage you need).

    This way you don't need any special data type for the steps, they can just be an array. Simply use a ref to keep track of the current step. You don't even need a v-for, if you just display one step at a time. For a wizard navigation you might still need a v-for, but it would be much simpler. Please see a rough example below.

    <div>
     <stepComponent step="currentStep" @step-complete="handleStepComplete"/>
     <div>
      <wizardNavigationItemComponent v-for="step in steps" :active="step.name === currentStep.name" />
     </div>
    </div>
    
    <script setup lang="ts">
    
     const steps = step[/*your step data here*/]
    
     const currentStepIndex = ref(0)
     const currentStep = ref(steps[currentStepIndex.value])
    
     function handleStepComplete(data) {
      /* handle the data and move to next step */
      currentStepIndex.value =  currentStepIndex.value + 1 % steps.length
     }
    
    </script>
    

    In the component you just need to define the event and emit it when the data is ready, to pass along the data:

    <script setup lang="ts">
    
    const emit = defineEmits<{
      (event: "stepComplete", data: <your data type>): void
    }>()
    
    /* call emit in the component when its done / filled */
    emit("stepComplete", data)
    
    </script>
    

    I hope this helps and can provide a viable path forward for you!