예측 가능한 코드
const [isOpen, setIsOpen] = useState(false)
// → 어딘가에서 setIsOpen(true/false)가 나올 것이다
const { data: product, isLoading } = useProduct(id)
// → product가 null일 수 있고, isLoading 중엔 스켈레톤을 보여줄 것이다
예측 불가한 코드
const [d, setD] = useState(null)
// → d가 뭔지, 어떤 타입인지, 언제 바뀌는지 알 수 없다// ❌ 하나의 컴포넌트가 너무 많은 일을 함
function ProductPage({ id }: { id: number }) {
const [product, setProduct] = useState<Product | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [quantity, setQuantity] = useState(1)
const [isWished, setIsWished] = useState(false)
useEffect(() => {
fetch(`/api/products/${id}`)
.then(res => res.json())
.then(data => {
setProduct(data)
setIsLoading(false)
})
}, [id])
if (isLoading) return <Spinner />
return (
<div>
<img src={product?.imageUrl} />
<h1>{product?.name}</h1>
<p>{product?.price.toLocaleString()}원</p>
<div>
<button onClick={() => setQuantity(q => q - 1)}>-</button>
<span>{quantity}</span>
<button onClick={() => setQuantity(q => q + 1)}>+</button>
</div>
<button onClick={() => setIsWished(w => !w)}>
{isWished ? '♥' : '♡'}
</button>
<button onClick={() => addToCart(product, quantity)}>장바구니</button>
</div>
)
}// ✅ 관심사를 분리
function ProductPage({ id }: { id: number }) {
const { product, isLoading } = useProduct(id) ← 데이터 페칭 분리
if (isLoading) return <Spinner />
if (!product) return <NotFound />
return <ProductDetail product={product} /> ← UI 렌더링 분리
}
function ProductDetail({ product }: { product: Product }) {
return (
<div>
<ProductImage src={product.imageUrl} alt={product.name} />
<ProductInfo product={product} />
<ProductActions productId={product.id} price={product.price} />
</div>
)
}
function ProductActions({ productId, price }: { productId: number; price: number }) {
const [quantity, setQuantity] = useState(1) ← 이 컴포넌트에서만 필요한 상태
const [isWished, setIsWished] = useState(false)
return (
<div>
<QuantitySelector value={quantity} onChange={setQuantity} />
<WishButton isWished={isWished} onToggle={() => setIsWished(w => !w)} />
<CartButton productId={productId} quantity={quantity} price={price} />
</div>
)
}상태 위치 결정 기준
이 컴포넌트에서만 쓰인다 → 컴포넌트 로컬 useState
형제/부모가 함께 쓴다 → 공통 부모로 상태 끌어올리기 (lifting state up)
멀리 떨어진 컴포넌트가 쓴다 → Context 또는 전역 상태
URL로 관리할 수 있다 → URL searchParams// ❌ 모든 상태를 최상단에 몰아두는 패턴
function App() {
const [isModalOpen, setIsModalOpen] = useState(false)
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null)
const [cartCount, setCartCount] = useState(0)
const [searchKeyword, setSearchKeyword] = useState('')
// ... 스크롤하지 않으면 어떤 상태가 어디서 쓰이는지 알 수 없다
}
// ✅ 상태는 쓰이는 곳 가장 가까이에
function ProductList() {
const [searchKeyword, setSearchKeyword] = useState('') ← 이 컴포넌트 트리에서만 필요
return (
<>
<SearchInput value={searchKeyword} onChange={setSearchKeyword} />
<ProductGrid keyword={searchKeyword} />
</>
)
}
function ProductCard({ product }: { product: Product }) {
const [isModalOpen, setIsModalOpen] = useState(false) ← 이 카드에서만 필요
return (
<>
<img onClick={() => setIsModalOpen(true)} src={product.imageUrl} />
{isModalOpen && <ProductModal product={product} onClose={() => setIsModalOpen(false)} />}
</>
)
}// ❌ 연관된 상태가 분산되어 있음
const [isLoading, setIsLoading] = useState(false)
const [data, setData] = useState<User | null>(null)
const [error, setError] = useState<Error | null>(null)
// isLoading=true이면서 error가 있는 상태가 생길 수 있다 — 불가능한 상태
// ✅ 상태를 discriminated union으로 설계
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
const [state, setState] = useState<AsyncState<User>>({ status: 'idle' })
// 불가능한 상태 조합 자체가 타입 수준에서 막힌다
if (state.status === 'success') {
console.log(state.data) ← 타입스크립트가 data를 보장함
}// ❌ 복잡한 폼 상태를 useState로 하나씩 관리
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [phone, setPhone] = useState('')
const [agree, setAgree] = useState(false)
// ✅ useReducer로 폼 상태를 하나로 관리
type FormState = {
name: string
email: string
phone: string
agree: boolean
}
type FormAction =
| { type: 'SET_FIELD'; field: keyof FormState; value: string | boolean }
| { type: 'RESET' }
const initialState: FormState = {
name: '',
email: '',
phone: '',
agree: false,
}
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'SET_FIELD':
return { ...state, [action.field]: action.value }
case 'RESET':
return initialState
}
}
const [form, dispatch] = useReducer(formReducer, initialState)
// 사용할 때
dispatch({ type: 'SET_FIELD', field: 'name', value: '김현우' })
dispatch({ type: 'RESET' })// ❌ 너무 잘게 쪼갠 훅 — 오히려 추적이 어려움
function useIsOpen() {
const [isOpen, setIsOpen] = useState(false)
return { isOpen, open: () => setIsOpen(true), close: () => setIsOpen(false) }
}
// ❌ React와 무관한 계산 로직을 훅으로 만든 경우
function useFormattedPrice(price: number) {
return useMemo(() => price.toLocaleString() + '원', [price])
// useMemo가 필요 없는 단순 계산 — 그냥 함수면 충분하다
}
// ✅ 상태와 관련 로직이 함께 있어야 의미 있는 훅
function useImageUpload() {
const [preview, setPreview] = useState<string | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
function handleFileSelect(file: File) {
const reader = new FileReader()
reader.onload = (e) => setPreview(e.target?.result as string)
reader.readAsDataURL(file)
}
async function upload(file: File): Promise<string> {
setIsUploading(true)
setError(null)
try {
const url = await uploadFile(file)
return url
} catch (e) {
setError('업로드에 실패했습니다')
throw e
} finally {
setIsUploading(false)
}
}
return { preview, isUploading, error, handleFileSelect, upload }
}// 순수 함수 — React와 완전히 분리된 로직
function formatPrice(price: number): string {
return price.toLocaleString() + '원'
}
function filterByCategory(products: Product[], category: string): Product[] {
return category === 'all' ? products : products.filter(p => p.category === category)
}
function sortByPrice(products: Product[], order: 'asc' | 'desc'): Product[] {
return [...products].sort((a, b) =>
order === 'asc' ? a.price - b.price : b.price - a.price
)
}
// 컴포넌트에서는 조합해서 사용
const displayed = useMemo(
() => sortByPrice(filterByCategory(products, category), sortOrder),
[products, category, sortOrder]
)// ❌ 부모의 상태 구조가 드러남
interface DatePickerProps {
date: string
setDate: (v: string) => void ← "부모가 date라는 useState를 쓴다"가 노출됨
}
// ✅ 네이티브 input 인터페이스를 따름
interface DatePickerProps {
value?: string
defaultValue?: string
min?: string
onChange?: (e: ChangeEvent<HTMLInputElement>) => void
onBlur?: (e: FocusEvent<HTMLInputElement>) => void
name?: string
}
export const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(
({ value, defaultValue, min, onChange, onBlur, name }, ref) => {
const inputProps = value !== undefined ? { value } : { defaultValue }
return (
<input
ref={ref}
type="date"
{...inputProps}
min={min}
onChange={onChange}
onBlur={onBlur}
name={name}
/>
)
}
)// ❌ 항목이 늘어날수록 시그니처가 폭발
<SearchFilter
keyword={keyword}
category={category}
startDate={startDate}
endDate={endDate}
onKeywordChange={setKeyword}
onCategoryChange={setCategory}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
// ✅ 타입으로 묶음
type SearchFilter = {
keyword: string
category: string
startDate: string
endDate: string
}
<SearchFilter filter={filter} onFilterChange={setFilter} /><!-- ❌ 하나의 컴포넌트가 너무 많은 일을 함 -->
<script setup lang="ts">
const route = useRoute()
const product = ref<Product | null>(null)
const isLoading = ref(true)
const quantity = ref(1)
const isWished = ref(false)
onMounted(async () => {
product.value = await fetchProduct(Number(route.params.id))
isLoading.value = false
})
</script>
<template>
<div v-if="isLoading"><Spinner /></div>
<div v-else>
<img :src="product?.imageUrl" />
<h1>{{ product?.name }}</h1>
<!-- 수십 줄의 인터랙션 로직 -->
</div>
</template><!-- ✅ 관심사 분리 -->
<!-- ProductPage.vue -->
<script setup lang="ts">
const props = defineProps<{ id: number }>()
const { product, isLoading } = useProduct(props.id) ← 데이터 페칭 분리
</script>
<template>
<Spinner v-if="isLoading" />
<ProductDetail v-else-if="product" :product="product" />
<NotFound v-else />
</template><!-- ProductActions.vue — 인터랙션만 담당 -->
<script setup lang="ts">
defineProps<{ productId: number; price: number }>()
const quantity = ref(1) ← 이 컴포넌트에서만 필요한 상태
const isWished = ref(false)
</script>
<template>
<QuantitySelector v-model="quantity" />
<WishButton v-model="isWished" />
<CartButton :product-id="productId" :quantity="quantity" :price="price" />
</template><!-- ❌ Vue 3.3 이하 — 보일러플레이트가 많음 -->
<script setup lang="ts">
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
function handleChange(e: Event) {
emit('update:modelValue', (e.target as HTMLInputElement).value)
}
</script>
<template>
<input :value="props.modelValue" @change="handleChange" />
</template><!-- ✅ Vue 3.4+ — defineModel() -->
<script setup lang="ts">
const value = defineModel<string>({ default: '' })
</script>
<template>
<input v-model="value" />
</template><!-- DateRangePicker.vue -->
<script setup lang="ts">
const startDate = defineModel<string>('start', { default: '' })
const endDate = defineModel<string>('end', { default: '' })
</script>
<template>
<input type="date" v-model="startDate" />
<input type="date" v-model="endDate" :min="startDate" />
</template>
<!-- 사용하는 쪽 -->
<DateRangePicker v-model:start="start" v-model:end="end" />// ref — 원시값, 또는 교체가 일어나는 참조에 적합
const count = ref(0)
const user = ref<User | null>(null)
// 나중에 객체 자체를 교체할 수 있다
user.value = await fetchUser(id) ← ref는 교체 가능
// reactive — 내부 속성만 바뀌는 객체에 적합
const filter = reactive({
keyword: '',
category: 'all',
sortOrder: 'asc' as 'asc' | 'desc',
})
filter.keyword = '나이키' ← 교체 없이 내부 속성만 변경
// ❌ reactive는 객체 자체를 교체하면 반응성이 끊긴다
// filter = { keyword: '나이키', category: 'all', sortOrder: 'asc' } ← 절대 이렇게 쓰면 안 됨// ❌ Vue 반응형과 무관한 계산 로직을 Composable로 만든 경우
export function useFilteredProducts(products: Ref<Product[]>, filter: Ref<Filter>) {
return computed(() =>
products.value
.filter(p => p.category === filter.value.category)
.sort((a, b) => a.price - b.price)
)
// computed만 쓴다고 Composable이 될 필요는 없다
}
// ✅ 순수 함수로 분리 — 테스트가 쉽고 Vue 없이도 동작
function filterProducts(products: Product[], filter: Filter): Product[] {
return products
.filter(p => p.category === filter.category)
.sort((a, b) =>
filter.sortOrder === 'asc' ? a.price - b.price : b.price - a.price
)
}
// 컴포넌트에서는 computed로 감싸서 사용
const filtered = computed(() => filterProducts(products.value, filter))// ✅ 진짜 Composable이 필요한 경우 — 반응형과 라이프사이클이 함께 필요함
export function useIntersectionObserver(target: Ref<HTMLElement | null>) {
const isVisible = ref(false)
onMounted(() => {
if (!target.value) return
const observer = new IntersectionObserver(([entry]) => {
isVisible.value = entry.isIntersecting
})
observer.observe(target.value)
onUnmounted(() => observer.disconnect())
})
return { isVisible }
}
// 컴포넌트에서
const cardRef = ref<HTMLElement | null>(null)
const { isVisible } = useIntersectionObserver(cardRef)<!-- ❌ 부모의 상태 구조가 Props로 새어나옴 -->
<script setup lang="ts">
defineProps<{
date: string
setDate: (v: string) => void ← 부모 구현이 노출됨
}>()
</script><!-- ✅ 컴포넌트 자신의 역할만 드러내는 인터페이스 -->
<script setup lang="ts">
const value = defineModel<string>({ default: '' })
defineProps<{
min?: string
max?: string
disabled?: boolean
}>()
</script>
<template>
<input
type="date"
v-model="value"
:min="min"
:max="max"
:disabled="disabled"
/>
</template><!-- ❌ 항목이 늘어날수록 시그니처가 폭발 -->
<script setup lang="ts">
defineProps<{
keyword: string
category: string
startDate: string
endDate: string
}>()
defineEmits<{
'update:keyword': [value: string]
'update:category': [value: string]
'update:startDate': [value: string]
'update:endDate': [value: string]
}>()
</script>
<!-- ✅ 타입으로 묶어서 인터페이스를 안정적으로 유지 -->
<script setup lang="ts">
type SearchFilter = {
keyword: string
category: string
startDate: string
endDate: string
}
const props = defineProps<{ filter: SearchFilter }>()
const emit = defineEmits<{
'update:filter': [filter: SearchFilter]
}>()
function handleChange<K extends keyof SearchFilter>(key: K, value: SearchFilter[K]) {
emit('update:filter', { ...props.filter, [key]: value })
}
</script>관심사 | React | Vue |
양방향 바인딩 | value + onChange (controlled) | defineModel() (3.4+) |
로컬 상태 | useState / useReducer | ref / reactive |
파생 상태 | useMemo | computed |
사이드 이펙트 | useEffect | watch / watchEffect |
로직 캡슐화 | Custom Hook | Composable |
컴포넌트 간 공유 | Context | provide / inject |
DOM 접근 | useRef | ref (template ref) |
