컴포넌트 간 데이터가 어떻게 흐르는지(상태 흐름)에 대한 정리.
한 줄 요약: 부모는 자식에게props로 데이터를 내려주고, 자식은emit으로 부모에게 이벤트를 올려보낸다.
💡 결론: "공통된 부모 컴포넌트에서 관리하자"
Pass Props)Emit event) 부모(Parent)
↑ ↓
$emit props
(이벤트) (데이터)
↑ ↓
자식(Child)
Props: 부모 컴포넌트로부터 자식 컴포넌트로 데이터를 전달하는 데 사용되는 사용자 지정 특성(attribute)
⚠️ 자식 컴포넌트 내부에서 props를 변경하려고 시도해서는 안 되며 불가능하다.
📌 TIP
- 자식이 부모 속성을 바꾸려면, props를 수정하지 말고 이후에 배울
emit을 이용해서 부모에게 알려야 한다.- 객체/배열 props는 자식에서 내부 값을 바꾸면 부모의 원본도 바뀌니 매우 주의해야 한다.
✅ 우리는 일관성 있고 예측 가능한 코드를 짜야 한다.
기본 컴포넌트 관계 구조: App > Parent > ParentChild
사전 작업 요약
1. vue 프로젝트 생성
2. 초기 생성된 컴포넌트 모두 삭제 (App.vue제외)
3.src/assets내부 파일 모두 삭제
4.main.js에서import './assets/main.css'코드 삭제
5.App > Parent > ParentChild컴포넌트 관계 작성
<!-- App.vue -->
<template>
<div>
<Parent />
</div>
</template>
<script setup>
import Parent from '@/components/Parent.vue'
</script>
<!-- Parent.vue -->
<template>
<div>
<h1>Parent</h1>
<ParentChild />
</div>
</template>
<script setup>
import ParentChild from '@/components/ParentChild.vue'
</script>
<!-- ParentChild.vue -->
<template>
<div>
<h2>ParentChild</h2>
</div>
</template>
<script setup>
</script>
부모 컴포넌트(Parent)에서 자식 컴포넌트(ParentChild)에 보낼 props를 작성한다.
<!-- Parent.vue -->
<template>
<div>
<h1>Parent</h1>
<ParentChild my-msg="message" />
</div>
</template>
my-msg = "message"
↑ ↑
props 이름 props 값
key="value"면 정적
바인딩(v-bind)을해서
:key = "value"면 동적으로 할당 -> 값이 ref로 되어있는 경우
- HTML 속성으로 전달하므로 **kebab-case**(`my-msg`)로 작성한다.
우리는 흐름을 읽을 줄 알아야 한다.
defineProps()defineProps()를 사용하여 props를 선언한다.<script setup>
defineProps()
</script>
💡
defineProps()는 컴파일러 매크로(내장) 라서 따로import할 필요가 없다.
defineProps()에 전달하는 인자의 형태에 따라 선언 방식이 나뉜다.
<!-- ParentChild.vue -->
<script setup>
defineProps(['myMsg'])
</script>
💡 JavaScript는 kebab-case를 지원하지 않으므로 props 이름을 camelCase(
myMsg)로 쓴다.
Vue가 알아서my-msg(템플릿) ↔myMsg(스크립트)를 매칭해준다.
String, Number 등)여야 한다. (유효성 검사까지 됨)<!-- ParentChild.vue -->
<script setup>
defineProps({
myMsg: String
})
</script>
📌 TIP: 가급적 "객체를 사용한 선언" 을 권장한다.
- 각 prop에 대해 상세한 규칙(유효성 검사)을 설정하여 컴포넌트의 안정성을 높여준다.
- 객체 구문은 그 자체로 컴포넌트의 설명서 역할을 하므로, 코드를 통해 명확하게 의사소통이 가능하다.
props 선언 후 템플릿에서 반응형 변수와 같은 방식으로 사용하거나, JS에서 props를 객체로 접근할 수 있다.
<!-- ParentChild.vue -->
<template>
<div>
<h2>ParentChild</h2>
<!-- 템플릿에서 활용해야하는 경우 -->
<p>{{ myMsg }}</p>
</div>
</template>
<script setup>
// js에서 props 데이터를 활용해야하는 경우
const props = defineProps({ myMsg: String })
console.log(props)
console.log(props.myMsg)
</script>
ParentChild를 부모로 갖는 ParentGrandChild 컴포넌트를 만들어 props를 한 단계 더 내려보낸다.
<!-- ParentGrandChild.vue -->
<template>
<div></div>
</template>
<script setup>
</script>
<!-- ParentChild.vue -->
<template>
<div>
<h2>ParentChild</h2>
<p>{{ myMsg }}</p>
<ParentGrandChild />
</div>
</template>
<script setup>
import ParentGrandChild from '@/components/ParentGrandChild.vue'
defineProps({
myMsg: String,
})
</script>
<!-- ParentChild.vue -->
<template>
<div>
<h2>ParentChild</h2>
<p>{{ myMsg }}</p>
<ParentGrandChild :my-msg="myMsg" />
</div>
</template>
<script setup>
import ParentGrandChild from '@/components/ParentGrandChild.vue'
defineProps({
myMsg: String
})
</script>
※
v-bind를 사용한 동적 props (아래 Static/Dynamic props에서 자세히)
<!-- ParentGrandChild.vue -->
<template>
<div>
<p>{{ myMsg }}</p>
</div>
</template>
<script setup>
defineProps({
myMsg: String,
})
</script>
my-msg)<ParentChild my-msg="message" />
myMsg)<template>
<div>
<p>{{ myMsg }}</p>
</div>
</template>
<script setup>
defineProps({
myMsg: String,
})
</script>
v-bind 를 사용하여 동적으로 할당된 props를 사용할 수 있다.v-bind: 하나 이상의 속성 또는 컴포넌트 데이터를 표현식에 동적 바인딩
📌 TIP - 동적 할당이 뭐예요?
- 고정된 값을 전달하는 것이 아닌, 바뀌는 데이터를 연결하는 것.
- 즉 부모의 데이터와 자식의 속성을 실시간으로 연결하는 것을 의미한다.
예시) Parent에 Dynamic props 정의하고, ParentChild에서 선언 및 출력
<!-- Parent.vue -->
<template>
<div>
<ParentChild
my-msg="message"
:dynamic-props="name"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
const name = ref('Alice')
</script>
<!-- ParentChild.vue -->
<template>
<div>
<p>{{ myMsg }}</p>
<p>{{ dynamicProps }}</p>
</div>
</template>
<script setup>
defineProps({
myMsg: String,
dynamicProps: String,
})
</script>
my-msg는 정적 문자열 "message"가 그대로 출력.:dynamic-props는 name ref 값인 "Alice"가 동적으로 출력.<!-- ParentItem.vue -->
<template>
<div>
</div>
</template>
<script setup>
</script>
<!-- Parent.vue -->
<template>
<div>
<ParentItem />
</div>
</template>
<script setup>
import ParentItem from '@/components/ParentItem.vue'
</script>
<!-- Parent.vue -->
<script setup>
const items = ref([
{ id: 1, name: '사과' },
{ id: 2, name: '바나나' },
{ id: 3, name: '딸기' }
])
</script>
<!-- Parent.vue -->
<template>
<div>
<ParentItem
v-for="item in items"
:key="item.id"
:my-prop="item"
/>
</div>
</template>
<!-- ParentItem.vue -->
<template>
<div>
<p>{{ myProp.id }}</p>
<p>{{ myProp.name }}</p>
</div>
</template>
<script setup>
defineProps({
myProp: Object
})
</script>
items 배열의 각 객체가 myProp으로 전달되어 id, name이 반복 출력된다.Pass Props)Emit event)
$emit(): 자식 컴포넌트가 이벤트를 발생시켜 부모 컴포넌트로 데이터를 전달하는 메서드
emit은 자식 컴포넌트가 부모 컴포넌트에게 특정 이벤트가 발생했음을 알리고 데이터를 전달하는 기능이다.$emit(event, ...args)
event: 커스텀 이벤트 이름args: 추가 인자📌 TIP -
$표기
- Vue 인스턴스의 내부 변수들을 가리킨다.
- Life cycle hooks, 인스턴스 메서드 등 내부 특정 속성에 접근할 때 사용한다.
$emit을 사용하여 템플릿 표현식에서 직접 사용자 정의 이벤트를 발신<!-- 자식 컴포넌트 -->
<button @click="$emit('someEvent')">클릭</button>
v-on(또는 @)을 사용하여 이벤트를 수신<!-- 부모 컴포넌트 -->
<ParentComp @some-event="someCallback" />
(1/2) ParentChild에서 someEvent 이벤트 발신
<!-- ParentChild.vue -->
<template>
<div>
<button @click="$emit('someEvent')">클릭</button>
</div>
</template>
(2/2) 부모 Parent는 v-on으로 이벤트를 수신 → 수신 후 처리할 콜백함수 호출
<!-- Parent.vue -->
<template>
<div>
<ParentChild
@some-event="someCallback"
my-msg="message"
:dynamic-props="name" />
</div>
</template>
<script setup>
const someCallback = function () {
console.log('ParentChild가 발신한 이벤트를 수신했어요.')
}
</script>
defineEmits()defineEmits()를 사용하여 발신할 이벤트를 선언한다.defineEmits()에 작성하는 인자의 데이터 타입에 따라 선언 방식이 나뉜다.defineEmits()는 <script setup> 내에서 이벤트를 발신하기 위한 emit 함수를 반환한다.$emit과 달리 <script setup>에서는 직접 접근할 수 없기 때문)<!-- ParentChild.vue -->
<template>
<div>
<button @click="buttonClick">클릭</button>
</div>
</template>
<script setup>
// emit 이벤트 선언 (배열방식)
const emit = defineEmits(['someEvent'])
const buttonClick = function () {
emit('someEvent')
}
</script>
<!-- Parent.vue -->
<template>
<div>
<ParentChild
@some-event="someCallback"
my-msg="message"
:dynamic-props="name" />
</div>
</template>
<script setup>
const someCallback = function () {
console.log('ParentChild가 발신한 이벤트를 수신했어요.')
}
</script>
<!-- 자식 컴포넌트 -->
const emit = defineEmits(['emitArgs'])
const emitArgs = function () {
emit('emitArgs', 1, 2, 3)
}
// 부모 컴포넌트
const getNumbers = function (...args) {
console.log(args)
console.log(`ParentChild가 전달한 추가인자 ${args}를 수신했어요.`)
}
<!-- ParentChild.vue -->
<template>
<div>
<button @click="emitArgs">추가 인자 전달</button>
</div>
</template>
<script setup>
const emit = defineEmits(['emitArgs'])
const emitArgs = function () {
emit('emitArgs', 1, 2, 3)
}
</script>
<!-- Parent.vue -->
<template>
<div>
<ParentChild
@emit-args="getNumbers"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import ParentChild from '@/components/ParentChild.vue'
const getNumbers = function (...args) {
console.log(args)
console.log(`ParentChild가 전달한 추가인자 ${args}를 수신했어요.`)
}
</script>
(3) [1, 2, 3]와 ParentChild가 전달한 추가인자 1,2,3를 수신했어요.가 출력됨.someEvent)some-event)<button @click="$emit('someEvent')">클릭</button>
<script setup>
const emit = defineEmits(['someEvent'])
emit('someEvent')
</script>
<ParentChild @some-event="..." />
(props의 casing 규칙과 동일한 흐름: JS는 camelCase, HTML 템플릿은 kebab-case)
💡 핵심: 이벤트는 한 번에 최상단으로 올라가는 게 아니라 step-by-step으로 단계마다 발신/수신을 거쳐 올라간다.
목표: 최하단 컴포넌트 ParentGrandChild에서 Parent 컴포넌트의 name 변수 변경 요청하기
전달 경로: ParentGrandChild → ParentChild → Parent (emit이 한 단계씩 올라감)
<!-- ParentGrandChild.vue -->
<template>
<div>
<button @click="updateName">이름 변경</button>
</div>
</template>
<script setup>
const emit = defineEmits(['updateName'])
const updateName = function () {
emit('updateName')
}
</script>
<!-- ParentChild.vue -->
<template>
<div>
<ParentGrandChild :my-msg="myMsg" @update-name="updateName" />
</div>
</template>
<script setup>
import ParentGrandChild from '@/components/ParentGrandChild.vue'
const emit = defineEmits(['someEvent', 'emitArgs', 'updateName'])
const updateName = function () {
emit('updateName')
}
</script>
(해당 변수를 props로 받는 모든 곳에서 자동 업데이트)
<!-- Parent.vue -->
<template>
<div>
<ParentChild @update-name="updateName" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import ParentChild from '@/components/ParentChild.vue'
const name = ref('Alice')
const updateName = function () {
name.value = 'Bella'
}
</script>
name이 Alice → Bella로 변경되고,name을 props로 받는 모든 컴포넌트의 출력이 자동으로 갱신된다."1"을 전달.1을 전달.<!-- 1. 정적: 문자열 "1"이 전달됨 -->
<SomeComponent num-props="1" />
<!-- 2. 동적(v-bind): 숫자 1(Number)이 전달됨 -->
<SomeComponent :num-props="1" />
⚠️ 같은
1처럼 보여도,v-bind(:)가 붙으면 JS 표현식으로 평가되어 숫자 타입으로 전달된다. 정적은 그냥 문자열.
defineProps({
// 여러 타입 허용
propB: [String, Number],
// 문자열 필수
propC: {
type: String,
required: true
},
// 기본 값을 가지는 숫자형
propD: {
type: Number,
default: 10
},
// ...
})
const emit = defineEmits({
// 유효성 검사 없음
click: null,
// submit 이벤트 유효성 검사
submit: ({ email, password }) => {
if (email && password) {
return true
} else {
console.warn('submit 이벤트가 옳지 않음')
return false // 이벤트는 정상 발신되지만 Warning이 출력됨
}
}
})
const submitForm = function (email, password) {
emit('submit', { email, password })
}
⚠️ 유효성 검사에서
false를 반환해도 이벤트 자체는 정상 발신되며, 콘솔에 Warning만 출력된다.
| 구분 | 방향 | 키워드 | Casing (템플릿/HTML) | Casing (스크립트/JS) |
|---|---|---|---|---|
| Props | 부모 → 자식 (하향) | defineProps(), v-bind | kebab-case (my-msg) | camelCase (myMsg) |
| Emit | 자식 → 부모 (상향) | $emit, defineEmits(), v-on/@ | kebab-case (@some-event) | camelCase (someEvent) |