Vue 3를 사용하던 초반에는 반응성을 다루는 데 애를 먹었다. props 배열을 전개 연산자로 복사한 뒤 내부 객체를 수정했다. 새 배열을 만들었으니 부모 상태와 분리됐다고 생각했는데 부모가 가진 객체도 함께 바뀌었다.
reactive는 Proxy로 변경 감지

vuejs.org
Reactivity in Depth | Vue.js
Vue.js - The Progressive JavaScript Framework
Vue 3의 reactive()는 원본 객체를 감싼 Proxy를 반환한다. Proxy의 get과 set을 거쳐야 Vue가 의존성을 추적하고 변경을 감지할 수 있다.
import { isProxy, reactive, toRaw } from 'vue'
const raw = { count: 0 }
const state = reactive(raw)
console.log(state === raw) // false (Proxy로 감싸진 객체)
console.log(isProxy(state)) // true
console.log(toRaw(state) === raw) // true (원본 객체 추출)
state와 raw는 같은 데이터를 가리키지만 객체의 identity는 다르다. raw.count를 직접 바꾸면 Proxy의 set을 거치지 않으므로 그 변경만으로는 반응형 effect가 실행되지 않는다. toRaw()도 디버깅이나 일시적인 접근을 위한 escape hatch 라서 이 값을 수정하지 않도록 하자.
props binding은 읽기 전용이지만 중첩 객체는 같다

vuejs.org
Mutating Object / Array Props | Vue.js
Vue.js - The Progressive JavaScript Framework
Vue는 자식이 props binding 자체를 덮어쓰지 못하게 막는다. TypeScript에서는 먼저 type error가 나고, 실행 단계까지 가면 개발 모드에서 경고한다. 반면 객체와 배열의 중첩 속성까지 막지는 않는다.
props.items = [] // 경고: props binding 변경
// 실행은 되지만 부모 상태까지 바뀐다
props.items[0].name = 'changed in child'
부모와 자식의 변수가 같은 객체를 가리키는 참조값을 갖고 있기 때문이다. Vue가 props 객체를 복사해 전달한 것이 아니다. 아래 그림처럼 자식에서 name을 수정하면 별도 복사본이 아니라 부모가 소유한 객체를 바로 수정한다.
강하게 연결된 구조가 아니라면 자식이 이벤트를 보내 부모가 변경하도록 권한다.
전개 연산자는 배열 컨테이너만 복사한다
부모 상태를 직접 건드리지 않으려고 새 배열을 만들었다.
const localItems = [...props.items]
console.log(localItems === props.items) // false
console.log(localItems[0] === props.items[0]) // true
// 부모가 가진 첫 번째 객체도 바뀐다
localItems[0].name = 'changed'
// localItems의 첫 번째 참조만 새 객체로 교체한다
localItems[0] = { id: 1, name: 'replaced' }
[...props.items]는 배열 컨테이너만 새로 만든다. 안에 들어 있던 객체 참조는 그대로 복사하므로 localItems[0].name을 바꾸면 부모 상태도 바뀐다. 반대로 localItems[0]에 새 객체를 할당하면 자식의 배열이 가리키는 대상만 달라진다.
function handleUpdate(id: number, newName: string) {
const updated = props.items.map(item =>
item.id === id
? { ...item, name: newName }
: item,
)
emit('update:items', updated)
}
수정할 요소를 새 객체로 바꾼 배열을 만들어 부모에게 전달해야 한다.
const updated = produce(item, draft => {
draft.profile.address.city = 'Seoul'
})
변경할 경로마다 deep 하게 새 객체를 만들거나, 상태 구조가 깊고 갱신이 잦다면 별도의 immutable update 도구를 써야한다. 위 코드처럼 immer 같은 도구를 활용해 특정 속성만 바꾼 새로운 객체를 반환해서 쓰는 방법.
Vue 3.5부터 구조 분해한 props도 반응형이다

vuejs.org
Reactive Props Destructure | Vue.js
Vue.js - The Progressive JavaScript Framework
Vue 3.4 이하에서는 defineProps() 결과를 구조 분해한 변수가 부모의 다음 변경을 추적하지 못했다. Vue 3.5부터는 컴파일러가 같은 <script setup> 블록의 접근을 props.count로 변환한다.
<script setup lang="ts">
import { watch } from 'vue'
const { count = 0 } = defineProps<{ count?: number }>()
watch(
() => count,
value => console.log(value),
)
</script>
watch(count, ...) 형태로는 변숫값을 바로 넘기는 거라 반응형으로 동작하지 않는다. 구조 분해한 prop을 watch나 외부 composable에 넘길 때는 () => count처럼 getter로 감싼다.
댓글 0개