Manage State in React Apps with MobX


MobX 공식 github에 소개된 튜토리얼 정리 포스팅 입니다.


MobX 깃헙에 소개된 튜토리얼이지만, asMap, useStrict 등 deprecated 된 함수가 등장해 일부 내용은 최근 API 기준으로 변경했습니다.


Contents

  • Sync the UI with the state
  • Computed values and side effects with reactions
  • Actions to change and guard state
  • Pass observable data through props
  • Handle user input and asynchronous actions
  • Connect observer components to the store
  • Custom reactions with when and autorun

Sync the UI with the state using MobX

애플리케이션은 State 중심으로 움직이며, UI의 여러 움직임은 항상 State를 일관되게 반영해야 합니다. MobX는 FRP 라이브러리이며, state로부터 값을 끌어내 UI에 동기화 할 수 있도록 해 줍니다. MobX를 사용한 접근 방법은 애플리케이션을 간단하게 만들 수 있고 번거로운 boilerplate 코드도 필요가 없습니다.

Mobx를 이용해 count를 하는 간단한 애플리케이션은 먼저 살펴보도록 하겠습니다. UI의 count가 state와 동기화되도록 observable과 observer 함수를 이용합니다.

import React, { Component, action } from 'react';
import { observable } from 'mobx';
import { observer } from 'mobx-react';

@observer
class Counter extends Component {
  @observable number = 0;

  @action handleDecrease = () => {
    this.number--;
  };

  @action handleIncrese = () => {
    this.number++;
  };

  render() {
    return (
      <div>
        Counter: {this.number} <br />
        <button onClick={this.handleDecrease}>-</button>
        <button onClick={this.handleIncrese}>+</button>
      </div>
    );
  }
}

export default Counter;

Computed values and side effects with reactions

Derivation은 MobX의 기반을 이룹니다. Derivation은 state로부터 자동적으로 파생되는 'computed value'와, UI에 정보를 보여주는 것과 같은 side effect를 다루는 'reaction'이 있습니다. 이러한 개념이 서로 어떻게 연관이 있고, MobX에서 어떠한 방식으로 최적화 되는지 알아보도록 하겠습니다.

아래의 예는 기온을 세 가지 단위로 환산해 주는 간단한 애플리케이션입니다. MobX의 주요한 원칙 중 하나는 가능한 적은 state를 지정하고 다른 필요한 값은 state로부터 파생해 얻는 것입니다.

아래의 예에서 state에 기온 단위로 Celsius만 생성하고 켈빈과 화씨는 별도로 만들지 않았습니다. 때문에, 온도가 변화했을 때 action이 간단해 지고 켈빈과 화씨를 업데이트해야 하는 번거로움을 줄일 수 있습니다.

MobX devTool이나 별도의 select 태그를 생성해 unit 값을 변경하면, 그에 따른 온도가 자동으로 연산 후 화면에 나타납니다.

import React from 'react';
import ReactDOM from 'react-dom';
import { observer } from 'mobx-react';
import { observable, computed } from 'mobx';

const t = new (class temperature {
  @observable unit = 'C';
  @observable temperatureCelsius = 25;

  @computed get temperatureKelvin() {
    return this.temperatureCelsius * (9 / 5) + 32;
  }

  @computed get temperatueFahrenheit() {
    return this.temperatureCelsius + 273.15;
  }

  @computed get temperature() {
    switch (this.unit) {
      case 'K':
        return this.temperatureKelvin + '◦K';
      case 'F':
        return this.temperatueFahrenheit + ' ◦F';
      case 'C':
        return this.temperatureCelsius + ' ◦C';
    }
  }
})();

const App = observer(({ temperature }) => {
  return <div>{temperature.temperature}</div>;
});

ReactDOM.render(<App temperature={t} />, document.getElementById('root'));

Computed value는 우리가 수정해야 할 state를 줄여주기 때문에 중요합니다. Computed에서 state를 변경하거나 network 요청을 보내는 등 side effect를 생성하지 않는 pure function이며, 이를 이용해 Mobx 내부적으로 최적화 돼 있습니다.

특정 요소가 Computed value에 영향을 미치면, 자동적으로 값을 다시 도출합니다. 하지만, 이전 연산 시의 데이터 중 어떠한 데이터도 변동이 없으면 다시 연산하지 않습니다. 또, UI등 애플리케이션에서 Computed value를 사용하지 않고 있어도 연산을 하지 않습니다.

extendObservable

extendObservable은 어떠한 기존의 target object에 observable 속성을 추가할 때 사용할 수 있습니다. property로 전달한 객체의 getter는 자동적으로 computed property로 변경됩니다. 위의 코드를 constructor 안에서 extendObservable을 사용해 아래와 같이 변경할 수도 있습니다.

  • extendObservable(target, properties, decorators?, options?)
