Local First

Keep state in the component that owns it, lift it only when a sibling needs it, and know what lifting early costs.

Syntaxconst visible = ref(false)

State is local when it is declared inside the component that uses it and nowhere else. A ref in <script setup> is local by construction: every instance of the component creates its own, and nothing outside the file can read it.

Local is the right default. One reader, one writer, one place to look when the value is wrong. A toggle that owns its open flag can be dropped into any page with no wiring.

Lifting

Lift when a second component needs the same value. Move the ref to the nearest parent that renders both, pass it down as a prop, and send changes back up as an emitted event. The parent is now the owner. The children read the prop and never write to it, because props are read-only and the development build warns when you assign to one.

What lifting too early costs

  • The child can no longer stand alone. It needs a parent to hold its state before it will work at all.
  • Every component between the owner and the user has to declare the prop and pass it on. That is prop drilling, and it makes small changes touch many files.
  • Anything under the owner can now read the value, so the blast radius of a change grows.

Lifting later is a small edit: move one line, add a prop, add an emit. Pushing state back down is harder, because by then other components depend on it. When in doubt, leave it where it is and wait for the second reader to actually appear.

Example

Example · vue
<script setup lang="ts">
import { ref, useId } from 'vue'

// The parent owns the text. This component owns only whether it is visible.
const model = defineModel<string>({ required: true })

const visible = ref(false)
const fieldId = useId()

function toggle(): void {
  visible.value = !visible.value
}
</script>

<template>
  <div class="field">
    <label :for="fieldId">Password</label>
    <div class="row">
      <input
        :id="fieldId"
        v-model="model"
        :type="visible ? 'text' : 'password'"
        autocomplete="current-password"
      />
      <button type="button" :aria-pressed="visible" @click="toggle">
        {{ visible ? 'Hide' : 'Show' }}
      </button>
    </div>
  </div>
</template>

<style scoped>
.field {
  display: grid;
  gap: 0.25rem;
}

.row {
  display: flex;
  gap: 0.5rem;
}
</style>

When to use it

  • A dropdown that tracks its own open state and needs no wiring from the page that renders it.
  • Two panes that must agree on the selected row, so the id moves up to the page that renders both.
  • A password field that owns its show/hide toggle while the parent owns the text value.
  • Deciding whether a flag belongs in the component or has outgrown it.

More examples

Lifting one value to the nearest common parent

InvoicesPage.vue is the smallest component that renders both the list and the detail pane, so it is the right owner of selectedId. The value moved up exactly one level, not into a store. Reach for this the moment a second component needs to read or change the same value. Note that the page passes data down with props and receives changes through @select, which is the shorthand for v-on:select.

Example · vue
<script setup lang="ts">
import { ref } from 'vue'
import InvoiceList from './InvoiceList.vue'
import InvoiceDetail from './InvoiceDetail.vue'

const invoices = [
  { id: 'inv_1', reference: 'INV-0001' },
  { id: 'inv_2', reference: 'INV-0002' }
]

// Both children need the selected id, so it lives in the parent that renders both.
const selectedId = ref<string | null>(null)

function select(id: string): void {
  selectedId.value = id
}
</script>

<template>
  <div class="layout">
    <invoice-list
      :invoices="invoices"
      :selected-id="selectedId"
      @select="select"
    />
    <invoice-detail :invoice-id="selectedId" />
  </div>
</template>

<style scoped>
.layout {
  display: grid;
  grid-template-columns: 16rem 1fr;
  gap: 1rem;
}
</style>

The child reads the prop and emits instead of writing it

InvoiceList.vue renders a button per invoice and marks the selected one; clicking one emits select with that id. The list holds no state of its own. Assigning to props.selectedId here would warn in the development build and would not reach the parent, so the change travels back as an event. The :key on v-for is the invoice id, never the loop index, because the list can be reordered or filtered.

Example · vue
<script setup lang="ts">
interface Invoice {
  id: string
  reference: string
}

const props = defineProps<{
  invoices: Invoice[]
  selectedId: string | null
}>()

const emit = defineEmits<{
  select: [id: string]
}>()
</script>

<template>
  <ul>
    <li v-for="invoice in props.invoices" :key="invoice.id">
      <button
        type="button"
        :aria-current="invoice.id === props.selectedId ? 'true' : 'false'"
        @click="emit('select', invoice.id)"
      >
        {{ invoice.reference }}
      </button>
    </li>
  </ul>
</template>

State that was lifted and should not have been

SiteMenu.vue owns its open flag. Earlier versions of this component often take menuOpen as a prop with an @update:menu-open emit, because someone lifted it before anything else needed it; the page then holds a flag it never reads. Pushing it back down is this diff: delete the prop and the emit, add one ref. Reach for this shape whenever a prop exists only so the child can change it back again.

Example · vue
<script setup lang="ts">
import { ref } from 'vue'

// Only this component reads menuOpen, so it belongs here rather than in the page.
const menuOpen = ref(false)

function close(): void {
  menuOpen.value = false
}
</script>

<template>
  <nav @focusout="close">
    <button type="button" @click="menuOpen = !menuOpen">Menu</button>
    <ul v-if="menuOpen">
      <li><a href="/invoices">Invoices</a></li>
      <li><a href="/settings">Settings</a></li>
    </ul>
  </nav>
</template>

<style scoped>
nav {
  position: relative;
}
</style>

Discussion

  • Be the first to comment on this lesson.