들어가기 전에

리액트 공식문서로 배워보자 #10, 상태 끌어올리기

종종 몇몇 컴포넌트들은 같은 변화하는 데이터를 반영할 필요가 있습니다. 우리는 공유된 상태를 그들의 가장 가까운 공유하는 조상으로 끌어올리는 것을 권장합니다. 실제로는 어떻게 동작하는지 살펴봅시다.

이 섹션에서, 우리는 물이 주어진 온도에서 끓는지 계산하는 온도 계산기를 만들 것입니다.

우리는 먼저 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>
    );
  }
}

코드펜에서 해보기

두번째 입력 값 추가하기

우리의 새로운 요구사항은 섭씨 온도 입력 값에 추가로 화씨 온도 입력을 제공하고 계속 싱크를 유지하는 것입니다.

Calculator로부터 TemperatureInput 컴포넌트를 추출함으로써 시작할 수 있습니다. 우리는 여기에 새로운 scale prop을 추가할 것입니다. scale"e""f"가 될 수 있습니다.

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를 2개의 분리된 온도 입력 창으로 변경할 수 있습니다.

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

코드펜에서 직접 해보기

이제 우리는 2개의 입력 창을 가졌습니다. 하지만 둘 중 하나에 온도를 입력할 때, 나머지 것은 업데이트되지 않습니다. 이건 우리의 요구사항에 반대됩니다. 우리는 싱크를 맞추고 싶습니다.

또 우리는 Calculator로부터 BoilingVerdict를 보여줄 수 없습니다. Calculator는 현재의 온도를 알 수 없습니다. 왜냐면 현재의 온도는 TemperatureInput 내부에 숨어있기 때문입니다.

변환 함수 작성하기

먼저, 우리는 화씨에서 섭씨, 섭씨에서 화씨로 온도를 변경하는 2가지 함수를 작성할 것입니다.

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

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

이 두가지 함수는 숫자를 변환합니다. 우리는 문자열 temperature를 받고 변환 함수를 인자로 가져와 문자열을 반환하는 다른 함수를 만들 것입니다. 다른 입력 값을 기반으로 입력 값을 계산하는데 이것을 이용할 것입니다.

이 함수는 유효하지 않은 temperature에 대해서 빈 문자열을 리턴합니다. 그리고 이 함수는 출력 값을 3번째 소수점에서 반올림합니다.

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을 반환합니다.

상태 끌어올리기

현재, 두 개의 TemperatureInput 컴포넌트는 독립적으로 그들의 값을 지역 상태에 저장합니다.

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;
    // ...  

하지만, 우리는 이 두 개의 입력 값이 서로 싱크가 맞길 원합니다. 우리가 섭씨 입력 값을 업데이트 했을 때, 화씨 입력 값은 변환된 값을 반영해야 합니다. 그리고 반대도 같습니다.

리액트에서, 상태를 공유하는 것은 상태를 상태가 필요한 컴포넌트들의 가장 가까운 공통 조상으로 이동시킴으로써 공유할 수 있습니다. 이러한 테크닉은 "상태 끌어올리기(lifting state up)"라고 불립니다. 우리는 지역 상태를 TemperatureInput으로부터 제거할 것이고 이 상태를 대신 Calculator로 옮길 것입니다.

만일, Calculator가 공유된 상태를 소유한다면, Calculator는 두 입력값의 현재의 온도에 대한 "source of truth(유일한 원천)"이 됩니다. Calculator는 두 입력 값 모두에게 서로 일관성 있는 값을 갖게 할 수 있습니다. 두 TemperatureInput의 props가 같은 부모인 Calculator 컴포넌트에서 오기 때문에, 두개의 입력 값은 항상 싱크가 맞습니다.

이게 어떻게 작동하는지 단계단계 살펴봅시다.

먼저, 우리는 this.state.temperatureTemperatureInput컴포넌트 내부의 this.props.temperature를 교체할 것입니다. 나중에는 Calculator로부터 this.props.calculator를 넘겨야겠지만, 지금은 this.props.temperature가 이미 존재한다고 쳐봅시다.

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

