How would you write a small Vue 3 i18n plugin that offers a $t helper in templates and a provided API for script setup code?
answer
- install gets messages as options
- reactive locale inside install
- globalProperties for templates
- provide with an InjectionKey
- script setup has no this
basics
~20 sExport an object whose install(app, options) keeps the locale in reactive state, assigns a $t function to app.config.globalProperties for templates, and calls app.provide with an InjectionKey so script setup code can inject the same typed API.
solid answer
~30 sThe plugin receives the messages and starting locale as `app.use` options. Inside `install` it creates per-app `reactive` state for the locale and a `t(key)` function that looks up `messages[locale][key]`. It then exposes that API twice: `app.config.globalProperties.$t = t` makes `{{ $t('key') }}` work in every template and on Options API `this`, and `app.provide(I18nKey, i18n)` makes the whole object injectable. The provide matters because `<script setup>` code has no `this`, so global properties are not reachable there; `inject(I18nKey)` gives it a typed object. Because `t` reads reactive state during render, changing the locale re-renders every template that called `$t`.
code
ts · 35 linesimport { reactive, type App, type InjectionKey, type Plugin } from 'vue'
export interface I18nOptions {
locale: string
messages: Record<string, Record<string, string>>
}
export interface I18n {
readonly locale: string
setLocale(locale: string): void
t(key: string): string
}
export const I18nKey: InjectionKey<I18n> = Symbol('i18n')
export const i18nPlugin: Plugin<[I18nOptions]> = {
install(app: App, options: I18nOptions) {
const state = reactive({ locale: options.locale }) // per app
const i18n: I18n = {
get locale() {
return state.locale
},
setLocale(locale) {
state.locale = locale
},
t(key) {
return options.messages[state.locale]?.[key] ?? key
},
}
app.config.globalProperties.$t = i18n.t // templates, Options API
app.provide(I18nKey, i18n) // script setup, composables
},
}go deeper
Recall that install receives the options from app.use, and that globalProperties is how a helper like $t reaches every template.
Explain why the plugin exposes both globalProperties and a provide, why the locale must be reactive, and how an InjectionKey types the injected API.
Discuss per-app state, name collisions in the $ namespace, missing-key behaviour and why a composable wrapper around inject fails faster than raw inject.
Weigh a hand-written plugin against adopting a library: plural rules, lazy locale loading and formatting are where home-grown i18n usually stops scaling.
## The goal An internationalisation (**i18n**) plugin maps message keys such as `home.title` to text in the current language. A useful minimal version needs three things: a message table passed in at install time, a **current locale** that can change at runtime, and a way to translate from both templates and component logic. Vue 3 gives a plugin exactly two app-wide channels for this, and a good answer uses both on purpose. ## Step by step 1. **Accept options.** `app.use(i18nPlugin, { locale: 'en', messages })` passes the object as the second argument of `install(app, options)`. 2. **Create state inside install.** `reactive({ locale: options.locale })` is created per app. Keeping it inside `install`, not at module level, means two apps installing the plugin get separate locales. 3. **Write the translate function.** `t(key)` reads `state.locale` and returns `options.messages[state.locale]?.[key] ?? key`, falling back to the key so missing translations are visible rather than blank. 4. **Expose it to templates.** `app.config.globalProperties.$t = t` adds `$t` to every component's public instance, which is what templates and Options API `this` read from. 5. **Expose it to composition code.** `app.provide(I18nKey, i18n)` makes the full API (`t`, `locale`, `setLocale`) available to any component in the app via `inject(I18nKey)`. ## Why both channels | Channel | Reached from | Typed via | Weakness | |---|---|---|---| | `app.config.globalProperties.$t` | templates, Options API `this` | augmenting `ComponentCustomProperties` | not reachable through `this` in `<script setup>`; one flat namespace shared by all plugins | | `app.provide(I18nKey, i18n)` | `inject()` in `setup` / `<script setup>` / composables called from them | the `InjectionKey<T>` symbol | must be injected explicitly in each component | `<script setup>` compiles to a `setup()` function in which there is no component `this`, so writing `this.$t(...)` there fails. Templates in the same component can still call `$t`, because template expressions are resolved through the component's public instance, which falls back to `globalProperties`. The provide channel covers the logic side, and with a symbol `InjectionKey<I18n>` the injected value is typed without casts. A plain string key would work at runtime but leave `inject` returning an untyped value. ## Why the locale must be reactive Vue re-renders a component when state it **read during render** changes. `$t('home.title')` runs inside the render, so it reads `state.locale`; Vue tracks that read. When `setLocale('fr')` writes `state.locale`, every component whose render called `$t` is scheduled to re-render. A plain `let locale` variable is invisible to that tracking: the language would change only when something unrelated happened to re-render each component. ## What happens during a locale switch 1. A button handler calls `i18n.setLocale('fr')`, which writes `state.locale`. 2. That reactive write notifies every effect that read `state.locale`: the render effects of components whose templates called `$t` or `i18n.t`, plus any `computed` or `watch` that read it. 3. Vue queues those component updates and runs them in one batched flush on a microtask, so each affected component re-renders once, however many `$t` calls it contains. 4. Components that never translated anything are not touched, because they never read the locale. This is also why caching a translation in a plain variable at setup time is a bug: `setup` runs once per component instance, so a string computed there stays in the old language. Translate in the template, or wrap it as `computed(() => i18n.t('key'))`, so it is re-read when the locale changes. ## Details that trip people up - **`this` inside `$t` is not the component.** In Vue 3, a function stored in `globalProperties` is returned as-is when a template reads it, not bound to the calling instance, so write `t` as a closure over plugin state. - **Name collisions.** A component's own property with the same name wins over a global property, and every plugin shares the `$` namespace, so keep global properties few and prefixed. - **Install once per app.** A second `app.use(i18nPlugin, otherOptions)` on the same app is skipped, so it cannot be used to swap message tables; expose a method for that instead. - **Missing keys.** Returning the key makes gaps visible in the UI; returning an empty string hides them. ## When to reach for a library instead This is interview-sized. Real i18n needs plural rules, interpolation, lazy-loaded locale files and date and number formatting. Mature i18n libraries for Vue follow the same shape: a factory that builds the plugin object, a global helper for templates and an injectable API for composition code.
- How would a composable such as useI18n() fit into this plugin?Export `function useI18n() { const i18n = inject(I18nKey); if (!i18n) throw new Error('i18n plugin not installed'); return i18n }`. Components call it from `<script setup>`, get the typed API, and fail fast with a clear message when the plugin is missing, instead of getting `undefined` deep inside a handler.
- Why not put the locale in a module-level reactive object shared by the plugin file?Module code runs once per JavaScript realm, so every app that installs the plugin would share one locale, and in server rendering every request would too. Creating the state inside `install` gives each app its own copy, which is what an app-scoped plugin should promise.
- What happens if a Vue 3 component defines its own $t method while the i18n plugin also sets a $t global property?The component's method wins: the instance proxy only falls back to `globalProperties` after the component's own properties. The shadowing is silent, which is one reason to keep global properties few and distinctively named. A `$`-prefixed key returned from `data()` or `setup()` is not exposed at all, because Vue reserves that prefix.
saying these in an interview costs you the question
- $t in globalProperties is available as a variable inside script setup code
- A plain module-level locale variable will re-render templates when it changes
- this inside a global property function is the calling component in Vue 3
- app.provide makes the value available to every Vue app on the page
- A string provide key gives inject() the value's type automatically