2023.02.02 State 끌어올리기
종종 동일한 데이터에 대한 변경사항을 여러 컴포넌트에 반영해야 할 필요가 있습니다.
이럴 때는 가장 가까운 공통 조상으로 state를 끌어올리는 것이 좋습니다.
Shared State는 공유된 state를 의미합니다.
자식 컴포넌트들이 가장 가까운 공통된 부모 컴포넌트의 state를 공유해서 사용하는 것이죠.
shared state는
어떤 컴포넌트의 state에 있는 데이터를 여러 개의 하위 컴포넌트에서 공통적으로 사용하는 경우를 말합니다.
위의 그림에서 자식 컴포넌트는 각각 값을 가지고 있을 필요가 없습니다.
그냥 부모 컴포넌트의 state에 있는 값에 각각 2와 3을 곱해서 표시해 주면 되기 때문입니다.
위의 그림에서 부모는 섭씨온도 값을 갖고 있으며
왼쪽 아래에 있는 컴포넌트 C는 온도를 섭씨로 표현하는 컴포넌트이고,
오른쪽 아래에 있는 컴포넌트 F는 온도를 화씨로 표현하는 컴포넌트 입니다.
이 경우에도 자식 컴포넌트들이 각각 온도 값을 가지고 있을 필요 없이,
그냥 부모 컴포넌트의 state에 있는 섭씨온도 값을 변환해서 표시해 주면 됩니다.
지금까지 살펴 본 것처럼 하위 컴포넌트가 공통된 부모 컴포넌트의 state를 공유하여 사용하는 것을 shared state라고 합니다.
사용자로부터 온도를 입력받아서 각각 섭씨온도와 화씨온도로 표현해 주고
해당 온도에서 물이 끓는지 않 끓는지를 출력해 주는 컴포넌트를 만들어 보면서 state를
공유하는 방법에 대해 자세히 살펴 보도록 하겠습니다.
먼저 섭씨온도 값을 props로 받아서 물이 끓는지 안 끓는지를 문자열로 출력해 주는 컴포넌트를 만들어 보도록 하겠습니다.
먼저 BoilingVerdict라는 이름의 컴포넌트부터 만들어봅시다.
이 컴포넌트는 섭씨온도를 의미하는 celsius prop를 받아서 이 온도가 물이 끓기에 충분한지 여부를 출력합니다.
function BoilingVerdict(props) {
if (props.celsius >= 100) {
return <p>The water would boil.</p>;
}
return <p>The water would not boil.</p>;
}
그 다음으로 Calculator라는 부모 컴포넌트를 만들어보겠습니다.
이 컴포넌트는 state로 온도 값을 하나 갖고 있습니다.
또한 사용자로부터 입력을 받기 위해서 <input> 태그를 사용하여 앞에서 배운 제어 컴포넌트 형태로 구현되어 있습니다.
사용자가 온도 값을 변경할 때마다 handleChange()함수가 호출되고,
setTemperature()함수를 통해 온도 값을 갖고 있는 temperature라는 이름의 state를 업데이트합니다.
그리고 state에 있는 온도 값은 앞에서 만든 BoilingVerdict 컴포넌트에 selsius라는 이름의 props로 전달됩니다.
또한 현재 입력값에 대한 BoilingVerdict 컴포넌트를 렌더링합니다.
import { useState } from "react";
function Calculator(props) {
const [temperature, setTemperature] = useState('');
const handleChange = (event) => {
setTemperature(event.target.value);
}
return (
<fieldset>
<legend>섭씨 온도를 입력하세요:</legend>
<input
value={temperature}
onChange={handleChange} />
<BoilingVerdict celsius={parseFloat(temperature)} />
</fieldset>
)
}
새 요구사항으로써 섭씨 입력 필드뿐만 아니라 화씨 입력 필드를 추가하고 두 필드 간에 동기화 상태를 유지하도록 해보겠습니다.
Calculator에서 TemperatureInput 컴포넌트를 빼내는 작업부터 시작해봅시다.
또한 "c" 또는 "f"의 값을 가질 수 있는 scale prop를 추가할 것입니다.
import { useState } from "react";
const scaleNames = {
c: '섭씨',
f: '화씨'
};
function TemperatureInput(props) {
const [temperature, setTemperature] = useState('');
const handleChange = (event) => {
setTemperature(event.target.value);
}
return (
<fieldset>
<legend>온도를 입력해 주세요(단위:{scaleNames[props.scale]}):</legend>
<input value={temperature} onChange={handleChange} />
</fieldset>
)
}
위 코드는 온도를 입력받기 위한 TemperatureInput 컴포넌트입니다.
Calcuator 컴포넌트에서 온도를 입력받는 부분을 추출하여 별도의 컴포넌트로 만든 것입니다.
추가적으로 props에 단위를 나타내는 scale을 추가하여 온도의 단위를 섭씨 또는 화씨로 입력 가능하도록 만들었습니다.
이제 Calculator가 분리된 두 개의 온도 입력 필드를 렌더링하도록 변경할 수 있습니다.
function Calculator(props) {
return (
<div>
<TemperatureInput scale="c" />
<TemperatureInput scale="f" />
</div>
);
}
이제 섭씨온도,화씨온도 두 개의 입력 필드를 갖게 되었습니다.
그러나 사용자가 입력하는 온도 값이 Temperature의 state에 저장되기 때문에
섭씨온도와 화씨온도 값을 따로 입력받으면 두 개의 값이 다를 수 있습니다.
이를 해결하기 위해서 값을 동기화시켜줘야 합니다.
또한 Calculator에서 BoilingVerdict도 역시 보여줄 수 없는 상황입니다.
현재 입력된 온도 정보가 TemperatureInput 안에 숨겨져 있으므로 Calculator는 그 값을 알 수 없기 때문입니다.
먼저, 섭씨를 화씨로, 또는 그 반대로 변환해주는 함수를 작성해보겠습니다.
function toCelsius(fahrenheit) {
return (fahrenheit - 32) * 5 / 9;
}
function toFahrenheit(celsius) {
return (celsius * 9 / 5) + 32;
}
이 두 함수는 숫자를 변환합니다.
이렇게 만든 함수를 호출하는 함수를 작성해 보겠습니다.
function tryConvert(temperature, convert) {
const input = parseFloat(temperature);
if (Number.isNaN(input)) {
return '';
}
const output = convert(input);
const rounded = Math.round(output * 1000) / 1000;
return rounded.toString();
}
tryConvert() 함수는 온도 값과 변환하는 함수를 파라미터로 받아서 값을 변환시켜 리턴해주는 함수입니다.
만약 숫자가 아닌 값을 입력하면 empty string을 리턴하도록 예외 처리를 했습니다.
이 함수를 사용하는 방법은 아래와 같습니다.
tryConvert('abc', toCelsius) //empty string을 리턴
tryConvert('10.22', toFahrenheit) //'50.396'을 리턴
현재는 두 TemperatureInput 컴포넌트가 각각의 입력값을 각자의 state에 독립적으로 저장하고 있습니다.
function TemperatureInput(props) {
const [temperature, setTemperature] = useState('');
const handleChange = (event) => {
setTemperature(event.target.value);
}
return (
<fieldset>
<legend>온도를 입력해 주세요(단위:{scaleNames[props.scale]}):</legend>
<input value={temperature} onChange={handleChange} />
</fieldset>
)
}
그러나 우리는 두 입력값이 서로의 것과 동기화된 상태로 있길 원합니다.
섭씨온도 입력값을 변경할 경우 화씨온도 입력값 역시 변환된 온도를 반영할 수 있어야 하며,
그 반대의 경우에도 마찬가지여야 합니다.
React에서 state를 공유하는 일은 그 값을 필요로 하는 컴포넌트 간의 가장 가까운 공통 조상으로 state를 끌어올림으로써 이뤄낼 수 있습니다.
이렇게 하는 방법을 “state 끌어올리기”라고 부릅니다.
이를 위해 먼저 TemperatureInput 컴포넌트에서 온도 값을 가져오는 부분을 아래와 같이 수정해야 합니다.
return (
<fieldset>
<legend>온도를 입력해 주세요(단위:{scaleNames[props.scale]}):</legend>
// 변경전 : <input value={temperature} onChange={handleChange} />
<input value={props.temperature} onChange={handleChange} />
</fieldset>
)
이렇게 하면 온도 값을 컴포넌트의 sate에서 가져오는 것이 아닌
props를 통해서 가져오게 됩니다.
또한 컴포넌트의 state를 사용하지 않게 되기 때문에 입력값이 변경되었을 때
상위 컴포넌트로 변경된 값을 전달해 주어야 합니다.
이를 위해서 handleChange()함수를 다음과 같이 변경합니다.
const handleChange = (event) => {
//변경전: setTemperature(event.target.value);
props.onTemperatureChange(event.target.value);
}
이제 사용자가 온도 값을 변경할 때마다 props에 있는 onTemperatureChange()함수를 통해 변경된 온도 값이 상위 컴포넌트로 전달됩니다.
최종적으로 완성된 TemperatureInput 컴포넌트의 모습은 아래와 같습니다.
state는 제거되었고 오로지 상위 컴포넌트에서 전달받은 값만을 사용하고 있습니다.
import { useState } from "react";
const scaleNames = {
c: '섭씨',
f: '화씨'
};
function TemperatureInput(props) {
const handleChange = (event) => {
props.onTemperatureChange(event.target.value);
}
return (
<fieldset>
<legend>온도를 입력해 주세요(단위:{scaleNames[props.scale]}):</legend>
<input value={props.temperature} onChange={handleChange} />
</fieldset>
)
}
마지막으로 변경된 TemperatureInput 컴포넌트에 맞춰서 Calculator 컴포넌트를 변경해 주어야 합니다.
import { useState } from "react";
function Calculator(props) {
const [temperature, setTemperature] = useState('');
const [scale, setScale] = useState('c');
const handleCelsiusChange = (temperature) => {
setTemperature(temperature);
setScale('c');
}
const handleFahrenheitChange = (temperature) => {
setTemperature(temperature);
setScale('f');
}
const celsius = scale === 'f' ? tryConvert(temperature, toCelsius) : temperature;
const fahrenheit = scale === 'c' ? tryConvert(temperature, toFahrenheit) : temperature;
return(
<div>
<TemperatureInput
scale="c"
temperature={celsius}
onTemperatureChange={handleCelsiusChange} />
<TemperatureInput
scale="f"
temperature={fahrenheit}
onTemperatureChange={handleFahrenheitChange} />
<BoilingVerdict
celsius={parseFloat(celsius)} />
</div>
);
}
우선 state로 temperature와 scale을 선언하여 온도 값과 단위를 각각 저장하도록 하였습니다.
이 온도와 단위를 이용하여 변환 함수를 통해 섭씨온도와 화씨온도를 구해서 사용합니다.
TemperatureInput 컴포넌트를 사용하는 부분에서는 각 단위로 변환된 온도 값과 단위를 props로 넣어 주었고,
값이 변경되었을 때 업데이트하기 위한 함수를 onTemperatureChange에 넣어 주었습니다.
따라서 섭씨온도가 변경되면 단위가 'c'로 변경되고,
화씨온도가 변경되면 단위가 'f'로 변경됩니다.
최종적으로 완성된 구조를 그림으로 나타내면 아래와 같습니다.
상위 컴포넌트인 Calculator에서 온도 값과 단위를 각각의 state로 가지고 있으며,
두 개의 하위 컴포넌트는 각각 섭씨와 화씨로 변환된 온도 값과 단위 그리고 온도를 업데이트하기 위한 함수를 props로 갖고 있습니다.
이처럼 각 컴포넌트가 state에 값을 갖고 있는 것이 아니라 공통된 상위 컴포넌트로 올려서 공유하는 방법을 사용하면 리액트에서 더욱 간결하고 효율적인 개발을 할 수 있습니다.
입력값을 변경할 때 일어나는 일들을 정리해보겠습니다.
React는 DOM <input>의 onChange에 지정된 함수를 호출합니다.
위 예시의 경우 TemperatureInput의 handleChange 메서드에 해당합니다.
TemperatureInput 컴포넌트의 handleChange 메서드는 새로 입력된 값과 함께 props.onTemperatureChange()를 호출합니다.
onTemperatureChange를 포함한 이 컴포넌트의 props는 부모 컴포넌트인 Calculator로부터 제공받은 것입니다.
이전 렌더링 단계에서, Calculator는 섭씨 TemperatureInput의 onTemperatureChange를 Calculator의 handleCelsiusChange 메서드로, 화씨 TemperatureInput의 onTemperatureChange를 Calculator의 handleFahrenheitChange 메서드로 지정해놓았습니다. - 따라서 우리가 둘 중에 어떤 입력 필드를 수정하느냐에 따라서 Calculator의 두 메서드 중 하나가 호출됩니다.
이들 메서드는 내부적으로 Calculator 컴포넌트가 새 입력값, 그리고 현재 수정한 입력 필드의 입력 단위와 함께 setTemperature(temperature),setScale("c");를 호출하게 함으로써 React에게 자신을 다시 렌더링하도록 요청합니다.
React는 UI가 어떻게 보여야 하는지 알아내기 위해 Calculator 컴포넌트의 render 메서드를 호출합니다.
두 입력 필드의 값은 현재 온도와 활성화된 단위를 기반으로 재계산됩니다.
온도의 변환이 이 단계에서 수행됩니다.
React는 Calculator가 전달한 새 props와 함께 각 TemperatureInput 컴포넌트의 render 메서드를 호출합니다.
그러면서 UI가 어떻게 보여야 할지를 파악합니다.
React는 BoilingVerdict 컴포넌트에게 섭씨온도를 props로 건네면서 그 컴포넌트의 render 메서드를 호출합니다.
React DOM은 물의 끓는 여부와 올바른 입력값을 일치시키는 작업과 함께 DOM을 갱신합니다.
값을 변경한 입력 필드는 현재 입력값을 그대로 받고, 다른 입력 필드는 변환된 온도 값으로 갱신됩니다.
입력 필드의 값을 변경할 때마다 동일한 절차를 거치고 두 입력 필드는 동기화된 상태로 유지됩니다.
React 애플리케이션 안에서 변경이 일어나는 데이터에 대해서는 “진리의 원천(source of truth)“을 하나만 두어야 합니다. 보통의 경우, state는 렌더링에 그 값을 필요로 하는 컴포넌트에 먼저 추가됩니다. 그러고 나서 다른 컴포넌트도 역시 그 값이 필요하게 되면 그 값을 그들의 가장 가까운 공통 조상으로 끌어올리면 됩니다. 다른 컴포넌트 간에 존재하는 state를 동기화시키려고 노력하는 대신 하향식 데이터 흐름에 기대는 걸 추천합니다.
state를 끌어올리는 작업은 양방향 바인딩 접근 방식보다 더 많은 “보일러 플레이트” 코드를 유발하지만, 버그를 찾고 격리하기 더 쉽게 만든다는 장점이 있습니다. 어떤 state든 간에 특정 컴포넌트 안에서 존재하기 마련이고 그 컴포넌트가 자신의 state를 스스로 변경할 수 있으므로 버그가 존재할 수 있는 범위가 크게 줄어듭니다. 또한 사용자의 입력을 거부하거나 변형하는 자체 로직을 구현할 수도 있습니다.
어떤 값이 props 또는 state로부터 계산될 수 있다면, 아마도 그 값을 state에 두어서는 안 됩니다. 예를 들어 celsiusValue와 fahrenheitValue를 둘 다 저장하는 대신, 단지 최근에 변경된 temperature와 scale만 저장하면 됩니다. 다른 입력 필드의 값은 항상 그 값들에 기반해서 render() 메서드 안에서 계산될 수 있습니다. 이를 통해 사용자 입력값의 정밀도를 유지한 채 다른 필드의 입력값에 반올림을 지우거나 적용할 수 있게 됩니다.
UI에서 무언가 잘못된 부분이 있을 경우, React Developer Tools를 이용하여 props를 검사하고 state를 갱신할 책임이 있는 컴포넌트를 찾을 때까지 트리를 따라 탐색해보세요. 이렇게 함으로써 소스 코드에서 버그를 추적할 수 있습니다.
const scaleNames = {
c: "섭씨",
f: "화씨"
};
function TemperatureInput(props) {
const handleChanege = (e) => {
props.onTemperatureChange(e.target.value);
};
return (
<fieldset>
<legend>
온도를 입력해주세요(단위: {scaleNames[props.scale]});
</legend>
<input value={props.temperature} onChange={handleChanege} />
</fieldset>
);
}
export default TemperatureInput;
TemperatureInput 컴포넌트는 props로 scale과 temperature를 받아서 표시해 주며,
온도 값이 변경되었을 때에는 props의 onTemperatureChange() 함수를 호출하여 상위 컴포넌트로 변경된 값을 전달하게 됩니다.
import React, {useState} from "react";
import TemperatureInput from "./TemperatureInput";
function BoilingVerdict(props) {
if (props.celsius >= 100) {
return <p>물이 끓습니다.</p>
}
return <p>물이 끓지 않습니다.</p>
}
function toCelsius(fahrenheit) {
return ((fahrenheit - 32) * 5) / 9;
}
function toFahrenheit(celsius) {
return (celsius * 9) / 5 + 32;
}
function tryConvert(temperature, convert) {
const input = parseFloat(temperature);
if (Number.isNaN(input)) {
return "";
}
const output = convert(input);
const reounded = Math.round(output * 1000) / 1000;
return reounded.toString();
}
function Calculator(props) {
const [temperature, setTemperature] = useState("");
const [scale, setScale] = useState("c");
const handleCelsiusChange = (temperature) => {
setTemperature(temperature);
setScale("c");
};
const handleFahrenheitChange = (temperature) => {
setTemperature(temperature);
setScale("f");
};
const celsius = scale === "f" ? tryConvert(temperature, toCelsius) : temperature;
const fahrenheit = scale === "c" ? tryConvert(temperature, toFahrenheit) : temperature;
return (
<div>
<TemperatureInput
scale = "c"
temperature = {celsius}
onTemperatureChange={handleCelsiusChange}
/>
<TemperatureInput
scale="f"
temperature={celsius}
onTemperatureChange={handleFahrenheitChange}
/>
<BoilingVerdict celsius={parseFloat(celsius)} />
</div>
);
}
export default Calculator;
Calculator 컴포넌트는 앞에서 만든 TemperatureInput 컴포넌트를 사용하여
섭씨와 화씨 두 가지의 입력 양식을 제공합니다.
또한 모든 온도를 섭씨로 변환하여 BoilingVerdict 컴포넌트에 전달해 줌으로써 물이 끓는지 아닌지를 출력합니다.
수정
import React, { Profiler } from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import Calculator from './chapter_12/Calculator';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<Calculator />
</React.StrictMode>
);
reportWebVitals();
화면에 섭씨나 화씨를 입력하면 그 값에 따라 섭씨나 화씨로 자동 변환되는고
물이 끓는지의 여부도 볼 수 있다.