우리는 props는 읽기 전용이란 것을 알고 있습니다. temperature가 지역 상태에 있을 때, TemperatureInput은 그냥 temperature를 변경하기 위해서 this.setState()를 호출할 수 있습니다. 하지만, 이제 temperature는 부모로부터 오는 prop이기 때문에, TemperatureInput는 더이상 통제 권한이 없습니다.

리액트에서, 이러한 문제는 컴포넌트를 통제된(controlled) 상태로 만듦으로써 해결 가능합니다. DOM <input>value 그리고 onChange prop을 받는 것처럼, custom TemperatureInput 역시 temperature 그리고 onTemperatureChange props 를 부모로부터 받을 수 있습니다.

이제, TemperatureInput이 온도를 업데이트하기 원할 때, 이 컴포넌트는 this.props.onTemperatureChange를 호출합니다.

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

알아둬야 할 것
커스텀 컴포넌트에서 temperature또는 onTemperatureChange prop 이름들이 특별한 의미를 갖진 않습니다. 아무런 다른 이름으로 부를 수도 있습니다. 일반적인 컨벤션과 같이 value 그리고 onChange처럼 불러도 됩니다.

onTemperatureChange prop은 Calculator 컴포넌트에 의해 temperature prop과 같이 전달될 것입니다. 이 prop은 지역 상태가 수정됨에 따라 일어나는 변화를 다룰(handle) 것이고 그럼으로써 두 입력값 모두를 새로운 값으로 재렌더링도 할 것입니다. 새로운 Calculator 구현을 곧 보게될 것입니다.

Calculator의 변화 속으로 다이빙하기 전에, TemperatureInput 컴포넌트의 변화들을 다시 살펴봅시다. 우리는 지역 상태를 없애버렸습니다. 그리고 this.state.temperature를 읽었는데 이젠 그 대신에 우린 this.props.temperature를 읽습니다. this.setState()를 호출하는 대신에, 우리가 변화를 원할 때, 이제 우린 this.props.onTemperatureChange()를 호출합니다. 이 함수는 Calculator에서 상속받은 것입니다.

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 컴포넌트로 가봅시다.

우리는 현재 입력된 temperature 그리고 scale을 지역 상태에 저장할 것입니다. 이건 우리가 입력값으로부터 "끌어올린" 상태입니다. 그리고 이 입력 값은 "source of truth"로 두 입력 값에 제공될 것입니다. 이건 이 두개의 입력창을 렌더링하기 위해서 우리가 알 필요가 있는 최소한의 설명입니다.

예를 들어, 우리가 37을 섭씨 입력 값에 넣으면, Calculator 컴포넌트는

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

만일 우리가 나중에 화씨(Fahrenheit) 필드를 212로 수정하면, Calculator는 다음과 같습니다.

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

우리는 두 입력 값의 값을 저장할 수 있지만 꼭 그렇게 할 필요가 없다는 것을 압니다. 최근에 변경된 입력 값과 그 값의 단위를 저장하는 것만으로 충분합니다. 우린 그럼 현재 입력된 1개의 temperaturescale을 기반으로 다른 입력 값을 추론할 수 있습니다.

입력 값은 동기화된 상태를 유지합니다. 왜냐하면 값들이 같은 상태로부터 계산되었기 때문입니다.

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

코드펜에서 직접 해보기

이제 여러분이 무슨 입력 값을 수정하든지에 상관 없이, Calculatorthis.state.temperaturethis.state.scale는 업데이트 됩니다. 둘 중 하나는 입력 값을 그대로 얻으므로 어떤 사용자 입력 값이 보존되고, 다른 입력 값은 이미 입력된 값을 기반으로 재계산됩니다.