const t = new (class Temperature {
  constructor() {
    extendObservable(this, {
      unit: 'C',
      temperatureCelsius: 25
    });
  }

  // ...
})();

Actions to change and guard state

action은 MobX의 네 번째 컨셉으로, state를 변경하는 코드를 말합니다. 위의 온도 계산 애플리케이션에 추가로 기온의 단위를 변경하는 아래의 method를 추가해 보도록 하겠습니다. 그리고, 이 method에 action decorator를 추가해 state를 변경하는 method임을 알 수 있도록 합니다.

@action setUnit(newUnit) {
  this.unit = newUnit;
}

@action setCelsius(degrees) {
  this.temperatureCelsius = degrees;
}

@action('update temperature and unit')
setTemperatureAndUnit(degrees, unit) {
  this.setCelsius(degrees);
  this.setUnit(unit);
}

튜토리얼에는 없는 코드이지만 select tag를 추가해 기온 단위에 따라 action이 실행되고, 이에따라 UI가 변경되도록 수정해 보았습니다. 참고로, configure API를 통해 아래와 같이 설정하면 action을 통해서만 state 수정이 가능합니다.

import { observable, action, computed, configure } from 'mobx';

// ... //

configure({ enforceActions: 'always' });

// ... 생략 ... //

const App = observer(({ temperature }) => (
  <div>
    {temperature.temperature}
    <select onChange={ev => temperature.setUnit(ev.target.value)}>
      <option value={'C'}>C</option>
      <option value={'K'}>K</option>
      <option value={'F'}>F</option>
    </select>
  </div>
));

Pass observable data through props

이번에는 아래와 같이 세 개의 기온 데이터를 생성하고, 이 데이터에 loop을 돌며 리스트를 생성했습니다. 리스트를 click하면 increment action이 실행되고 온도를 1도씩 증가합니다.

class Temperature {
  // 생략...

  @action increment() {
    this.setCelsius(this.temperatureCelsius + 1);
  }
}
const temps = observable([]);
temps.push(new Temperature(20, 'K'));
temps.push(new Temperature(25, 'F'));
temps.push(new Temperature(20, 'C'));

const App = observer(({ temperatures }) => (
  <div>
    {temperatures.map(t => (
      <li key={t.id} onClick={() => t.increment()}>
        {t.temperature}
      </li>
    ))}
  </div>
));

ReactDOM.render(<App temperatures={temps} />, document.getElementById('root'));

하지만, 위의 코드는 비효율적입니다. 리스트 중 한 개만 클릭해도 아래와 같이 전체가 다시 렌더됩니다. 이는 온도를 렌더하는 컴포넌트가 하나뿐이기 때문입니다. 분할정복 방식으로 위 문제를 해결할 수 있습니다.

우리는 개별 리스트를 렌더하기 위한 컴포넌트를 새로 생성할 수 있습니다. TemperatureItem이라는 컴포넌트를 생성했는데, observer decorator를 추가하지 않으면 클릭을 해도 UI에 업데이트된 데이터(온도)가 반영이 되지 않으니 유의할 필요가 있습니다.

@observer
class TemperatureItem extends Component {
  render() {
    const { temperature } = this.props;

    return <li onClick={() => temperature.increment()}>{temperature.temperature}</li>;
  }
}

const App = observer(({ temperatures }) => (
  <div>
    {temperatures.map(t => (
      <TemperatureItem key={t.id} temperature={t} />
    ))}
  </div>
));

이제 리스트를 클릭하면, 해당하는 리스트만 렌더가 다시되는 것을 확인할 수 있습니다.

추가로 이벤트 리스너를 별도의 action으로 구분해서 리팩토링 할 수도 있습니다.

@observer
class TemperatureItem extends Component {
  @action onTemperatureClick() {
    this.props.temperature.increment();
  }

  render() {
    const { temperature } = this.props;

    return <li onClick={this.onTemperatureClick}>{temperature.temperature}</li>;
  }
}

Handle user input and asynchronous actions

이제 현재까지 작성한 앱을 조금 더 확장해 보도록 하겠습니다. 유저가 위치를 입력하면, 외부 API를 이용해 날씨 정보를 받아오도록 하겠습니다. MobX를 이용해 network request의 현 상태를 UI와 동기화 할 수 있습니다.

우선 유저가 입력하는 location 정보를 반영할 수 있도록, Temperature class를 아래와 같이 수정합니다.

class Temperature {
  id = Math.random();
  @observable unit = 'C';
  @observable temperatureCelsius = 25;
  @observable location = 'Seoul';

  constructor(location) {
    this.location = location;
  }
}

그리고 유저가 지역 정보를 입력할 수 있도록 Input tag를 생성해 줍니다.

@observer
class TemperatureInput extends Component {
  @observable input = '';

  render() {
    return (
      <li>
        Destination
        <input onChange={this.onChange} value={this.input} />
        <button onClick={this.onSubmit}>Add</button>
      </li>
    );
  }
}

