Vue Props: The Real Talk About Component Data Flow

Posted on Sep 21, 2025

Props look simple until they’re not. You pass data from parent to child, everything should work. But then your component stops updating, reactivity breaks, and you’re debugging at midnight wondering why your perfectly reasonable code doesn’t work.

I’ve debugged prop issues across multiple projects—same patterns keep breaking the same ways. Here’s what you’ll face and how to fix it.

1. The Destructuring Death Trap

Problem: You destructure props and lose reactivity.

<script setup>
// You extract props directly...
const { user } = defineProps({ user: Object })

// ...but Vue can no longer track changes to user.firstName
const displayName = computed(() => user.firstName)

// If the parent updates user.firstName, displayName never updates.
</script>

Solution: Use toRefs() to keep the reactive link.

<script setup>
import { toRefs, computed } from 'vue'

// Keep the props object intact
const props = defineProps({ user: Object })

// Convert props.user into a reactive ref
const { user } = toRefs(props)

// Now displayName updates whenever user.firstName changes
const displayName = computed(() => user.value.firstName)
</script>

2. The Mutation Warning That Won’t Stop

Problem: Directly modifying a prop triggers a Vue warning and breaks one-way data flow.

<script setup>
const props = defineProps({ todos: Array })

// BAD: This adds to the parent’s todos directly
const addTodo = () => {
  props.todos.push({ id: Date.now(), text: 'New item' })
}
</script>

Solution: Emit events so the parent makes the change.

Child (TodoList.vue):

<template>
  <button @click="addTodo">Add Todo</button>
</template>

<script setup>
const props = defineProps({ todos: Array })
const emit = defineEmits(['add-todo'])

// Child asks parent to add the item
const addTodo = () => {
  const newTodo = { id: Date.now(), text: 'New item' }
  emit('add-todo', newTodo)
}
</script>

Parent (App.vue):

<template>
  <TodoList :todos="todos" @add-todo="handleAddTodo" />
</template>

<script setup>
import { ref } from 'vue'
import TodoList from './TodoList.vue'

const todos = ref([])

// Parent owns and mutates its data
const handleAddTodo = (newTodo) => {
  todos.value.push(newTodo)
}
</script>

3. Async Data Loading Issues

Problem: Child renders before async props arrive, leading to errors.

<template>
  <UserCard :user="userData" :loading="loading" />
</template>

<script setup>
import { ref, onMounted } from 'vue'
const userData = ref(null)
const loading = ref(true)

onMounted(async () => {
  userData.value = await fetchUserFromAPI()
  loading.value = false
})
</script>

If userData is null, accessing user.name in the child errors out.

Solution: Handle all states in the child template.

<template>
  <div v-if="loading">Loading</div>
  <div v-else-if="user">
    <h2>{{ user.name }}</h2>
    <p>{{ user.email }}</p>
  </div>
  <div v-else>No user data</div>
</template>

<script setup>
const props = defineProps({
  user: Object,
  loading: Boolean
})
</script>

4. Default Values Don’t Merge

Problem: Object defaults are not merged with passed-in values.

const props = defineProps({
  config: {
    type: Object,
    default: () => ({ theme: 'dark', size: 'medium' })
  }
})
// Passing { theme: 'light' } yields only { theme: 'light' }

Solution: Merge defaults using a computed property.

<script setup>
import { computed } from 'vue'

const props = defineProps({
  config: {
    type: Object,
    default: () => ({})
  }
})

const mergedConfig = computed(() => ({
  theme: 'dark',
  size: 'medium',
  showIcons: true,
  ...props.config
}))
</script>

<template>
  <div :class="mergedConfig.theme">
    <!-- uses mergedConfig.size and mergedConfig.showIcons -->
  </div>
</template>

5. Props Not Updating in Templates

Problem: Copying a prop into local state stops updates.

<script setup>
const props = defineProps({ initialValue: String })

// Only initialValue is captured
const currentValue = ref(props.initialValue)
</script>

Changes to props.initialValue never reflect in currentValue.

Solution A: Use a computed value directly.

const currentValue = computed(() => props.initialValue)

Solution B: Watch the prop and update local state.

<script setup>
import { ref, watch } from 'vue'
const props = defineProps({ initialValue: String })
const currentValue = ref(props.initialValue)

watch(() => props.initialValue, (newVal) => {
  currentValue.value = newVal
}, { immediate: true })
</script>

6. Performance Killers

Problem: Passing new object or array literals in templates causes unnecessary re-renders.

<!-- BAD: new object each time -->
<MyComponent :options="{ dark: isDarkMode }" />

Solution: Move to a computed ref.

<script setup>
import { computed } from 'vue'
const props = defineProps({ isDarkMode: Boolean })
const options = computed(() => ({ dark: props.isDarkMode }))
</script>

<template>
  <MyComponent :options="options" />
</template>

7. Object Props and Deep Mutations

Problem: Vue only blocks top-level prop mutation. Nested fields can be mutated silently.

props.user.details.age = 30 // no warning, but breaks data flow

Solution: Always emit changes to the parent or clone objects before mutating.

8. Debug Props Like a Pro

Use Vue’s render hooks to trace what triggers updates:

import { onRenderTracked, onRenderTriggered } from 'vue'

onRenderTracked((e) => console.log('Tracked:', e.key))
onRenderTriggered((e) => console.log('Triggered:', e.key))

This reveals which props or dependencies cause a component to re-render.


The Rules That Actually Matter

  • Never destructure props without toRefs().
  • Don’t mutate props—emit events instead.
  • Handle async props in the child template.
  • Merge object defaults manually.
  • Keep prop references stable with computed refs.
  • Use computed/watch wisely for local copies.

Props are the foundation of Vue components. Master these real-world patterns and your components will be predictable, performant, and easy to maintain.