Питання на співбесіді: Реактивність
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
15 питань
Обидва створюють реактивний стан, за яким Vue стежить і перемальовує шаблон при змінах.
ref(value)- обгортка над будь-яким значенням: числом, рядком, об'єктом. Значення лежить у.value. У шаблоні.valueписати не треба - Vue розгортає ref автоматично.reactive(object)- реактивний проксі над об'єктом (чи масивом,Map). Поля читаються напряму, без.value.
import { ref, reactive } from 'vue';
const count = ref(0);
count.value++;
const form = reactive({ email: '', password: '' });
form.email = 'olia@example.com';
Обмеження reactive:
- Працює лише з об'єктами -
reactive(0)не має сенсу. - Не можна замінити цілком:
form = reactive({...})розриває зв'язок із шаблоном, бо шаблон стежить за старим проксі. - Деструктуризація губить реактивність:
const { email } = formдає звичайний рядок.
Через ці обмеження документація Vue радить ref як основний інструмент: його можна передавати у функції, повертати з composables, перезаписувати цілком (items.value = newItems), і поведінка завжди однакова. reactive зручний для згрупованого стану на кшталт форми, який не замінюють і не деструктуризують.
computed - значення, похідне від іншого стану. Vue кешує результат і перераховує його, лише коли змінилися реактивні залежності.
const items = ref([]);
const total = computed(() => items.value.reduce((sum, i) => sum + i.price, 0));
Метод у шаблоні ({{ calcTotal() }}) викликається при кожному рендері компонента, навіть якщо items не змінювалися. Для важких обчислень це зайва робота.
watch - не значення, а побічна дія у відповідь на зміну: запит на сервер, запис у localStorage, ручна робота з DOM.
watch(searchQuery, async (query) => {
results.value = await search(query);
});
Як обирати:
- Потрібно значення для шаблону чи коду -
computed. - Потрібно щось зробити, коли значення змінилося, -
watch. - Потрібна дія з аргументами за запитом (обробник кліку) - метод.
Типова помилка - watch, що просто записує похідне значення в інший ref:
watch(items, () => { total.value = sum(items.value); }, { deep: true }); // зайве
Це той самий computed, але з ручною синхронізацією, яку легко зламати.
computed має бути чистим: без запитів, змін іншого стану чи DOM. Інакше результат залежатиме від того, коли й скільки разів Vue вирішить його перерахувати.
У шаблоні .value писати не треба - але лише для ref верхнього рівня, тобто змінних, оголошених прямо в <script setup> (чи повернених з setup()).
<script setup>
import { ref } from 'vue';
const count = ref(1);
const item = { id: ref(1) }; // ref усередині звичайного об'єкта
</script>
<template>
<p>{{ count + 1 }}</p> <!-- 2 -->
<p>{{ item.id }}</p> <!-- 1 -->
<p>{{ item.id + 1 }}</p> <!-- [object Object]1 -->
</template>
Чому так:
count- ref верхнього рівня, у виразі він розгорнутий;item.id- ref усередині звичайного об'єкта. Виразitem.id + 1додає до об'єкта-ref число;{{ item.id }}без обчислень показує1- це окреме правило: якщо кінцевий результат інтерполяції - ref, текст розгортається. Але для будь-якого виразу з ним - ні.
Як виправити:
const { id } = item; // зробити ref верхнього рівня
або явно {{ item.id.value + 1 }}.
Інші правила розгортання:
- у
reactive()ref, що лежить як властивість, розгортається при доступі:state.count, а неstate.count.value; - у масивах і колекціях (
reactive([ref(1)]),Map) - не розгортається, потрібно.value; - props приходять уже розгорнутими значеннями -
.valueне потрібен.
Практичне правило, що знімає плутанину: не класти ref-и всередину звичайних об'єктів. Або окремі ref-и на верхньому рівні, або один reactive/ref з об'єктом, поля якого - звичайні значення.
Інструменти допомагають: Vue - Official (Volar) у редакторі підсвічує, де .value потрібен, а TypeScript покаже, що item.id + 1 додає Ref<number> до числа.
Докладніше в документації: Особливість розгортання в шаблонах
За замовчуванням computed - лише для читання: значення виводиться з інших даних. Спроба записати дає попередження в консолі, і нічого не змінюється.
Записувана обчислювана властивість - об'єкт з get і set:
import { ref, computed } from 'vue';
const firstName = ref('Оля');
const lastName = ref('Коваль');
const fullName = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (value) => {
[firstName.value, lastName.value] = value.split(' ');
},
});
fullName.value = 'Іра Лі';
firstName.value; // 'Іра'
set не зберігає значення сам - він змінює вихідні дані, з яких get потім перерахує результат.
Типові застосування:
1. v-model над перетвореним значенням - наприклад, ціна в копійках у стані й у гривнях у полі:
const priceCents = ref(12550);
const priceUah = computed({
get: () => (priceCents.value / 100).toFixed(2),
set: (value) => { priceCents.value = Math.round(Number(value) * 100); },
});
<input v-model="priceUah" type="number" step="0.01">
2. Обгортка над props + emit для v-model на компоненті (до Vue 3.4 - стандартний шаблон, тепер його замінює defineModel):
const value = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
});
3. Синхронізація з URL чи сховищем: читання з параметра запиту, запис - через router.replace.
Що варто знати:
getмає бути чистим: без побічних ефектів, запитів, змін іншого стану;setне викликається, якщо змінити значення «всередині»: для об'єктаfullName.value.x = 1змінює повернений об'єкт, а не викликає сеттер;- у Vue 3.4+
getотримує попереднє значення першим аргументом - зручно, щоб повернути той самий об'єкт, якщо нічого суттєво не змінилося, і не запускати зайві оновлення.
Докладніше в документації: Обчислювані властивості з записом
Vue оновлює шаблон лише тоді, коли змінюється реактивний стан - той, за яким система реактивності може стежити. Звичайна змінна такою не є.
<script setup>
let count = 0; // звичайна змінна
const increment = () => count++;
</script>
<template>
<button @click="increment">{{ count }}</button> <!-- завжди 0 -->
</template>
count справді збільшується в пам'яті, але Vue про це не знає й не перемальовує компонент.
Як правильно:
<script setup>
import { ref } from 'vue';
const count = ref(0);
const increment = () => count.value++;
</script>
Чому Vue не може стежити за звичайною змінною: JavaScript не дає способу перехопити присвоєння локальній змінній. Реактивність працює через перехоплення доступу до властивостей: ref - це об'єкт з властивістю value, reactive - проксі над об'єктом. Читання реєструє залежність, запис - сповіщає про зміну.
Коли звичайна змінна доречна:
- константи й дані, які не змінюються (
const MAX_ITEMS = 50); - функції-обробники;
- значення, що потрібні лише в логіці й не показуються в шаблоні (ідентифікатор таймера, екземпляр сторонньої бібліотеки).
Схожі пастки:
- заміна
reactive-об'єкта цілком:let state = reactive({...}); state = reactive({...})- шаблон стежить за старим проксі; - деструктуризація
reactiveчи props - отримані змінні не реактивні; - дані, отримані з
fetch, записані в звичайну змінну замістьref- перший рендер порожній, і після завантаження нічого не змінюється.
Підказка: якщо значення має з'являтися в шаблоні й змінюватися - воно має бути ref, reactive чи computed.
watch(source, callback)стежить за явно вказаним джерелом. Колбек отримує нове й старе значення і за замовчуванням не запускається одразу - лише при першій зміні.watchEffect(callback)запускається одразу і сам відстежує всі реактивні значення, які прочитав під час виконання. Старого значення немає.
watch(userId, async (id, oldId) => {
user.value = await fetchUser(id);
});
watchEffect(async () => {
user.value = await fetchUser(userId.value); // залежність від userId знайдена сама
});
Опції watch:
immediate: true- викликати колбек одразу з поточним значенням, якwatchEffect.deep: true- реагувати на зміни вкладених полів об'єкта, а не лише на заміну всього значення. Заreactive-об'єктомwatchстежить глибоко автоматично. З Vue 3.5deepприймає й число - глибину обходу.once: true(3.4+) - спрацювати лише раз.
Нюанси:
watchEffectвідстежує лише те, що прочитано синхронно до першогоawait. Значення, прочитані після, у залежності не потрапляють.deepна великій структурі - дорогий обхід при кожній зміні. Краще стежити за конкретним полем:watch(() => form.email, ...).- Гонки: якщо
userIdзмінився двічі швидко, відповіді можуть прийти не в тому порядку.onCleanup(чиonWatcherCleanupу 3.5) дозволяє скасувати попередній запит.
Правило: watch - коли важливо, що саме змінилося і нові/старі значення; watchEffect - коли ефект просто має відповідати поточному стану.
Реактивність Vue працює через проксі: Vue знає про зміну, бо зміна йде через проксі (state.count = 1). Деструктуризація копіює значення полів у звичайні змінні - зв'язок із проксі втрачається.
const state = reactive({ count: 0, name: 'Оля' });
const { count } = state; // просто число 0
state.count++; // count лишився 0
Як уникнути:
toRefsперетворює кожне поле наref, пов'язаний з початковим об'єктом:
const { count, name } = toRefs(state);
count.value++; // змінює state.count
toRef- для одного поля:const count = toRef(state, 'count').- Повертати з composables
ref-и, а неreactive, - тоді деструктуризація на місці виклику безпечна.
Props: props - теж реактивний об'єкт. До Vue 3.5 const { title } = defineProps(...) давав нереактивну змінну. З Vue 3.5 деструктуризація defineProps у <script setup> реактивна: компілятор сам перетворює title на props.title. Але передати таку змінну у функцію чи watch напряму не вийде - потрібен геттер: watch(() => title, ...).
Ще місця, де реактивність губиться:
- Заміна всього
reactive-об'єкта:state = reactive({...}). - Передача примітиву в функцію:
useSomething(state.count)передає число, а не реактивне джерело. ПередавайтеtoRef(state, 'count')чи геттер() => state.count.
Ці утиліти розв'язують одну проблему: як передати реактивне значення далі, не втративши реактивності.
toRefs(reactiveObj) - перетворює кожну властивість reactive-об'єкта на ref, пов'язаний з оригіналом. Головне застосування - повернути стан з composable так, щоб його можна було деструктуризувати:
function useMouse() {
const state = reactive({ x: 0, y: 0 });
// ...
return toRefs(state);
}
const { x, y } = useMouse(); // x і y - ref-и, реактивні
toRef - один ref:
const title = toRef(props, 'title'); // ref, що читає props.title
const fullName = toRef(() => `${props.first} ${props.last}`); // ref тільки для читання з геттера
toValue(source) (3.3+) - «розгорнути що завгодно»: ref - .value, геттер - результат виклику, звичайне значення - як є.
Composable, що приймає ref, геттер або значення - тип MaybeRefOrGetter:
import { ref, watchEffect, toValue, type MaybeRefOrGetter } from 'vue';
export function useFetch<T>(url: MaybeRefOrGetter<string>) {
const data = ref<T | null>(null);
watchEffect(async () => {
data.value = null;
const response = await fetch(toValue(url)); // читання всередині ефекту = залежність
data.value = await response.json();
});
return { data };
}
useFetch('/api/users'); // рядок
useFetch(userUrl); // ref
useFetch(() => `/api/users/${props.id}`); // геттер від props
Чому toValue треба викликати всередині watchEffect/computed: саме там Vue відстежує читання. Якщо розгорнути значення один раз на початку composable, зміни props.id вже не підхопляться.
Типова помилка з props:
useFetch(`/api/users/${props.id}`); // рядок обчислено один раз - не реактивно
useFetch(() => `/api/users/${props.id}`); // правильно
unref - старший аналог toValue, що не викликає геттери. У новому коді краще toValue.
До Vue 3.5 деструктуризація props губила реактивність: const { title } = defineProps(...) давала звичайне значення на момент створення компонента.
З Vue 3.5 деструктуризація з defineProps реактивна:
<script setup>
const { title, count = 0 } = defineProps(['title', 'count']);
</script>
<template>
<h1>{{ title }}</h1>
<p>{{ count }}</p>
</template>
Компілятор <script setup> переписує звернення title на __props.title, тож значення завжди актуальне. Заодно це найзручніший спосіб задати значення за замовчуванням - звичайним синтаксисом JavaScript замість withDefaults.
Пастка - передача у функцію чи watch:
const { id } = defineProps(['id']);
watch(id, () => { /* ... */ }); // помилка компіляції: передано значення, а не джерело
watch(() => id, () => { /* ... */ }); // правильно: геттер
useFetch(id); // composable отримає число, а не реактивне джерело
useFetch(() => id); // правильно
Деструктуризована змінна виглядає як звичайна змінна, але реактивна лише там, де компілятор може переписати звернення. Передаючи її кудись, треба загорнути в геттер, - інакше функція отримає «знімок» значення.
Спостереження за props без деструктуризації:
const props = defineProps(['id', 'filters']);
watch(() => props.id, (id) => load(id));
watch(() => props.filters, reload, { deep: true }); // об'єкт - глибоко
watch(props.id, ...) - та сама помилка: значення замість джерела.
Що варто знати:
- props змінювати не можна - вони належать батьківському компоненту. Для локальної копії -
ref(props.initial)(свідомо не реактивна до змін батька) абоcomputed; - об'єкти в props передаються за посиланням, і зміна їх полів технічно спрацює, але порушує односпрямований потік даних - батько не знатиме, хто змінив його стан;
- для
v-modelна компоненті -defineModel(), а не зміна props.
Типова задача: при зміні id завантажити дані. Якщо id змінюється швидко (користувач клацає по списку), запити летять паралельно, і відповідь на старий запит може прийти пізніше за новий - на екрані опиняться дані не того запису.
watch(id, async (newId) => {
const response = await fetch(`/api/users/${newId}`);
user.value = await response.json(); // може перезаписати свіжі дані старими
});
Очищення побічного ефекту. Перед наступним запуском спостерігача (і при його зупинці) Vue викликає функцію очищення - туди кладуть скасування попереднього запиту:
import { watch, onWatcherCleanup } from 'vue';
watch(id, async (newId) => {
const controller = new AbortController();
onWatcherCleanup(() => controller.abort()); // Vue 3.5+
try {
const response = await fetch(`/api/users/${newId}`, { signal: controller.signal });
user.value = await response.json();
} catch (error) {
if (error.name !== 'AbortError') throw error;
}
});
Важливо: onWatcherCleanup треба викликати синхронно, до першого await - після нього Vue вже не знає, до якого спостерігача він належить.
До Vue 3.5 - третій аргумент колбеку:
watch(id, async (newId, oldId, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
// ...
});
Цей варіант працює й зараз і не має обмеження щодо await.
Інші застосування очищення: зняти таймер (clearInterval), відписатися від WebSocket-каналу, знищити екземпляр сторонньої бібліотеки, створений для попереднього значення.
Зупинка спостерігача:
- спостерігачі, створені синхронно в
setup, зупиняються автоматично разом з компонентом; - створені асинхронно (після
awaitчи вsetTimeout) - ні: їх треба зупинити вручну, інакше витік пам'яті:
const stop = watch(source, callback);
stop();
У Vue 3.5 повернений об'єкт має ще й pause() і resume().
Альтернатива для даних, що залежать від параметрів, - бібліотеки на кшталт TanStack Query, що вже розв'язують гонитву, кешування й повтори.
Докладніше в документації: Спостерігачі: очищення побічних ефектів
Основа - Proxy і два хуки: track і trigger.
reactive()загортає об'єкт уProxy. Обробникgetвикликає track: запам'ятовує, що поточний «ефект» (рендер компонента,computed,watch) читає це поле.- Обробник
setвикликає trigger: знаходить усі ефекти, що читали поле, і планує їх перезапуск. refробить те саме через геттер і сеттер на.value- тому він працює з примітивами, які проксувати неможливо.
// спрощено
const state = new Proxy(target, {
get(obj, key) { track(obj, key); return obj[key]; },
set(obj, key, value) { obj[key] = value; trigger(obj, key); return true; },
});
Рендер компонента - теж ефект: під час рендеру він «підписується» рівно на ті поля, які прочитав шаблон. Зміна поля, якого шаблон не читає, рендер не запускає.
Планувальник і nextTick. Зміни не перемальовують DOM одразу. Vue збирає всі ефекти, які треба перезапустити, у чергу й виконує їх один раз в мікрозадачі. Десять змін поспіль - один рендер.
Звідси nextTick: одразу після зміни стану DOM ще старий.
items.value.push(newItem);
await nextTick();
listEl.value.lastElementChild.scrollIntoView(); // DOM уже оновлено
Наслідки для продуктивності:
- Глибока реактивність проксує вкладені об'єкти ліниво, при доступі. Великі незмінні дані (результат API на тисячі рядків) дешевше тримати в
shallowRef, де відстежується лише заміна.value. markRawпозначає об'єкт, який Vue ніколи не має робити реактивним: екземпляри сторонніх бібліотек, карти, редактори.
Глибока реактивність має ціну: кожен вкладений об'єкт стає проксі, кожне читання поля - виклик track. На списку з десятків тисяч об'єктів з десятками полів це помітно і в пам'яті, і в часі.
Інструменти:
shallowRef- реактивна лише заміна.value, вміст не проксується. Ідеально для даних з API, які не редагують по полю, а замінюють цілком:
const rows = shallowRef([]);
rows.value = await fetchRows(); // рендер спрацює
rows.value[0].name = 'x'; // а тут - ні, і це очікувано
rows.value = [...rows.value]; // явна заміна, щоб оновити
shallowReactive- реактивні лише поля верхнього рівня.markRaw- об'єкт ніколи не стане реактивним: екземпляри Mapbox, Chart.js, редакторів, класи з власним станом. Проксі навколо них не лише дорогий, а й може ламати їхню внутрішню логіку.Object.freezeна даних, які не змінюються, - Vue пропускає їх при перетворенні на реактивні.
Не реактивністю єдиною:
- Віртуалізація списків - рендерити лише видимі рядки (
vue-virtual-scroller, TanStack Virtual). Тисяча DOM-вузлів важча за будь-які проксі. v-memo- пропускати оновлення частини шаблону, доки не змінилися вказані значення.- Стабільні props: передавати в дочірні компоненти примітиви чи стабільні посилання, а не нові об'єкти на кожен рендер.
v-once- для частин шаблону, що відмальовуються раз і не змінюються.
Як знайти проблему: вкладка Performance у Vue DevTools показує час рендеру кожного компонента, а браузерний профайлер - скільки часу йде на проксі й track.
Докладніше в документації: Продуктивність: великі незмінні структури
Кожен computed, watch і watchEffect, створений у setup компонента, Vue автоматично прив'язує до компонента і зупиняє, коли компонент знищується. Поза компонентом такої прив'язки немає - ефекти живуть, доки їх не зупинити вручну.
effectScope збирає всі ефекти, створені всередині, в одну групу, яку можна зупинити одним викликом:
import { effectScope, ref, watch, computed } from 'vue';
const scope = effectScope();
scope.run(() => {
const query = ref('');
const results = computed(() => search(query.value));
watch(query, (value) => saveToHistory(value));
watchEffect(() => console.log(results.value.length));
});
// пізніше - зупинити все разом
scope.stop();
Де це потрібно:
1. Спільний стан composable для кількох компонентів («спільний composable»): ефекти мають жити, поки є хоча б один споживач, і зупинитися, коли зник останній:
function createSharedComposable(factory) {
let consumers = 0;
let state, scope;
return () => {
consumers++;
if (!state) {
scope = effectScope(true); // detached: не прив'язаний до поточного компонента
state = scope.run(factory);
}
onScopeDispose(() => {
if (--consumers === 0) {
scope.stop();
state = scope = undefined;
}
});
return state;
};
}
Саме так влаштований createSharedComposable у VueUse.
2. Бібліотеки й сховища. Pinia створює effectScope для кожного стора - тому $dispose() прибирає всі його спостерігачі.
3. Тести - ізолювати й гарантовано прибрати ефекти між тестами.
4. Тимчасові ефекти - наприклад, на час відкритого модального вікна чи активного режиму редагування.
onScopeDispose(fn) - реєструє очищення для поточної області: в компоненті спрацює при його знищенні, в effectScope - при stop(). Тому composable, що використовує onScopeDispose замість onUnmounted, працює і в компонентах, і поза ними.
getCurrentScope() - дізнатися, чи код виконується всередині області (наприклад, щоб composable попередив, що його викликали поза компонентом).
Без цього composable, викликаний поза setup (у сторі, в роутері, в модулі), створює спостерігачів, які ніколи не зупиняться, - витоки пам'яті в довгоживучих SPA.
customRef дає змогу створити ref з власною логікою відстеження й сповіщення про зміни. Фабрика отримує дві функції:
track()- зареєструвати залежність (викликати при читанні);trigger()- сповістити залежних про зміну (викликати, коли значення справді змінилося).
Класичний приклад - ref із затримкою (debounce):
import { customRef } from 'vue';
export function useDebouncedRef(initial, delay = 300) {
let value = initial;
let timeout;
return customRef((track, trigger) => ({
get() {
track();
return value;
},
set(newValue) {
clearTimeout(timeout);
timeout = setTimeout(() => {
value = newValue;
trigger();
}, delay);
},
}));
}
<script setup>
const query = useDebouncedRef('', 400);
watch(query, (value) => search(value)); // запит лише після паузи у введенні
</script>
<template>
<input v-model="query">
</template>
Поле оновлюється в шаблоні... тільки після затримки - бо значення змінюється в set із запізненням. Для пошуку це прийнятно, а для звичайного введення - ні: користувач не бачитиме набраного тексту. Тому частіше роблять інакше: звичайний ref для поля і окремий debounced-ref (чи watch з затримкою) для запиту.
Інші застосування customRef:
- ref, синхронізований з
localStorage- читання з кешу, запис у сховище; - ref над зовнішнім джерелом (значення з бібліотеки, що має власні події змін) -
trigger()з обробника події бібліотеки; - валідація чи нормалізація при записі (обрізати пробіли, обмежити діапазон);
- лінива ініціалізація дорогого значення при першому читанні.
Що варто знати:
getмає повертати однакове значення для однакового стану - Vue може викликати його багато разів;- не забувати
track()- без нього шаблон не оновиться, хочаtrigger()викликано; - документація окремо застерігає від
get, що щоразу створює новий об'єкт: якщо такий ref передано в props, кожен рендер батька дає дочірньому компоненту «нове» значення, і той перерендерюється без потреби; - готові варіанти вже є у VueUse:
refDebounced,useLocalStorage,refThrottled.
reactive() повертає проксі над оригінальним об'єктом, а не сам об'єкт:
const raw = { a: 1 };
const state = reactive(raw);
state === raw; // false
toRaw(state) === raw; // true
reactive(raw) === state; // true - для одного оригіналу Vue повертає той самий проксі
Зміни через проксі змінюють оригінал (і запускають оновлення), а зміни оригіналу напряму - не запускають нічого.
Де це вилазить:
- порівняння й пошук:
items.value.indexOf(obj)чиSet.has(obj)з оригінальним об'єктом, коли в масиві лежать проксі (або навпаки), дають «не знайдено». Vue перехоплюєindexOf/includesу реактивних масивах, щоб пошук сирого об'єкта працював, але вMap,Setі власному коді порівняння за посиланням легко помилитися; - передача в сторонні бібліотеки: бібліотека отримує проксі. Бібліотеки, що порівнюють об'єкти за посиланням, змінюють їх інтенсивно чи покладаються на приватні поля (
#field), можуть працювати неправильно або повільно; - класи з приватними полями: метод, викликаний через проксі, отримує
this= проксі, а в проксі немає#field-TypeError; structuredClone(state)падає на проксі - потрібноstructuredClone(toRaw(state)).
toRaw(proxy) - отримати оригінал. Корисно для:
- передачі даних у сторонні бібліотеки,
postMessage,IndexedDB,structuredClone; - тимчасового читання/запису без відстеження й оновлень (масові зміни, після яких одне оновлення).
markRaw(obj) - позначити об'єкт як ніколи не реактивний: Vue не обгортатиме його в проксі навіть усередині реактивного стану.
const map = markRaw(new mapboxgl.Map({ container: 'map' }));
state.map = map; // лишається звичайним об'єктом
Застосовують для екземплярів сторонніх бібліотек (карти, редактори, графіки), складних класів, великих незмінних структур.
shallowRef / shallowReactive - реактивність лише на верхньому рівні: заміна значення відстежується, зміни всередині - ні. Добре для великих даних, що замінюються цілком.
Застереження: toRaw і markRaw - інструменти для конкретних проблем. Змішування сирих і реактивних версій того самого об'єкта в коді - джерело помилок «чому не оновилося». Документація Vue радить тримати стабільну межу: або завжди проксі, або завжди сирий.