TemperatureInput 컴포넌트에서 사용하는 이벤트 핸들러는 아래와 같이 간단하게 작성할 수 있습니다. onChange는 단순히 유저의 입력값을 받아 input state에 할당합니다. onSubmit은 전달 받은 위치정보로 새로운 Temperature instance를 생성해 temperatures 컬렉션에 추가해 줍니다.

@observer
class TemperatureInput extends Component {
  // ...

  @action onChange = e => {
    this.input = e.target.value;
  };

  @action onSubmit = () => {
    this.props.temperature.push(new Temperature(this.input));

    this.input = '';
  };

  // ...
}

그리고 App Component에 위에서 생성한 TemperatureInput 컴포넌트를 추가해 줍니다.

const temps = observable([]);

const App = observer(({ temperatures }) => (
  <ul>
    <TemperatureInput temperatures={temperatures} />
    {temperatures.map(t => (
      <TemperatureView key={t.id} temperature={t} />
    ))}
  </ul>
));

마지막으로 지역 정보와 날씨를 UI에 보여줄 수 있도록 지역 정보를 추가해 줍니다.

@observer
class TemperatureView extends Component {
  @action onTemperatureClick() {
    this.props.temperature.increment();
  }

  render() {
    const { temperature } = this.props;

    return (
      <li onClick={this.onTemperatureClick}>
        {temperature.location} : {temperature.temperature}
      </li>
    );
  }
}

지금까지 작성한 코드를 확인해 보면, input에 지역명을 입력하면 아래와 같이 리스트를 생성할 수 있습니다.

하지만, 아직 지역명에 상관없이 기온이 일정합니다. 그러면 마지막으로, 외부 API를 이용해 날씨 정보를 받아온 후 UI에 반영할 수 있도록 하겠습니다. Network request의 현 status를 알기 위해 loading state를 추가해 줍니다. 그리고 별도의 fetchWeather action을 만들었습니다.

class Temperature {
  id = Math.random();
  @observable unit = 'C';
  @observable temparatureCelsius = 25;
  @observable location = 'Amsterdam, NL';
  @observable laoding = true;

  constructor(location) {
    this.location = location;
    this.fetchWeather();
  }

  @action fetchWeather() {
    axios(`http://api.openweathermap.org/data/2.5/weather?q=${this.location}&APPID=YOUR_API_KEY_HERE`).then(
      action(res => {
        this.temperatureCelsius = res.data.main.temp - 273.15;
        this.loading = false;
      })
    );
  }
}

날씨 정보를 아직 받기 전 loader를 보여주고, 응답을 받으면 기온을 보여줄 수 있도록 TemperatureView 컴포넌트를 아래와 같이 수정합니다.

@observer
class TemperatureView extends Component {
  @action onTemperatureClick() {
    this.props.temperature.increment();
  }

  render() {
    const { temperature } = this.props;

    return (
      <li onClick={this.onTemperatureClick}>
        {temperature.location} :{temperature.loading ? 'loading' : temperature.temperature}
      </li>
    );
  }
}

이제 Input에 지역명을 입력 후 리스트를 생성하면, 아래와 같이 날씨정보를 볼 수 있습니다.

Connect observer components to the store

여기까지 MobX의 비동기 action이 상태를 변경하면, 상태가 UI에 자동적으로 반영되는 것까지 확인했습니다.

앞서 작성한 바와 같이 observable state는 component 내부나, model class 또는 별도의 store에 있던 Mobx를 사용하는 컴포넌트는 상관을 하지 않습니다. 하지만, 상태 관리 툴을 사용하지 않고 프로그래밍을 하다 보면 깊이 nesting 된 컴포넌트에 prop을 전달하는 것이 점점 까다로워집니다. Redux와 마찬가지로 MobX도 store를 적용할 수 있습니다.

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { observer, Provider, inject } from 'mobx-react';
import { observable, action, computed } from 'mobx';
import axios from 'axios';

// ... 생략 ... //

// index.tsx
const temps = observable([]);

ReactDOM.render(
  <Provider temperatures={temps}>
    <App />
  </Provider>,
  document.getElementById('root')
);

그리고 inject 함수를 통해 스토어의 상태를 컴포넌트에 주입할 수 있습니다.

const App = observer(['temperatures'], ({ temperatures }) => (
  <div>
    <TemperatureInput />
    {temperatures.map(t => (
      <TemperatureView key={t.id} temperature={t} />
    ))}
  </div>
));

@inject('temperatures')
@observer
class TemperatureInput extends Component {
  @observable input = '';

  // ... 생략 ... //

  render() {
    return (
      <li>
        Destination
        <input onChange={this.onChange} value={this.input} />
        <button onClick={this.onSubmit}>Add</button>
      </li>
    );
  }
}

Custom reactions with when and autorun