IT

[영어로 배우는 React] 10. state 위로 올리기(부모쪽으로)

생각파워 2022. 4. 22. 14:48

공식 사이트 : https://reactjs.org/docs/lifting-state-up.html

 

 

종종 몇몇 컴포넌트들은 같은 데이터 변화를 반영할 필요가 있습니다. 이때는 state를 가장 가까운 공통 조상으로 올려서 공유하는 것을 추천합니다. 그럼 어떻게 작업하는지 알아보겠습니다. 

 

이번 섹션에서는 주어진 온도에서 물이 끓는지 알아보는 온도 계산기를 만들어볼 겁니다. 섭씨온도를 프로퍼티로 받고, 그 온도에서 물이 끓을 수 있는지 출력해주는, BoilingVerdict라는 컴포넌트에서부터 시작해보겠습니다. 

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를 랜더링 합니다. 

function BoilingVerdict(props) {
  if (props.celsius >= 100) {
    return <p>The water would boil.</p>;  
  }
  return <p>The water would not boil.</p>;
}

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>
    );
  }
}

ReactDOM.render(
  <Calculator />,
  document.getElementById('root')
);

전체 코드는 위와 같습니다. 그리고, 아래와 같이 실행됩니다.

키를 누를 때마다 handleChange(e) 메서드가 호출되면서, 랜더링이 일어나기 때문에, '제출' 같은 이벤트를 일으키는 버튼은 필요 없습니다. 10을 타이핑하면 1에 한번, 0에 또 한 번 2번 실행되고, 200을 타이핑하면, 2에 한번, 0에 한번, 마지막 0에 한번 해서 총 3번이 실행됩니다. 

 

 

두 번째 입력 추가하기

새로운 요구사항은 섭씨 입력 부분에 화씨 입력이 가능하도록 하는 것입니다. 그리고 그 둘을 동기화된 상태로 유지하는 것입니다. 먼저 Calculator 컴포넌트에서 TemperatureInput 컴포넌트를 추출해 내고, 거기에 'c'나 'f'값을 가질 수 있는, scale 프로퍼티를 추가하겠습니다.

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>
    );
  }
}

이후 구분된 온도 입력을 랜더링 하기 위해 Calculator 컴포넌트를 변경하겠습니다.

class Calculator extends React.Component {
  render() {
    return (
      <div>
        <TemperatureInput scale="c" /> 
        <TemperatureInput scale="f" />
      </div>
    );
  }
}

각각의 온도를 입력할 수 있는 input 필드가 아래와 같이 생성됩니다.

지금 두 개의 input필드를 가지고 있지만, 한 곳에 입력한다고 다른 곳의 내용이 변경되진 않습니다. 이것은 두 값이 동기화가 이뤄져야 한다는 우리 요구사항과 모순됩니다. 또, Calculator로부터 BoilingVerdict를 출력되게 할 수도 없습니다. 현재 온도가 TemperatureInput 내부로 숨겨졌기 때문에 Calculator가 알 수 없기 때문입니다.  

 

 

변경 함수 작성하기

이제 두 함수를 작성할 것입니다. 하나는 화씨를 섭씨로 바꾸고, 다른 하나는 섭씨를 화씨로 변경합니다. 

function toCelsius(fahrenheit) {
  return (fahrenheit - 32) * 5 / 9;
}

function toFahrenheit(celsius) {
  return (celsius * 9 / 5) + 32;
}

