이번에는 React의 State와 Props의 관계, 그리고 컴포넌트가 언제 리렌더링되는지 알아보겠습니다.
특히 서로 관련 없어 보이는 Bulb와 Counter 컴포넌트가 왜 함께 리렌더링되는지 확인하고, 컴포넌트를 분리하는 이유도 함께 살펴보겠습니다.
1. State를 Props로 전달하기
React에서는 부모 컴포넌트가 관리하는 State를 자식 컴포넌트에 Props로 전달할 수 있습니다.
예를 들어 App 컴포넌트에서 전구의 상태를 관리하고, Bulb 컴포넌트에 전달해보겠습니다.
import './App.css'
import { useState } from 'react'
const Bulb = ({ light }) => {
console.log(light)
return (
<div>
{light === 'ON' ? (
<h1 style={{ backgroundColor: 'orange' }}>ON</h1>
) : (
<h1 style={{ backgroundColor: 'gray' }}>OFF</h1>
)}
</div>
)
}
function App() {
const [count, setCount] = useState(0)
const [light, setLight] = useState('OFF')
return (
<>
<div>
<Bulb light={light} />
<h1>{light}</h1>
<button
onClick={() =>
setLight(light === 'OFF' ? 'ON' : 'OFF')
}
>
{light === 'ON' ? 'Light OFF' : 'Light ON'}
</button>
</div>
<div>
<h1>{count}</h1>
<button onClick={() => setCount(count + 1)}>
+
</button>
</div>
</>
)
}
export default App
여기서 App 컴포넌트는 두 개의 State를 관리하고 있습니다.
const [count, setCount] = useState(0)
const [light, setLight] = useState('OFF')
- count → 카운터 상태
- light → 전구 상태
그리고 light State를 Bulb 컴포넌트에 Props로 전달합니다.
<Bulb light={light} />
2. Props는 객체 형태로 전달된다
자식 컴포넌트에서는 전달받은 Props를 사용할 수 있습니다.
const Bulb = ({ light }) => {
이는 구조 분해 할당을 사용한 것입니다.
실제로 Props는 객체 형태로 전달됩니다.
const Bulb = (props) => {
console.log(props)
}
light라는 Props를 전달했다면 다음과 같은 형태입니다.
{
light: 'OFF'
}
따라서 다음과 같이 사용할 수도 있습니다.
const Bulb = (props) => {
return <h1>{props.light}</h1>
}
하지만 일반적으로 필요한 Props만 구조 분해 할당으로 받아 사용하는 경우가 많습니다.
const Bulb = ({ light }) => {
return <h1>{light}</h1>
}
3. State가 변경되면 어떻게 될까?
예를 들어 Light ON 버튼을 클릭하면 다음 코드가 실행됩니다.
setLight(light === 'OFF' ? 'ON' : 'OFF')
기존 상태가 OFF라면 ON으로 변경됩니다.
OFF
↓
버튼 클릭
↓
setLight()
↓
ON
State가 변경되면 React는 해당 State를 관리하는 App 컴포넌트를 다시 렌더링합니다.
그리고 변경된 light 값이 다시 Bulb 컴포넌트에 Props로 전달됩니다.
App의 light State 변경
↓
App 컴포넌트 리렌더링
↓
Bulb에 새로운 Props 전달
↓
Bulb 리렌더링
이 경우 Bulb 컴포넌트가 리렌더링되는 것은 자연스러운 동작입니다.
4. 그런데 Count 버튼을 눌러도 Bulb가 리렌더링된다?
여기서 한 가지 의문이 생깁니다.
Bulb는 light State만 사용하고 있습니다.
그런데 Count 버튼을 눌러 count State만 변경해도 Bulb의 console.log()가 다시 실행되는 것을 확인할 수 있습니다.
const Bulb = ({ light }) => {
console.log(light)
// ...
}
즉, 다음과 같은 상황입니다.
Count 버튼 클릭
↓
count State 변경
↓
App 컴포넌트 리렌더링
↓
Bulb 컴포넌트도 다시 렌더링
count와 Bulb는 직접적인 관계가 없는데 왜 이런 일이 발생할까요?
5. React 컴포넌트가 리렌더링되는 경우
일반적으로 React 컴포넌트의 리렌더링을 이해할 때 다음 세 가지 경우를 기억하면 됩니다.
1. 자신이 관리하는 State가 변경될 때
컴포넌트 내부의 State가 변경되면 해당 컴포넌트는 다시 렌더링됩니다.
const [count, setCount] = useState(0)
setCount(count + 1)
count가 변경되면 해당 State를 관리하는 컴포넌트가 리렌더링됩니다.
2. 제공받는 Props가 변경될 때
부모로부터 전달받은 Props가 변경되면 자식 컴포넌트도 새로운 값을 반영하기 위해 다시 렌더링됩니다.
<Bulb light={light} />
light 값이 변경되면 Bulb도 변경된 Props를 받습니다.
3. 부모 컴포넌트가 리렌더링될 때
기본적으로 부모 컴포넌트가 다시 렌더링되면 그 과정에서 자식 컴포넌트도 다시 렌더링됩니다.
현재 구조는 다음과 같습니다.
App
├── Bulb
└── Counter 영역
count State는 App 컴포넌트가 관리하고 있습니다.
따라서 Count 버튼을 클릭하면 다음과 같은 과정이 발생합니다.
Count 버튼 클릭
↓
App의 count State 변경
↓
App 컴포넌트 리렌더링
↓
App 내부의 자식 컴포넌트도 렌더링 과정 수행
↓
Bulb 컴포넌트 리렌더링
그래서 Bulb와 직접 관련 없는 count State가 변경되어도 Bulb의 함수가 다시 실행되는 것을 확인할 수 있습니다.
6. 관련 없는 State를 하나의 컴포넌트에서 관리하는 문제
현재 App 컴포넌트는 다음 두 State를 모두 관리합니다.
const [count, setCount] = useState(0)
const [light, setLight] = useState('OFF')
하지만 두 상태는 서로 관련이 없습니다.
light ─────→ Bulb
count ─────→ Counter
그런데 둘 다 App에서 관리하고 있기 때문에 어느 하나의 State가 변경되어도 App이 리렌더링됩니다.
count 변경
↓
App 리렌더링
↓
Bulb도 렌더링
반대로 light가 변경되어도 마찬가지입니다.
light 변경
↓
App 리렌더링
↓
Counter 영역도 렌더링
컴포넌트가 작을 때는 큰 문제가 아닐 수 있지만, 컴포넌트가 복잡하고 자식 컴포넌트가 많아질수록 불필요한 렌더링이 발생할 가능성이 커집니다.
따라서 서로 관련 없는 상태는 가능한 한 해당 상태가 실제로 필요한 컴포넌트 가까이에서 관리하는 것이 좋습니다.
이를 State를 필요한 위치에 배치한다는 의미로 이해할 수 있습니다.
7. State를 각각의 컴포넌트로 이동하기
light는 Bulb에서만 필요하므로 Bulb 내부에서 관리하도록 변경할 수 있습니다.
const Bulb = () => {
const [light, setLight] = useState('OFF')
return (
<div>
{light === 'ON' ? (
<h1 style={{ backgroundColor: 'orange' }}>ON</h1>
) : (
<h1 style={{ backgroundColor: 'gray' }}>OFF</h1>
)}
</div>
)
}
이제 Bulb는 부모로부터 light를 전달받지 않습니다.
따라서 Props도 필요하지 않습니다.
기존에는 다음과 같았습니다.
const Bulb = ({ light }) => {
변경 후에는 다음과 같습니다.
const Bulb = () => {
8. Bulb 내부에서 전구 상태 변경하기
State를 Bulb 내부로 옮겼다면 상태를 변경하는 버튼도 Bulb 컴포넌트에서 관리하는 것이 자연스럽습니다.
const Bulb = () => {
const [light, setLight] = useState('OFF')
return (
<div>
<h1
style={{
backgroundColor: light === 'ON' ? 'orange' : 'gray',
}}
>
{light}
</h1>
<button
onClick={() =>
setLight(light === 'OFF' ? 'ON' : 'OFF')
}
>
{light === 'ON' ? 'Light OFF' : 'Light ON'}
</button>
</div>
)
}
이제 Bulb는 자신의 상태를 직접 관리합니다.
Bulb
└── light State
├── ON
└── OFF
9. Counter도 별도의 컴포넌트로 분리하기
마찬가지로 count State는 Counter 컴포넌트에서만 필요합니다.
따라서 Counter 컴포넌트를 별도로 만들 수 있습니다.
const Counter = () => {
const [count, setCount] = useState(0)
return (
<div>
<h1>{count}</h1>
<button onClick={() => setCount(count + 1)}>
+
</button>
</div>
)
}
이제 Counter가 자신의 State를 직접 관리합니다.
Counter
└── count State
↓
버튼 클릭
↓
count 증가
10. 전체 코드를 컴포넌트별로 작성하기
우선 하나의 파일에 작성하면 다음과 같습니다.
import './App.css'
import { useState } from 'react'
const Bulb = () => {
const [light, setLight] = useState('OFF')
return (
<div>
<h1
style={{
backgroundColor: light === 'ON' ? 'orange' : 'gray',
}}
>
{light}
</h1>
<button
onClick={() =>
setLight(light === 'OFF' ? 'ON' : 'OFF')
}
>
{light === 'ON' ? 'Light OFF' : 'Light ON'}
</button>
</div>
)
}
const Counter = () => {
const [count, setCount] = useState(0)
return (
<div>
<h1>{count}</h1>
<button onClick={() => setCount(count + 1)}>
+
</button>
</div>
)
}
function App() {
return (
<>
<Bulb />
<Counter />
</>
)
}
export default App
이제 구조는 다음과 같습니다.
App
├── Bulb
│ └── light State
│
└── Counter
└── count State
11. 실제 프로젝트에서는 파일로 분리하기
일반적으로 실제 React 프로젝트에서는 컴포넌트를 별도의 파일로 분리해서 관리합니다.
예를 들어 다음과 같은 구조로 만들 수 있습니다.
src
├── components
│ ├── Bulb.jsx
│ └── Counter.jsx
├── App.jsx
├── App.css
└── main.jsx
Bulb.jsx
import { useState } from 'react'
const Bulb = () => {
const [light, setLight] = useState('OFF')
return (
<div>
<h1
style={{
backgroundColor: light === 'ON' ? 'orange' : 'gray',
}}
>
{light}
</h1>
<button
onClick={() =>
setLight(light === 'OFF' ? 'ON' : 'OFF')
}
>
{light === 'ON' ? 'Light OFF' : 'Light ON'}
</button>
</div>
)
}
export default Bulb
Counter.jsx
import { useState } from 'react'
const Counter = () => {
const [count, setCount] = useState(0)
return (
<div>
<h1>{count}</h1>
<button onClick={() => setCount(count + 1)}>
+
</button>
</div>
)
}
export default Counter
App.jsx
App에서는 각각의 컴포넌트를 불러와 화면에 배치하기만 하면 됩니다.
import './App.css'
import Bulb from './components/Bulb'
import Counter from './components/Counter'
function App() {
return (
<>
<Bulb />
<Counter />
</>
)
}
export default App
이렇게 하면 App 컴포넌트가 훨씬 간단해집니다.
12. 컴포넌트를 분리한 후의 리렌더링
State를 각각의 컴포넌트 내부로 이동하면 구조가 다음과 같이 변경됩니다.
App
├── Bulb
│ └── light State
│
└── Counter
└── count State
이제 Counter의 버튼을 클릭하면 count State가 변경됩니다.
Counter의 버튼 클릭
↓
Counter의 count 변경
↓
Counter 리렌더링
Bulb의 State는 변경되지 않았기 때문에 Bulb는 이 상태 변경과 직접적인 관계가 없습니다.
반대로 전구 버튼을 클릭하면 다음과 같습니다.
Bulb의 버튼 클릭
↓
Bulb의 light 변경
↓
Bulb 리렌더링
Counter 역시 직접적인 영향을 받지 않습니다.
이처럼 State를 실제로 필요한 컴포넌트 가까이에서 관리하면 컴포넌트의 책임을 분리하고 불필요하게 넓은 렌더링 범위를 줄이는 데 도움이 됩니다.
다만 모든 State를 무조건 자식 컴포넌트로 내리는 것이 정답은 아닙니다. 여러 컴포넌트가 같은 State를 공유해야 한다면 오히려 공통 부모에서 State를 관리하고 Props로 전달해야 합니다.
정리
이번에는 State를 Props로 전달하는 방법과 컴포넌트의 리렌더링에 대해 알아봤습니다.
State를 Props로 전달하기
부모 컴포넌트의 State를 자식 컴포넌트에 전달할 수 있습니다.
<Bulb light={light} />
자식 컴포넌트에서는 Props로 받을 수 있습니다.
const Bulb = ({ light }) => {
// ...
}
리렌더링이 발생하는 주요 경우
- 컴포넌트가 관리하는 State가 변경될 때
- 컴포넌트가 전달받는 Props가 변경될 때
- 부모 컴포넌트가 리렌더링되어 자식도 렌더링 과정에 포함될 때
State는 필요한 곳 가까이에서 관리하기
서로 관련 없는 State를 하나의 부모 컴포넌트에서 모두 관리하면, 하나의 State 변경이 부모의 리렌더링으로 이어지고 다른 자식 컴포넌트까지 렌더링에 영향을 받을 수 있습니다.
❌ App
├── count State
└── light State
따라서 서로 독립적인 기능이라면 다음과 같이 책임을 분리할 수 있습니다.
✅ App
├── Bulb
│ └── light State
│
└── Counter
└── count State
핵심은 State를 무조건 상위 또는 하위 컴포넌트에 두는 것이 아니라, 해당 State를 실제로 사용하는 범위를 고려해 가장 적절한 위치에서 관리하는 것입니다.