본문으로 건너뛰기
Advertisement

12.2 Composition API 타입 — ref, reactive, computed, watch

ref<T>

<script setup lang="ts">
import { ref } from 'vue'

// 타입 추론
const count = ref(0) // Ref<number>
const name = ref('') // Ref<string>
const active = ref(false) // Ref<boolean>

// 명시적 타입 지정
const user = ref<User | null>(null)
const items = ref<string[]>([])

// .value로 접근
count.value++
name.value = 'Alice'
user.value = { id: '1', name: 'Alice', email: 'alice@example.com' }

// 템플릿에서는 .value 없이 자동 언래핑
</script>

<template>
<p>{{ count }}</p> <!-- .value 불필요 -->
<p>{{ name }}</p>
</template>

reactive<T>

<script setup lang="ts">
import { reactive } from 'vue'

interface FormState {
name: string
email: string
age: number
address: {
city: string
country: string
}
}

// reactive: 객체를 깊게 반응형으로 만듦
const form = reactive<FormState>({
name: '',
email: '',
age: 0,
address: {
city: '',
country: 'KR',
},
})

// 직접 접근 (.value 불필요)
form.name = 'Alice'
form.address.city = '서울'

// ❌ reactive 객체 자체를 교체하면 반응성 잃음
// form = { name: 'Bob', ... } // 오류

// ✅ Object.assign으로 업데이트
Object.assign(form, { name: 'Bob', email: 'bob@example.com' })
</script>

ref vs reactive 선택 기준

<script setup lang="ts">
import { ref, reactive } from 'vue'

// ref 권장:
const count = ref(0) // 원시값
const user = ref<User | null>(null) // null 가능한 값
const items = ref<string[]>([]) // 배열

// reactive 권장:
const form = reactive({ // 여러 필드를 가진 폼
name: '',
email: '',
})

const state = reactive({ // 관련된 상태를 그룹화
isLoading: false,
error: null as Error | null,
data: [] as User[],
})
</script>

computed<T>

<script setup lang="ts">
import { ref, computed } from 'vue'

const firstName = ref('길동')
const lastName = ref('홍')

// 읽기 전용 computed
const fullName = computed(() => `${lastName.value} ${firstName.value}`)
// fullName: ComputedRef<string>

// 타입 명시 (복잡한 경우)
const filteredItems = computed<ProcessedItem[]>(() => {
return rawItems.value
.filter(item => item.active)
.map(item => processItem(item))
})

// 쓰기 가능한 computed (get/set)
const fullNameWritable = computed({
get() {
return `${lastName.value} ${firstName.value}`
},
set(value: string) {
const [last, first] = value.split(' ')
lastName.value = last
firstName.value = first
},
})

// 사용
fullNameWritable.value = '김 철수'
// lastName.value === '김', firstName.value === '철수'
</script>

watch와 watchEffect

<script setup lang="ts">
import { ref, watch, watchEffect } from 'vue'

const count = ref(0)
const user = ref<User | null>(null)

// 단일 ref 감시
watch(count, (newValue: number, oldValue: number) => {
console.log(`${oldValue} → ${newValue}`)
})

// 옵션 포함
watch(user, (newUser, oldUser) => {
if (newUser) {
console.log(`사용자 변경: ${newUser.name}`)
}
}, {
deep: true, // 깊은 감시
immediate: true, // 즉시 실행
})

// 여러 소스 감시
const searchQuery = ref('')
const currentPage = ref(1)

watch([searchQuery, currentPage], ([query, page], [prevQuery, prevPage]) => {
fetchData(query, page)
})

// getter 함수로 감시
const activeCount = ref(0)
watch(
() => activeCount.value * 2, // getter
(newValue) => {
console.log('doubled:', newValue)
}
)

// watchEffect: 의존성 자동 추적
const stopWatch = watchEffect(() => {
// 이 블록 안에서 접근한 반응형 데이터가 변경될 때마다 실행
console.log(`count: ${count.value}, user: ${user.value?.name}`)
})

// 감시 중지
stopWatch()
</script>

커스텀 Composable 타입 설계

// composables/useCounter.ts
import { ref, computed, Ref, ComputedRef } from 'vue'

interface UseCounterOptions {
initial?: number
min?: number
max?: number
step?: number
}

interface UseCounterReturn {
count: Ref<number>
doubled: ComputedRef<number>
increment: () => void
decrement: () => void
reset: () => void
isAtMin: ComputedRef<boolean>
isAtMax: ComputedRef<boolean>
}

export function useCounter(options: UseCounterOptions = {}): UseCounterReturn {
const {
initial = 0,
min = -Infinity,
max = Infinity,
step = 1,
} = options

const count = ref(initial)
const doubled = computed(() => count.value * 2)
const isAtMin = computed(() => count.value <= min)
const isAtMax = computed(() => count.value >= max)

function increment() {
if (count.value + step <= max) {
count.value += step
}
}

function decrement() {
if (count.value - step >= min) {
count.value -= step
}
}

function reset() {
count.value = initial
}

return {
count,
doubled,
increment,
decrement,
reset,
isAtMin,
isAtMax,
}
}
// composables/useFetch.ts
import { ref, Ref } from 'vue'

interface UseFetchReturn<T> {
data: Ref<T | null>
error: Ref<Error | null>
isLoading: Ref<boolean>
execute: () => Promise<void>
}

export function useFetch<T>(url: string): UseFetchReturn<T> {
const data = ref<T | null>(null) as Ref<T | null>
const error = ref<Error | null>(null)
const isLoading = ref(false)

async function execute() {
isLoading.value = true
error.value = null

try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
data.value = await response.json() as T
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e))
} finally {
isLoading.value = false
}
}

execute()

return { data, error, isLoading, execute }
}

고수 팁

1. toRef / toRefs로 반응성 유지

import { reactive, toRef, toRefs } from 'vue'

const state = reactive({ count: 0, name: 'Alice' })

// toRef: 단일 프로퍼티를 Ref로 변환
const count = toRef(state, 'count')
// count.value === state.count (동기화됨)

// toRefs: 모든 프로퍼티를 Ref로 변환 (비구조화 분해 시 반응성 유지)
const { count: countRef, name: nameRef } = toRefs(state)
// countRef.value === state.count

2. shallowRef / shallowReactive

import { shallowRef, shallowReactive } from 'vue'

// 얕은 반응성 — 최상위만 반응형 (성능 최적화)
const bigObject = shallowRef({ nested: { value: 1 } })
bigObject.value = { nested: { value: 2 } } // ✅ 반응형 업데이트
bigObject.value.nested.value = 3 // ❌ 반응형 업데이트 안 됨
Advertisement