이 두 함수는 숫자를 변경합니다. 우리는 다른 함수를 하나 더 만들 겁니다. 문자 temperature와 변경 함수를 인자로 받아들여 문자열을 리턴하는 함수입니다. 이 함수는 하나의 input값을 다른 input값을 기초로 계산하기 위해 사용합니다. 또, 잘못된 온도가 오면 빈 문자열을 리턴하고, 세 번째 숫자 공간에 반올림된 출력 값을 유지하게 됩니다. 

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('abc', toCelsius)는 빈문자를 리턴하고, tryConvert(10.22', toFahrenheit)로 호출하면, '50.396' 문자가 리턴됩니다.

 

 

state 부모 쪽으로 올리기

현재 양쪽 TemperatureInput 컴포넌트는 독립적으로 그들의 값을 로컬 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;    // ...

그러나 우리는 두 input값이 동기화되길 원합니다. 우리가 섭씨 입력값을 업데이트했을 때,  화씨 입력값도 반영되길 원하죠. 반대의 경우도 마찬가지고요. 

리액트에서 state 공유는 컴포넌트의 공통조상으로 옮기는 방법으로 달성할 수 있습니다. 이것을 'state 위로 올리기'라고 부릅니다. 우리는 TemperatureInput 컴포넌트에서 로컬 state를 삭제하고, 그것을 Calculator 안으로 옮길 겁니다. Calculator가 공유된 state를 소유하게 되면, 데이터를 한곳에서만 관리하는 "진실의 소스"가 될 수 있습니다. 이것은 자식 컴포넌트들에게 같은 값을 가지도록 강제할 수 있습니다.

 

이제 이 작업이 어떻게 이뤄지는지 차근차근 살펴보겠습니다. 

먼저 this.state.temperature를 this.props.temperature로 변경할 겁니다. 이제 this.props.temperature가 이미 존재합니다. 이후 Calulator 컴포넌트에서 값을 전달해주는 부분이 필요하긴 하겠죠.

  render() {
    // Before: const temperature = this.state.temperature;
    const temperature = this.props.temperature;    // ...

우리는 props가 읽기 전용이라는 것을 압니다. temperature가 로컬 state 였을 때, TemperatureInput은 오직 this.setState()를 호출해서만 temperature값을 변경할 수 있었습니다. 하지만 지금은 temperature 가 부모로부터 오고, TemperatureInput 컴포넌트는 그 값을 변경할 수 없습니다. 리액트에서는 이 문제를 보통 컨트롤된 컴포넌트로 풉니다. 

DOM <input>과 같은 것은 value와 onChange 프로퍼티를 함께 받아들입니다. 그래서 TemperatureInput은 temperature와 onTemperatureChange를 부모인 Calculator 컴포넌트로부터 props로 받을 수 있습니다. 그래서 TemperatureInput 컴포넌트가 온도를 업데이트하길 원하면, this.props.onTemperatureChange 메서드를 호출하면 됩니다. 

handleChange(e) {
    // Before: this.setState({temperature: e.target.value});
    this.props.onTemperatureChange(e.target.value);    // ...

 

주의
프로퍼티 이름을 temperature나 onTemperatureChange로 짓는 것은 특별한 의미가 있는 것이 아닙니다. 관행적으로 value, onChange와 같이 이름 짓는 것처럼 어떤 이름이든 지을 수 있습니다. 

onTemperatureChange에게는 부모(Calculator)에게 받은 temperature가 제공되어야 합니다. 이것은 TemperatureInput의 로컬 state를 변경해서 변경내용을 처리할 수 있습니다. state를 변경하면 다시 랜더링 되기 때문에, 새로운 값으로 서로의 input 필드를 변경할 수 있습니다. 우리는 곧 새로 만든 Calculator에서 살펴볼 것입니다. 

 

변경된 Calculator를 살펴보기 전에, TemperatureInput컴포넌트의 변경사항들을 살펴보겠습니다. 우리는 로컬 state를 제거했고, this.state.temperature를 읽는 대신에 현재는 this.props.temperature를 읽습니다. 또, Calculator에서 제공되는 temperature를 변경하기 위해 this.setState()를 호출하는 대신, this.props.onTemperatureChange()를 호출합니다. 

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 컴포넌트를 살펴보겠습니다. 

우리는 현재 input의 temperature와 scale을 state에 저장할 겁니다. 이 상태는 우리가 input으로부터 들어 올려진 상태입니다. 이것은 한곳에서 데이터를 관리하는 "진실의 소스"가 되어서 각각의 input에 대해 정확한 값을 전달해 줄 것입니다. 이것은 두 개의 입력 모두를 랜더링 하기 위한 최소한의 표현방식입니다. 

예를 들면 우리가 Celsius 입력란에 37을 입력했다면 Calculator 컴포넌트의 state는 아래와 같이 표현될 것입니다. 

{
  temperature: '37',
  scale: 'c'
}

이후 Fahrenheit 입력란에 212를 입력했다면 Calculator 컴포넌트의 state 값은 아래와 같이 변경될 것입니다. 

{
  temperature: '212',
  scale: 'f'
}

우리는 각각의 input에 대한 값들을 저장할 수 있었지만, 불필요한 일이었습니다. 하나의 input값을 다른 input에 적용시켜야 되는 상황이니까요. 이런 상황에서는 하나의 최신 값만 저장하면 되고, 그 값을 다른 값으로 변경하면 표현해 주면 되는 거였습니다. input의 값은 최신의 값 하나로 계산되기 때문에 동기화됩니다. 

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>
    );
  }
}

