DEV Community

Sibasish Mohanty
Sibasish Mohanty

Posted on

Props & Emits: Component Communication in Vue

In the last post, we covered the foundations of Vue and deep dive into Vue’s mental model and how it contrasts React’s hooks and JSX.

I’ve followed the classic React Tic-Tac-Toe tutorial enough times to finish it in my sleep 😅. Today, let’s build the same game in Vue. Along the way, you’ll see how Vue’s props, emits, reactive arrays, and computed properties map to (and simplify) React patterns like callback props, immutable state updates, and useMemo.

Creating a Square that emits clicks

Each cell shows a value ('X', 'O', or blank) and notifies its parent when clicked. In React, You pass an onSelect callback prop down the tree and call it on click:

<!-- Square.jsx -->
  <button className="square" onClick={onSelect}>
    {value}
  </button>
Enter fullscreen mode Exit fullscreen mode

Vue inverts this: children emit events, parents listen. In React, you lift state up from individual squares into the Board component so that all Squares share the same array. In Vue, the pattern is the same conceptually but uses reactive state in the parent and props/emits for communication.

<!-- Square.vue -->
<template>
  <button class="square" @click="select">{{ value }}</button>
</template>

<script setup>
const props = defineProps({ value: String })
const emit  = defineEmits(['select'])

function select() {
  emit('select')
}
</script>
Enter fullscreen mode Exit fullscreen mode
  • Decoupled communication: The square only cares that it fires a “select” event. It doesn’t need a function reference or prop name.
  • Closer to the DOM model: Just like native <button> fires a click, Vue components fire custom events—melding component logic with browser mental models.
  • Cleaner templates: No need to pass down and name callbacks (e.g. onSelect). You simply write @click="select" and @select="..." wherever you embed <Square>.

Managing a reactive board state

In React you maintain immutability explicitly, cloning the array on every move:

const [squares, setSquares] = useState(Array(9).fill(null))
const [xIsNext, setXIsNext] = useState(true)

function handleClick(i) {
  // 1. Prevent overwriting a filled square or past winner
  if (squares[i] || calculateWinner(squares)) return

  // 2. Create a copy of the array
  const next = squares.slice()

  // 3. Mutate the copy
  next[i] = xIsNext ? 'X' : 'O'

  // 4. Update state
  setSquares(next)
  setXIsNext(!xIsNext)
}
Enter fullscreen mode Exit fullscreen mode

Every change requires a new array (slice + direct index assignment) to preserve previous state. You call setSquares(next) and setXIsNext(!xIsNext) to trigger a re-render. React diffs the new virtual DOM against the old, then patches the real DOM. React’s model gives you clear versioned state but at the cost of more boilerplate.

Vue’s Proxy approach feels more like working with plain JS objects. Instead of copying arrays on every move, Vue wraps your board in a Proxy so you can mutate it in place—and Vue will still know exactly what changed.

<script setup>
import { reactive, ref } from 'vue'

 // 1. Declare a reactive array
const squares = reactive(Array(9).fill(null))

 // 2. A simple ref for toggling players
const xIsNext = ref(true)

function handleClick(i) {
  // 3. Guard against re-clicks and early exit if there’s a winner
  if (squares[i] || winner.value) return

  // 4. Directly mutate the array
  squares[i] = xIsNext.value ? 'X' : 'O'

  // 5. Toggle the next-player ref
  xIsNext.value = !xIsNext.value
}
</script>
Enter fullscreen mode Exit fullscreen mode
  • Proxy-based tracking: reactive(array) wraps your array in a Proxy. Mutations—like squares[i] = 'X' or squares.push(...) are intercepted, so Vue knows exactly which index changed.
  • Fine-grained updates: Vue’s compiler adds patch flags to your render functions, ensuring only the changed squares re-render.

Rendering Squares & Listening to Moves

In React, you’d write:

<div className="board-row">
  {[0,1,2].map(i => (
    <Square
      key={i}
      value={squares[i]}
      onSelect={() => handleClick(i)}
    />
  ))}
</div>
Enter fullscreen mode Exit fullscreen mode

You map an array inside JSX, pass a callback prop onSelect, and inline a lambda each render.

In Vue, the template stays HTML-centric:

<template>
  <div class="board-row" v-for="row in [0,3,6]" :key="row">
    <Square
      v-for="offset in 3"
      :key="row + offset - 1"
      :value="squares[row + offset - 1]"
      @select="() => handleClick(row + offset - 1)"
    />
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

v-for + :key reads like HTML: “for each row index…” and @select is shorthand for v-on:select, wiring child events inline without JSX lambdas.

In React, You pass onSelect down; child calls onSelect() while in Vue, Child emits select; parent listens with @select — no callback-prop plumbing. But a rookie mistake in Vue is trying to update a prop inside a child:

<script setup>
  const props = defineProps({ value: String })
  props.value = 'X'   // ❌ Vue warns: props are read-only
</script>
Enter fullscreen mode Exit fullscreen mode

Vue enforces one-way data flow. Instead, children must: Emit an event and let the parent mutate its own state.

Computing the Game Status

React recomputes derived value on every render, to avoid it you use in-built hooks like useMemo but most of the time you end up over-using it.

Vue’s computed handles this elegantly. Automatic caching as computed recalculates only when squares change, no manual dependency array. Logic stays in interpolation, separate from JS. But remember, not to mutate the computed value!

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

const winner = computed(() => {
  const lines = [
    [0,1,2],[3,4,5],[6,7,8],
    [0,3,6],[1,4,7],[2,5,8],
    [0,4,8],[2,4,6],
  ]
  for (const [a,b,c] of lines) {
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
      return squares[a]
    }
  }
  return null
})
</script>

<template>
  <p class="status">
    {{ winner
      ? `Winner: ${winner}`
      : `Next player: ${xIsNext ? 'X' : 'O'}` }}
  </p>
</template>
Enter fullscreen mode Exit fullscreen mode

Here’s the final Vue version of the Tic-Tac-Toe game, mirroring the core gameplay experience from the React tutorial. It follows the principles we just explored: props down, events up, reactive state in the parent.

By now, we’ve seen how Vue handles inter-component communication with a mental model that’s surprisingly simple once you get it. Instead of juggling useState, useEffect, and callback props, you emit events and let the parent decide what to do—clean separation, fewer footguns 🙃.

We’ve also built a fully reactive Tic-Tac-Toe board without writing a single setState, no deep memoization tricks or maintaining dependency arrays. Just intuitive, declarative logic.

But this is just the beginning.

In the next post, we’ll explore advanced reactivity patterns in Vue:

  • watch vs watchEffect
  • Computed properties in practice
  • Lifting state across nested components
  • Managing forms with validation and lifecycle hooks

We’ll move from just toggling state to building more interactive forms and flows, the kind that most real-world apps need. Think of this as the part where things get opinionated—and interesting.

Stay tuned. It’s about to get reactively fun.

Top comments (0)