In Vue 3, why does an injected locale stop updating when the provider passes locale.value, and how should injectors change it?
answer
- a snapshot versus the ref
- injected refs are not unwrapped
- mutations live with the provider
- readonly plus an updater function
basics
~20 sprovide('locale', locale.value) provides a plain string captured once, so later changes never reach injectors. Provide the ref itself, ideally wrapped in readonly(), plus a setLocale function, so injectors stay reactive and all changes happen in the provider.
solid answer
~40 s`provide()` hands over whatever value you pass. `locale.value` is a plain string, so injectors get a snapshot of the locale at setup time and never see a change. Provide the ref (or a `reactive()` object, or a `computed()`) instead: Vue injects a ref as-is, **without unwrapping it**, so the injector holds the same ref and stays linked to the provider. The Vue docs then recommend keeping mutations in the provider: provide `readonly(locale)` so injectors cannot assign it, and provide a function such as `setLocale(code)` for the cases where a descendant, like a language switcher, must change it. A readonly ref refuses writes with a development warning. The result is one owner of the state, reactive readers anywhere below it, and every change going through a function you can find.
code
vue · 16 lines<script setup lang="ts">
import { inject } from 'vue'
import type { Ref } from 'vue'
const { locale, setLocale } = inject('locale') as {
locale: Readonly<Ref<string>>
setLocale: (code: string) => void
}
</script>
<template>
<select :value="locale" @change="setLocale(($event.target as HTMLSelectElement).value)">
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
</template>go deeper
Remember to provide the ref itself, not its .value, when the value will change.
Explain that injected refs are not unwrapped, why a primitive is a snapshot, and how readonly() plus an updater function keeps mutations at the provider.
Design provided APIs as a readonly state view plus intent-named functions, so changes are validated in one place and injectors cannot write shared state by accident.
Treat every provided mutable value as a shared API with an owner, and require readonly views with explicit updaters for anything injected across teams.
## The bug A layout provides the current locale, and a deeply nested date component formats dates with it: ```vue <script setup> import { ref, provide } from 'vue' const locale = ref('en') provide('locale', locale.value) // provides the string 'en' </script> ``` When the user switches to German, `locale.value` becomes `'de'`, but every injector still shows English dates. Nothing is broken in Vue: `provide()` stored exactly what it was given, a **plain string**. A string has no connection to the ref it was read from. ## Provide the reactive source, not its value Vue does not make provided values reactive for you. Reactivity travels only if the provided value **is** reactive: - a `ref`: injected **as-is and not unwrapped**, so the injector holds the same ref and reads `.value`; - a `reactive()` object: the injector reads its properties reactively; - a `computed()`: a derived, read-only view of provider state; - a getter function: the injector calls it inside its own computed or template. ```ts provide('locale', locale) // the ref itself ``` ```ts const locale = inject('locale') // Ref<string> const formatted = computed(() => new Intl.DateTimeFormat(locale.value).format(date)) ``` In a template, a ref held in a top-level `<script setup>` binding is unwrapped as usual, so `{{ locale }}` renders the string. ## Keep mutations in the provider Once the ref is shared, any injector could assign `locale.value = 'fr'`. The Vue docs recommend keeping mutations of provided reactive state **inside the provider whenever possible**, so the state and the ways it changes live in one component. Two tools make that practical: 1. **`readonly()`** around the provided value. Injectors can read and react to it, but an assignment is refused, with a development warning. 2. **An updater function** provided alongside it, for the legitimate cases where a descendant must request a change. ```ts import { ref, readonly, provide } from 'vue' const locale = ref('en') function setLocale(code: string) { if (['en', 'de', 'fr'].includes(code)) locale.value = code } provide('locale', { locale: readonly(locale), setLocale }) ``` A language switcher deep in the tree injects `{ locale, setLocale }`, renders `locale`, and calls `setLocale('de')`. Validation, persistence of the choice, or loading translation files happens in one place. ## Comparing the options | What the provider passes | Injector sees changes | Injector can assign | |---|---|---| | `locale.value` | no, a snapshot | only its own copy | | `locale` (ref) | yes | yes, directly into shared state | | `readonly(locale)` | yes | no, refused with a development warning | | `readonly(locale)` + `setLocale` | yes | only through the provider's function | ## Common variations The same rule, provide something reactive, covers several shapes: - **A derived value**: `provide('dir', computed(() => (rtlLocales.includes(locale.value) ? 'rtl' : 'ltr')))` gives descendants a read-only, always-current text direction. - **A reactive object**: `provide('i18n', reactive({ locale: 'en', messages: {} }))`, wrapped in `readonly()` for injectors, groups several related fields. - **A getter function**: `provide('locale', () => locale.value)`; injectors call it inside their own `computed` or template, which keeps it reactive and makes writing impossible by construction. Pick the smallest shape that says what injectors are allowed to do. ## Things to remember - `readonly()` protects writes made **through the provided object**; the provider's own ref stays writable, which is the point. - Destructuring an injected object is fine when its members are refs or functions, as above; destructuring plain values out of a `reactive()` object would copy them and lose reactivity. - The same rules apply to `app.provide()`: provide a ref or reactive object if the value will change. - Provided objects are not copied per injector; every injector receives the same reference. ## Why interviewers ask this It tests whether a candidate understands that reactivity in Vue belongs to values, not to the channel that delivers them. A candidate who says "provide/inject is reactive" without qualification will ship the `locale.value` bug; a strong answer names the ref-as-is behaviour and the readonly-plus-updater convention.
- Is a value injected in Vue automatically reactive?Only if the provided value is reactive. A ref, a `reactive()` object or a `computed()` stays live because the injector receives the same object. A primitive such as `locale.value` is a snapshot. The injection channel itself adds no reactivity.
- What happens if an injector assigns to a ref provided through readonly()?The write is refused and the ref keeps its value; a development build logs that the target is readonly. The provider's own ref is unaffected by the wrapper and remains writable, so changes go through the provider or a function it provides.
saying these in an interview costs you the question
- provide/inject makes any provided value reactive automatically.
- Providing locale.value keeps injectors in sync with the ref.
- Vue unwraps a provided ref, so the injector gets a plain string.
- readonly() on the provided ref also stops the provider from changing it.
- Injectors should assign to the shared ref directly whenever they need to.