Vue Props

It is a farily common scenario in any system that the frontend receives some initial data from a backend, and then modifies and sends it back.

If the UI is more complex, and consist of nested elements, usually the parent component gets the data, and passes down to its children as properties. Once the child modifies it, it has to push back the changes to the parent component responsible for the API communication. Vue actually doesn't make it simple to achive this, with the readonly properties.

Let me summarise the requirements:

  • The child component must be able to recieve an initial value
  • It also must be able to reflect any changes to this value later
  • And finally must be able to change this data, and communicate this changes to its parent

Parent component

Here is a simple example of a parent components, having a reactive counter property, passed down to it's child:

<template>
    <Panel>
        <template #header>
            <header class="p-5 m-5">
                <div class="wrapper"></div>
            </header>
        </template>

        <main class="p-5 flex flex-row gap-5">
            <Card class="basis-1/2">
                <template #title>Counter: {{ counter }}</template>
                <template #content>
                    <CounterComponent :counter="counter" @update:counter="(event: number) => (counter = event)" />
                </template>
                <template #footer>
                    <Button @click="counter++">Increment</Button>
                    <Button @click="counter--">Decrement</Button>
                </template>
            </Card>
        </main>
    </Panel>
</template>

<script setup lang="ts">
import { ref, type Ref } from 'vue'
import Card from 'primevue/card'
import Button from 'primevue/Button'

const counter: Ref<number> = ref<number>(10)
</script>

Naive implementation

The naive implementation of the children is to also have a property called counter:

<template>
  <Card>
    <template #content>
      <InputNumber v-model="props.counter" />
    </template>
    <template #footer>
      <Button @click="props.counter++">Increment</Button>
      <Button @click="props.counter--">Decrement</Button>
    </template>
  </Card>
</template>

<script setup lang="ts">
import InputNumber from 'primevue/inputnumber'
import Card from 'primevue/card'
import Button from 'primevue/Button'
import { computed } from 'vue'

const props = defineProps({
  counter: {
    type: Number,
    required: true
  }
});
</script>

There is one massive problem with this:

Properties are readonly in Vue.

Reactive variable

We can change our implementation that the children also have a reactive counter variable, and maybe copy out the initial value of the property:

<template>
  <Card>
    <template #content>
      <InputNumber v-model="counter" />
    </template>
    <template #footer>
      <Button @click="counter++">Increment</Button>
      <Button @click="counter--">Decrement</Button>
    </template>
  </Card>
</template>

<script setup lang="ts">
import InputNumber from 'primevue/inputnumber'
import Card from 'primevue/card'
import Button from 'primevue/Button'
import { ref } from 'vue'

const props = defineProps({
  counter: {
    type: Number,
    required: true
  }
});

const counter = ref(props.counter);
</script>

While this solution eliminates the problem with not being able to update the counter from within the child component, and also receives the initial value from the parent component, it won't react if it gets updated in the parent:

Making a field of the props reactive disconnects it from the parent data.

Writeable computed property

A common solution to this is something called writeable computed property, which involves a computed variable, where the getter part returns the property directly, while the setter part emits an event to the parent component, and delegates the responsibility of updating the data.

<template>...</template>

<script setup lang="ts">
import InputNumber from 'primevue/inputnumber'
import Card from 'primevue/card'
import Button from 'primevue/Button'
import { ref } from 'vue'

const props = defineProps({
  counter: {
    type: Number,
    required: true
  }
});

const emit = defineEmits(['update:counter']);

const counter = computed({
  get() {
    return props.counter
  },
  set(value) {
    emit('update:counter', value)
  }
});
</script>

This solves all our initial requirements, it can receive initial data from the parent, can update the value, and this update is emitted to the parent.

VueUse composable

This is a common solution, as a matter of fact the VueUse library has a composable called useVModel to simplify this syntax:

<template>...</template>

<script setup lang="ts">
import InputNumber from 'primevue/inputnumber'
import Card from 'primevue/card'
import Button from 'primevue/button'
import { useVModel } from '@vueuse/core'

const props = defineProps({
    counter: {
        type: Number,
        required: true
    }
});

const emit = defineEmits(['update:counter']);
const counter = useVModel(props, 'counter', emit);
</script>

There is only one big issue with this. This component can not work on it's own, it always relies on it's parent to update the data.

If the update event is not handled in the parent, the children can't update its own internal state on its own.

This can be easy to overlook, and might be hard to debug.

So how to tackle this?

Watchers

In general I'm not a huge fan of watchers, but in this case this one seems to be the final solution:

  • The children can get an initial data from its parent
  • If the data is changed in the parent, it will be reflected in the children
  • The children can modify the data internally
  • This update can be emitted to the parent - but it is not a requirement for the child component to work
<template>
    <Card>
        <template #title>Child counter: {{ counter }}</template>
        <template #content>
            <InputNumber v-model="counter" />
        </template>
        <template #footer>
            <Button @click="counter++">Increment</Button>
            <Button @click="counter--">Decrement</Button>
        </template>
    </Card>
</template>

<script setup lang="ts">
import InputNumber from 'primevue/inputnumber'
import Card from 'primevue/card'
import Button from 'primevue/button'
import { ref, watch, type Ref } from 'vue'

const props = defineProps({
    counter: {
        type: Number,
        required: true
    }
});

const emit = defineEmits(['update:counter']);
const counter: Ref<number> = ref(props.counter);

watch(() => props.counter, (value: number) => counter.value = value);
watch(() => counter.value, (value: number) => emit('update:counter', value));

</script>