지금은 어느 input을 변경하든지 문제없이, this.state.temperature와 this.state.scale은 Calculator에서 수정됩니다. 하나의 값을 그대로 얻으면, 어떤 유저의 입력이든 보존되고, 다른 input 값은 계산되어 표기됩니다. 

 

우리가 input을 변경했을 때 어떤 일이 일어나는지 다시 한번 살펴보겠습니다. 

  • 리액트는 DOM <input>의  onChange를 구체화시킨 함수를 호출합니다. 우리 케이스에서 이것은 TemperatureInput 컴포넌트의 handleChange 메서드입니다. 
  • TemperatureInput 컴포넌트의 handleChange 메서드는 새로운 값이 입력될 때 this.props.onTemperatureChange()를 호출합니다. onTemperatureChange를 포함한 props는 부모 컴포넌트인 Calculator 컴포넌트에 제공됩니다.
  • 이전에 랜더링 됐을 때, Celsius TemperatureInput의 onTemperatureChange는 Calculator의 handleCelsiusChange 메서드로 구체화되었고, Fahrenheit TemperatureInput의 onTemperatureChange는 Calculator의 handleFahrenheitChange 메서드로 구체화되었습니다. 그래서 이 Calculator의 두 메서드는 우리가 수정한 input값을 가져옵니다. 
  • 이들 메서드 내에서, input 값이 변하면 this.setState()가 호출되면서 React가 Calculator 컴포넌트를 다시 랜더링 하게 됩니다. 결국 전체 컴포넌트가 랜더링 되면서 결괏값이 반영됩니다.
  • 리액트는 UI를 어떻게 보여야 하는지 알기 위해서 Calculator 컴포넌트의 render() 메서드를 호출합니다. 각각의 input값은 현재 온도와 스케일을 기초로 재계산됩니다. 온도 변환은 여기에서 일어납니다. 
  • 리액트는 각각의 TemperatureInput 컴포넌트를 Calculator에서 가져온 새로운 props로 render() 메서드를 호출합니다. 이것은 UI가 어떻게 보여야 하는지를 알려줍니다. 
  • 리액트는 BoilingVerdict 컴포넌트의 render() 메서드를 호출합니다. 그 props에 섭씨온도를 전달합니다. 
  • 리액트 Dom은 input 값을 적용하기 위해 Boiling verdict로 DOM을 업데이트합니다. 우리가 수정한 input은 현재 값을 받고, 다른 input은 변환시킨 값을 업데이트합니다. 

모든 업데이트는 같은 절차를 통해 유지되고 동기화됩니다. 

 

교훈

리액트 애플리케이션에서 데이터 변경은 "단일 진실 공급원"이 되어야 합니다. 보통 state는 그것을 필요로 하는 컴포넌트에 최초로 추가됩니다. 그래서 다른 컴포넌트들이 state정보가 필요하게 되면, 관계된 컴포넌트들의 가장 가까운 공통 조상 컴포넌트로 값을 올리게 됩니다.  다른 컴포넌트 사이에 state값을 동기화하는 대신에, top-down 데이터 흐름(리액트에서는 위에서 아래로만 데이터가 이동)을 믿을 수 있습니다. 

 

state 위로 올리기는 두가지 바인딩 접근법보다 "boilerplate"가 더 많이 코딩하지만, 버그를 찾고 격리하는데 필요한 작업이 작습니다. 어떤 state가 몇몇 컴포넌트에 살아있고, 한 컴포넌트만 그것을 변경할 수 있기 때문에, 버그 발생 위치는 획기적으로 줄어들것입니다. 덧붙여, 유저 input을 거절하거나 변경하기 위한 커스텀 로직을 구현할수도 있습니다. 

 

어떤것이 props나 state에서 끌어내질 수 있다면, 그것은 state에 있어서는 안될것입니다. 예를 들면, celsiusValue와 fahrenheitValue를 각각 저장하는 대신에, 우리는 단지 마지막에 수정된 temperature와 scale을 저장했습니다. 다른 input값은 항상 다른 input값으로부터 계산되어질 수 있습니다. 이를 통해 사용자의 입력에 대한 정밀도를 잃지 않고, 반올림을 지우거나 적용할 수 있습니다. 

 

만약 UI에서 잘못된것을 보았을 때, React Developer Tools를 사용해서 props를 검사하고, state 업데이트에 대해 신뢰할 만한 컴포넌트를 찾을 때까지 tree를 이동할 수 있습니다. 아래가 소스에서 버그를 추적한 내용입니다. 

 

 

 

 

 

반응형