Vue 3 반응성과 렌더링 메모 모음
Vue 3 Composition API로 작업하면서 메모해 둔 내용을 정리했다.
computed
computed는 ref처럼 다룬다
computed()는 읽기 전용 ref를 반환한다. 스크립트에서는 .value로 읽고, 템플릿에서는 자동으로 풀려서 그냥 쓴다.
const fullName = computed(() => `${first.value} ${last.value}`);
console.log(fullName.value);
반응형 값을 거치지 않으면 갱신되지 않는다
computed는 계산 중에 읽은 반응형 값만 추적한다. 일반 변수나 반응형이 아닌 객체의 필드를 읽으면, 그 값이 바뀌어도 다시 계산되지 않는다.
Date 내부를 바꾸면 반응하지 않는다
const currDate = ref(new Date());
const currMonth = computed(() => currDate.value.getMonth() + 1);
const handleMonthChange = (month: number) => {
currDate.value.setMonth(month); // ❌ currMonth가 갱신되지 않음
};
Vue의 반응성은 속성 대입을 감지한다. Date의 setMonth는 객체 내부 상태를 바꾸는 메서드라 Vue가 알 수 없다. 그래서 다른 핸들러에서 currMonth를 읽으면 이전 값이 나온다. 새 객체로 교체한다.
const handleMonthChange = (month: number) => {
const next = new Date(currDate.value);
next.setMonth(month);
currDate.value = next; // ✅ ref 값 교체 → 반응
};
Map·Set은 Vue가 감싸서 추적하지만, Date처럼 내부 슬롯을 쓰는 객체는 교체해야 한다.
watch
ref 배열 내부 변경은 deep이 필요하다
const items = ref([{ checked: false }]);
watch(items, () => {}); // 배열 교체(items.value = [...])에만 반응
watch(items, () => {}, { deep: true }); // 내부 요소 변경까지 반응
const state = reactive({ items: [] });
watch(() => state.items, () => {}, { deep: true });
reactive 객체 자체를 watch하면 자동으로 deep이지만, ref나 getter로 감시하면 기본은 얕은 비교다. deep watch에서는 oldVal과 newVal이 같은 객체를 가리킨다는 점도 함께 기억한다.
computed는 watch 콜백보다 먼저 최신화된다
어떤 ref가 바뀌면, 그 ref를 쓰는 computed는 다음에 읽힐 때 다시 계산된다. watch 콜백은 기본적으로 컴포넌트 업데이트 전(flush: 'pre')에 실행되는데, 콜백 안에서 computed를 읽으면 이미 최신 값이다.
렌더링
:key가 바뀌면 컴포넌트를 새로 만든다
<DatePicker :key="resetKey" />
key가 바뀌면 Vue는 기존 컴포넌트를 버리고 새로 마운트한다. 내부 상태를 완전히 초기화할 때 유용하다.
v-for 구조 분해는 원본과 연결이 끊긴다
<!-- ❌ name은 복사된 값이라 v-model이 원본을 바꾸지 못함 -->
<input v-for="{ name } in users" v-model="name" />
<!-- ✅ 요소 객체의 속성을 직접 바인딩 -->
<input v-for="user in users" :key="user.id" v-model="user.name" />
범위 v-for는 1부터 시작한다
<span v-for="n in 3">{{ n }}</span> <!-- 1 2 3 -->
배열 인덱스처럼 0부터라고 생각하면 off-by-one이 난다.
style scoped
<style scoped>는 현재 컴포넌트의 템플릿 요소에만 적용된다. 자식 컴포넌트는 루트 요소에만 적용되고, 그 내부 요소에는 닿지 않는다. 자식(특히 외부 라이브러리 컴포넌트)의 내부를 꾸미려면 :deep()을 쓴다.
.picker :deep(.dp__cell) {
border-radius: 50%;
}
nextTick
상태 변경 후 DOM을 읽어야 할 때
Vue는 상태 변경을 모아 다음 틱에 DOM을 갱신한다. 변경 직후 갱신된 DOM이 필요하면 nextTick을 기다린다.
const handleToggle = async () => {
isOn.value = !isOn.value;
await nextTick();
// 여기서는 DOM이 갱신된 상태
};
다른 상태를 계산하기 위해서라면 nextTick이 아니라 computed를 쓰는 것이 맞다.
const toggleVariant = computed(() => (isOn.value ? 'on' : 'off'));
nextTick vs setTimeout
nextTick: Vue가 자신의 DOM 업데이트를 마친 직후 실행된다(마이크로태스크). 브라우저가 화면을 그리기 전일 수 있다.setTimeout(fn, 0): 다음 태스크에 실행되어, 대개 그 사이 브라우저 렌더링 기회가 지나간다.
외부 라이브러리(예: VueDatePicker)가 Vue 업데이트와 별도로 자기 DOM을 늦게 그리는 경우, nextTick 시점에는 아직 그 DOM이 없을 수 있다. 이럴 때 setTimeout으로 미루면 동작하기도 하지만 타이밍에 기대는 방법이다. 라이브러리가 제공하는 이벤트(열림 완료, 렌더 완료 등)가 있다면 그쪽을 먼저 쓴다.
vue-query로 쿼리 조건부 실행
<script setup lang="ts">
import { useQuery } from '@tanstack/vue-query';
const filter = ref('');
const isEnabled = computed(() => !!filter.value);
const { data } = useQuery({
queryKey: ['todos', filter],
queryFn: () => fetchTodos(filter.value),
enabled: isEnabled, // filter가 비어 있는 동안 비활성
});
</script>
queryKey와 enabled에 ref·computed를 넣으면 값이 바뀔 때 자동으로 반영된다.