여러분이 입력 값을 수정할때, 어떤 일이 일어나는지 다시 한번 되짚어봅시다.

  • 리액트는 DOM <input>에서 onChanged로 명시된 함수를 호출합니다. 우리의 경우엔, TemperatureInput 컴포넌트 내부에 있는 handleChange 메소드입니다.
  • TemperatureInput 컴포넌트 내부에 있는 handleChange 메소드는 this.props.onTemperatureChange() 메소드를 새롭게 입력한 값을 이용하여 호출합니다. 이 onTmperatureChange를 포함하는 props는 이 부모 컴포넌트 Calculator로부터 제공받습니다.
  • 이전에 렌더링할 때, Calculator는 섭씨 TemperatureInputonTemperatureChangeCalculatorhandleCelsiusChange 메소드이고 화씨 TemperatureInputonTemperatureChangeCalculatorhandleFahrenheitChange 메소드라고 명시했습니다. 어떤 입력 값이 수정되냐에 따라 두 개의 Calculator 메소드 중 어떤 것이 호출될지 결정합니다.
  • 이 메소드들 안에서, Calculator 컴포넌트는 새로운 입력 숫자값 그리고 우리가 수정한 현재의 scale 값과 함께 this.setState() 메소드를 호출함으로써 리액트에게 재렌더링하라는 명령을 보냅니다.
  • 리액트는 UI가 어떻게 보여야할지 학습하기 위해서 Calculator 컴포넌트의 render 메소드를 호출합니다. 두 입력 값은 현재 온도와 활성화된 scale을 기반으로 다시 계산됩니다. 온도 변환은 여기서 이뤄집니다.
  • 리액트는 Calculator에 의해 명시된 새로운 props값과 함께 각 TemperatureInput 컴포넌트의 render 메소드를 호출합니다. 리액트는 UI가 어떻게 생겨야 하는지 학습합니다.
  • 리액트는 BoilingVerdict 컴포넌트의 render 메소드를 호출하고, 섭씨 온도를 props로 받습니다.
  • 리액트 DOM은 원하는 입력 값에 맞추기 위해 boiling verdict와 함께 DOM을 업데이트합니다. 우리가 수정한 입력 창은 현재 값을 받고, 다른 입력 창에는 온도 값을 변환 후에 변환된 온도가 업데이트됩니다.

모든 업데이트는 같은 과정을 거치고 그래서 모든 입력 값은 계속 싱크를 유지합니다.

우리가 배운 것들

리액트 어플리케이션에서 변화하는 데이터에 대해 단 하나의 "source of truth"만 있는 것이 바람직합니다. 주로, 상태는 처음 렌더링을 위해 상태가 필요한 컴포넌트에 추가됩니다. 그 후에, 만일 다른 컴포넌트들도 상태가 필요하다면, 여러분은 상태를 그들의 가장 가까운 공유하는 조상으로 끌어올릴 수 있습니다. 다른 컴포넌트들 사이의 상태의 싱크를 맞추는 것 대신에, 여러분은 top-down data flow에 의존하는 것이 바람직합니다.

상태를 끌어올리는 것은 two-way 바인딩 접근법보다 더 많은 "보일러 플레이트(boilerplate)" 코드를 작성하는 것을 포함합니다. 하지만 이점으로서, 버그를 찾고 격리시키기 위해 더 적은 노동력을 요구로 합니다. 어떤 상태가 어떤 컴포넌트 안에 살고 있고 그 컴포넌트가 혼자 상태를 변환할 수 있기 때문에, 버그에 대한 표면적인 공간은 매우 줄어듭니다. 추가적으로, 여러분은 유저의 입력 값을 거부하거나 변환하기 위한 커스텀 로직을 구현할 수 있습니다.

만일 어떤 것을 props 또는 state에서 끌어올 수 있다면, 그것은 상태에 있으면 안됩니다. 예를 들어, celsiusValue 그리고 fahrenheitValue 두 개를 저장하는 것 대신에, 우리는 마지막으로 수정된 temperature와 그 scale을 저장합니다. 다른 입력란의 값은 render 메소드 안에서 temperaturescale을 이용해 계산될 수 있습니다. 이렇게 구현하면 유저 입력값에서 어떠한 정밀함의 손실도 없이 다른 필드에 반올림을 수행할 수 있습니다.

UI에서 잘못된 점을 보았을 때, 여러분은 상태를 업데이트하는 것에 대한 책임을 갖는 컴포넌트를 찾을 때까지, props를 검사하고 tree를 위로 이동시키기 위해 [React Developer Tools]을 사용할 수 있습니다. 이 툴은 소스의 버그를 추적하는것을 가능하게 해줄 것입니다.

reactstate.gif