State
는 리액트에서 앱의 유동적인 데이터를 다루기 위해 있는 객체로, React에서는 일반적으로 유동적인 데이터는 변수에 담아 사용하지 않고 useState()
라는 리액트 함수를 사용하여 State라는 저장공간에 담아서 사용합니다.
React는 종종 동일한 데이터에 대한 변경사항을 여러 컴포넌트에 반영해야 할 필요가 있는데, 이럴 때는 가장 가까운 공통의 조상 컴포넌트로 state를 끌어올려 사용하는 것이 좋습니다.
온도 계산기 만들기
주어진 온도에서 물이 끓는지 여부를 추정하는 온도 계산기를 만들기 위해 우선 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
라는 컴포넌트를 만드는데, 이 컴포넌트는 온도를 입력할 수 있는 <input>
을 렌더링하고, 그 값을 this.state.temperature
에 저장하는 역할을 합니다. 그리고 이 컴포넌트는 현재 입력값에 대한 BoilingVerdict
컴포넌트를 렌더링하는 부모 컴포넌트이기도 합니다.
class Calculator extends React.Component { constructor(props) { super(props); this.handleChange = this.handleChange.bind(this); this.state = {temperature: ''}; } handleChange(e) { this.setState({temperature: e.target.value}); } render() { const temperature = this.state.temperature; return ( <fieldset> <legend>Enter temperature in Celsius:</legend> <input value={temperature} onChange={this.handleChange} /> <BoilingVerdict celsius={parseFloat(temperature)} /> </fieldset> ); } }
여기까지 코드를 완성했다면 온도 계산기가 입력된 수치(온도)에 따라 물이 끓는지 여부를 판단해 적절한 메세지를 보여주는 것을 확인할 수 있습니다.
새로운 Input 추가
앞에서 만든 온도 계산기에는 섭씨 온도를 입력하는 필드만 추가했지만, 새로운 요구 사항으로 화씨 온도를 입력하는 필드를 추가하고 두 필드 간에 동기화 상태를 유지하도록 만들어야 한다고 가정해 보겠습니다.
이를 위해 우선 Calculator
에서 TemperatureInput
컴포넌트를 빼내고, "c"
또는 "f"
의 값을 가질 수 있는 scale이라는 prop를 추가합니다.
const scaleNames = { c: 'Celsius', f: 'Fahrenheit' }; class TemperatureInput extends React.Component { constructor(props) { super(props); this.handleChange = this.handleChange.bind(this); this.state = {temperature: ''}; } handleChange(e) { this.setState({temperature: e.target.value}); } render() { const temperature = this.state.temperature; const scale = this.props.scale; return ( <fieldset> <legend>Enter temperature in {scaleNames[scale]}:</legend> <input value={temperature} onChange={this.handleChange} /> </fieldset> ); } }
위와 같이 TemperatureInput
컴포넌트를 수정했다면, 이제 다음과 같이 Calculator
가 분리된 두 개의 온도 입력 필드를 렌더링하도록 변경할 수 있습니다.
class Calculator extends React.Component { render() { return ( <div> <TemperatureInput scale="c" /> <TemperatureInput scale="f" /> </div> ); } }
코드에서와 같이 온도 계산기 필드 추가는 두 개의 입력 필드를 갖게 되었지만, 두 입력 필드 간에 동기화 상태가 유지되지 않고 섭씨 또는 화씨 온도 입력 필드 중 하나에 온도를 입력해도 다른 온도는 갱신되지 않는 문제가 아직 남아있습니다.
또 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(); }
위의 함수는 temperature
문자열과 변환 함수를 인수로 취해 문자열을 반환하는 함수로 한 입력값에 기반하여 나머지 입력값을 계산하는 용도로 사용할 수 있는데, 이 함수는 올바르지 않은 temperature
값에 대해서는 빈 문자열을 반환하고 값을 소수점 세 번째 자리로 반올림하여 출력하게 됩니다.
예를 들어 tryConvert('abc', toCelsius)
와 같이 사용하면 빈 문자열을 반환하고, tryConvert('10.22', toFahrenheit)
와 같이 사용하면 '50.396'
을 반환하게 됩니다.
State 끌어올리기
class TemperatureInput extends React.Component { constructor(props) { super(props); this.handleChange = this.handleChange.bind(this); this.state = {temperature: ''}; } handleChange(e) { this.setState({temperature: e.target.value}); } render() { const temperature = this.state.temperature; // ...
위 코드와 같이 현재는 두 TemperatureInput
컴포넌트가 각각의 입력값을 각자의 state
에 독립적으로 저장하고 있지만 새로운 요구 사항에 따라 두 입력값이 서로의 값과 동기화된 상태를 유지해야 합니다.
즉 섭씨 온도의 입력값을 변경하면 화씨 온도의 입력값이 변환된 온도를 반영할 수 있어야 하고 그 반대의 경우에도 마찬가지여야 하는데, React에서 state
를 공유하는 일은 그 값을 필요로 하는 컴포넌트 간의 가장 가까운 공통 조상으로 state
를 끌어올림으로써 이뤄낼 수 있습니다.
이런 방법을 “state 끌어올리기”라고 하는데, 이를 위해 TemperatureInput
이 개별적으로 가지고 있던 지역 state
를 지우는 대신 Calculator
로 그 값을 옮겨놓아야 합니다.
Calculator
가 공유될 state
를 소유하고 있으면, 이를 통해 두 입력 필드가 서로 간에 일관된 값을 유지하도록 만들 수 있습니다. 즉 두 TemperatureInput
컴포넌트의 props
가 같은 부모인 Calculator
로부터 전달되기 때문에, 두 입력 필드는 항상 동기화된 상태를 유지할 수 있게 됩니다.
우선 다음과 같이 TemperatureInput
컴포넌트에서 this.state.temperature
를 this.props.temperature
로 대체해야 하는데, 현재는 this.props.temperature
가 이미 존재한다고 가정하지만, 실제로 이 값은 Calculator
로부터 건네받아야 할 값입니다.
render() { // Before: const temperature = this.state.temperature; const temperature = this.props.temperature; // ...
여기서 props
는 읽기 전용 속성인데, temperature
가 지역 state
였을 때는 그 값을 변경하기 위해 TemperatureInput
의 this.setState()
를 호출하는 걸로 충분했지만, 이제는 temperature
가 부모로부터 prop
으로 전달되기 때문에 TemperatureInput
은 그 값을 제어할 수 없습니다.
React에서는 이런 경우에 컴포넌트를 “제어 가능” 하게 만드는 방식으로 해결하는데, DOM <input>
이 value
와 onChange
라는 prop
을 건네받는 것과 비슷한 방식입니다. 즉 사용자 정의된 TemperatureInput
역시 temperature
와 onTemperatureChange
라는 props
를 자신의 부모인 Calculator
로부터 건네받을 수 있습니다.
handleChange(e) { // Before: this.setState({temperature: e.target.value}); this.props.onTemperatureChange(e.target.value); // ...
위 코드와 같이 이제 TemperatureInput
에서 온도를 갱신하고 싶으면 this.props.onTemperatureChange
를 호출하면 되는데, onTemperatureChange
prop은 부모 컴포넌트인 Calculator
로부터 temperature
prop과 함께 제공됩니다.
이를 통해 각 컴포넌트는 자신의 지역 state
를 수정해서 변경사항을 처리하기 때문에, 변경된 새 값을 전달받은 두 입력 필드는 모두 리렌더링되어 표현됩니다.
참고로, 사용자 정의 컴포넌트에서 temperature
와 onTemperatureChange
prop의 이름이 특별한 의미를 갖는 것은 아닙니다. 단순히 일관된 컨벤션으로 value
와 onChange
을 사용할 수도 있고, 다른 원하는 그 어떤 이름이라도 사용할 수 있습니다.
class TemperatureInput extends React.Component { constructor(props) { super(props); this.handleChange = this.handleChange.bind(this); } handleChange(e) { this.props.onTemperatureChange(e.target.value); } render() { const temperature = this.props.temperature; const scale = this.props.scale; return ( <fieldset> <legend>Enter temperature in {scaleNames[scale]}:</legend> <input value={temperature} onChange={this.handleChange} /> </fieldset> ); } }
Calculator
의 변경사항을 확인하기 전에 TemperatureInput
컴포넌트에 대한 변경사항을 확인해보면, 우선 이 컴포넌트에서는 지역 state
를 제거했고, this.state.temperature
대신 this.props.temperature
를 읽어오도록 변경했습니다. 그리고 state
를 변경하고 싶은 경우 this.setState()
대신 Calculator
로부터 건네받은 this.props.onTemperatureChange()
를 호출하도록 변경했습니다.
이제 Calculator
컴포넌트에서는 temperature
와 scale
의 현재 입력값을 이 컴포넌트의 지역 state
에 저장하는데, 이것은 우리가 입력 필드들로부터 “끌어올린” state
데이터이고, 두 입력 필드를 렌더링하기 위해서 알아야 하는 모든 데이터를 최소한으로 표현한 것이기도 합니다.
{ temperature: '37', scale: 'c' }
예를 들어, 섭씨 입력 필드에 37
을 입력하면 Calculator
컴포넌트의 state
는 위와 같은 데이터를 담게 되는데, 이후에 화씨 입력 필드의 값을 212
로 수정하면 Calculator
의 state
는 다음과 같은 데이터로 수정이 됩니다.
{ temperature: '212', scale: 'f' }
물론 두 입력 필드에 모두 값을 저장할 수도 있지만, 이는 불필요한 작업으로 가장 최근에 변경된 입력값과 그 값이 나타내는 단위를 저장하는 것만으로도 충분한데, 이 데이터만으로도 현재의 temperature
와 scale
에 기반해 다른 입력 필드의 값을 추론할 수 있기 때문입니다.
class Calculator extends React.Component { constructor(props) { super(props); this.handleCelsiusChange = this.handleCelsiusChange.bind(this); this.handleFahrenheitChange = this.handleFahrenheitChange.bind(this); this.state = {temperature: '', scale: 'c'}; } handleCelsiusChange(temperature) { this.setState({scale: 'c', temperature}); } handleFahrenheitChange(temperature) { this.setState({scale: 'f', temperature}); } render() { const scale = this.state.scale; const temperature = this.state.temperature; const celsius = scale === 'f' ? tryConvert(temperature, toCelsius) : temperature; const fahrenheit = scale === 'c' ? tryConvert(temperature, toFahrenheit) : temperature; return ( <div> <TemperatureInput scale="c" temperature={celsius} onTemperatureChange={this.handleCelsiusChange} /> <TemperatureInput scale="f" temperature={fahrenheit} onTemperatureChange={this.handleFahrenheitChange} /> <BoilingVerdict celsius={parseFloat(celsius)} /> </div> ); } }
위 코드와 같이 수정된 컴포넌트에서는 두 입력 필드의 값이 동일한 state로부터 계산되기 때문에 두 입력 필드는 항상 동기화된 상태를 유지하게 되는데, 어떤 입력 필드를 수정해도 Calculator
의 this.state.temperature
와 this.state.scale
이 갱신되는 것을 확인할 수 있습니다.
입력 필드 중 하나는 있는 그대로의 값을 받기 때문에 사용자가 입력한 값이 보존되고, 다른 입력 필드의 값은 항상 다른 하나에 기반해 재계산됩니다.
입력 값 변경시 일어나는 일들
앞의 온도 계산기 예제에서 입력 값을 변경할 때 일어나는 일들을 정리하면 다음과 같은데, 애플리케이션은 입력 필드의 값을 변경할 때마다 동일한 절차를 거치고 두 입력 필드는 동기화된 상태로 유지됩니다.
- React는 DOM
<input>
의onChange
에 지정된 함수를 호출, 위 예시의 경우TemperatureInput
의handleChange
메서드에 해당함 TemperatureInput
컴포넌트의handleChange
메서드는 새로 입력된 값과 함께this.props.onTemperatureChange()
를 호출,onTemperatureChange
를 포함한 이 컴포넌트의props
는 부모 컴포넌트인Calculator
로부터 제공받음- 이전 렌더링 단계에서,
Calculator
는 섭씨TemperatureInput
의onTemperatureChange
를Calculator
의handleCelsiusChange
메서드로, 화씨TemperatureInput
의onTemperatureChange
를Calculator
의handleFahrenheitChange
메서드로 지정, 둘 중에 어떤 입력 필드를 수정하느냐에 따라Calculator
의 두 메서드 중 하나가 호출됨 - 이들 메서드는 내부적으로
Calculator
컴포넌트가 새 입력값과 현재 수정한 입력 필드의 입력 단위와 함께this.setState()
를 호출하게 함으로써 React에게 자신을 다시 렌더링하도록 요청 - React는 UI가 어떻게 보여야 하는지 알아내기 위해
Calculator
컴포넌트의render
메서드를 호출, 두 입력 필드의 값은 현재 온도와 활성화된 단위를 기반으로 재계산되며, 온도의 변환이 이 단계에서 수행됨 - React는
Calculator
가 전달한 새props
와 함께 각TemperatureInput
컴포넌트의render
메서드를 호출, 이와 함께 UI가 어떻게 보여야 할지를 파악함 - React는
BoilingVerdict
컴포넌트에게 섭씨온도를props
로 건네고, 그 컴포넌트의render
메서드를 호출함 - React DOM은 물의 끓는 여부와 올바른 입력값을 일치시키는 작업과 함께 DOM을 갱신, 값을 변경한 입력 필드는 현재 입력값을 그대로 받고, 다른 입력 필드는 변환된 온도 값으로 갱신됨
정리
React 애플리케이션 안에서 변경이 일어나는 데이터에 대해서는 하나의 state만을 두는 것이 좋은데, 보통 state는 렌더링에 그 값을 필요로 하는 컴포넌트에 먼저 추가되고, 다른 컴포넌트도 역시 그 값이 필요하게 되면 그 값을 그들의 가장 가까운 공통 조상으로 끌어올리면 됩니다.
일반적으로는 다른 컴포넌트 간에 존재하는 state
를 동기화시키려고 노력하는 대신 하향식 데이터 흐름에 기대는 것이 좋은데, state
를 끌어올리는 작업은 양방향 바인딩 접근 방식보다 더 많은 “보일러플레이트” 코드를 유발하게 되어 버그를 찾고 격리하기 더 쉽게 만든다는 장점이 있습니다.
또 어떤 state
라도 특정 컴포넌트 안에서 존재하기 마련인데, 그 컴포넌트는 자신의 state
를 스스로 변경할 수 있기 때문에 버그가 존재할 수 있는 범위가 크게 줄어들고, 사용자의 입력을 거부하거나 변형하는 자체 로직을 구현할 수도 있습니다.
만약 어떤 값이 props
또는 state
로부터 계산될 수 있다면, 그 값을 state
에 두는 것은 추천하지 않습니다. 예를 들어 celsiusValue
와 fahrenheitValue
를 둘 다 저장하는 대신, 최근에 변경된 temperature
와 scale
만 저장하면 되는데, 다른 입력 필드의 값은 항상 그 값들에 기반해 render()
메서드 안에서 계산될 수 있기 때문에, 이를 통해 사용자 입력값의 정밀도를 유지하면서 다른 필드 입력값의 반올림을 지우거나 적용할 수 있습니다.