MobX with TypeScript

2woongjae@gmail.com

Mark Lee (이웅재)

  • Studio XID inc. ProtoPie Engineer
  • Seoul.JS 오거나이저
  • 타입스크립트 한국 유저 그룹 오거나이저
  • 일렉트론 한국 유저 그룹 운영진
  • Seoul.js 오거나이저
  • Microsoft MVP - Visual Studio and Development Technologies
  • Code Busking with Jimmy
    • https://www.youtube.com/channel/UCrKE8ihOKHxYHBzI0Ys-Oow

MobX

Redux  32282   VS   9893  MobX

2017.07

Redux  37557   VS   12957  MobX

2018.01

MobX 주요 특징

  • 데코레이터를 적극 활용한다.
    • experimentalDecoratorstrue 로 설정.
    • 스토어 객체에 붙이는 데코레이터가 있고, => @observable
    • 컴포넌트에서 사용하는 데코레이터가 있다. => @observer
  • TypeScript 가 Base 인 라이브러리이다.
    • 당연히 @types/mobx, @types/mobx-react 는 필요가 없음.
  • Redux 와 마찬가지로, 스토어에 필요한 부분과 리액트에 필요한 부분이 있다.
    • npm i mobx -D
    • npm i mobx-react -D
  • 리덕스와 다르게 단일 스토어를 강제하진 않는다.​

MobX

후다닥 만들어 보기 단계 - 나이 먹는 앱

  • step.1 state 객체를 정한다. => 리액트의 state 가 아니다.
    • 그냥 객체거나, 프리미티브 값일수도 있다.
  • step.2 정한 state 객체에 @observable 데코레이터를 붙인다.
  • step.3 state 값을 다루는 함수들을 만든다.
    • get / set 함수
    • 혹은 액션과 같은 역할을 하는 함수
    • 이뮤터블이 아니다 ! => 뮤테이트 하는것이다 !
  • step.4 state 값에 변경되면 반응하는 컴포넌트를 만든다.
    • 그리고 그 컴포넌트에 @observer 데코레이터를 붙인다.
  • step.5 컴포넌트에서 state 값을 사용한다.
  • step.6 컴포넌트에서 state 값을 변경하는 함수를 사용한다.

step 1 ~ 3

import {observable} from 'mobx';

export class AgeState {
    @observable private _age = 35;
    constructor(age: number) {
        this._age = age;
    }
    public getAge(): number {
        return this._age;
    }
    public setAge(age: number): void {
        this._age = age;
    }
    public addAge(): void {
        this._age = this._age + 1;
    }
}

step 4 ~ 6

import * as React from 'react';
import './App.css';
import {AgeState} from './AgeState';
import {observer} from 'mobx-react';

const logo = require('./logo.svg');

const ageState = new AgeState(35);

@observer class App extends React.Component<{}, {}> {
  constructor(props: {}) {
    super(props);

    this.addAge = this.addAge.bind(this);
  }
  render() {
    return (
      <div className="App">
        <div className="App-header">
          <img src={logo} className="App-logo" alt="logo" />
          <h2>Welcome to React</h2>
        </div>
        <p className="App-intro">
          {ageState.getAge()}
          <button onClick={() => this.addAge()}>한해가 지났다.</button>
        </p>
      </div>
    );
  }
  addAge() {
    const age = ageState.getAge();
    ageState.setAge(age + 1);
    // ageState.addAge();
  }
}

export default App;

https://github.com/2woongjae/mobx-ts-basic

  • mobx 에서 말하는 state 는 리액트 컴포넌트의 state 와 다른 것
  • (리덕스보다) 쉽지 않습니까 ?
    • 리액티브 프로그래밍 > 함수형 프로그래밍
    • 간편한 비동기 처리 => 넘나 쉬워요
  • 데코레이터의 적극적인 사용으로 인해, 깔끔한 구성
  • 하지만,
    • 단일 스토어가 아니다.
      • 스토어를 어떻게 사용지에 대한 적합한 해결을 모색해야 할 것
      • 결국 최상위 스토어를 만들고, props 로 공유해가는 방식으로
      • https://github.com/gothinkster/react-mobx-realworld-example-app
    • 라이프사이클에 대한 고민

Quick Start - MobX

프로젝트 준비

~/Project/workshop-201801 
➜ npx create-react-app mobx-ts-quick-start --scripts-version=react-scripts-ts

~/Project/workshop-201801 took 1m 47s 
➜ cd mobx-ts-quick-start/                                      

Project/workshop-201801/mobx-ts-quick-start is 📦 v0.1.0 via ⬢ v8.9.4 
➜ npm i mobx mobx-react -D                                     
+ mobx-react@4.3.5
+ mobx@3.4.1
added 3 packages in 12.528s

Project/workshop-201801/mobx-ts-quick-start is 📦 v0.1.0 via ⬢ v8.9.4 took 13s 
➜ npm install autobind-decorator -D                                          
+ autobind-decorator@2.1.0
added 1 package in 9.727s

tsconfig.json 수정

{
  "compilerOptions": {
    "outDir": "build/dist",
    "module": "esnext",
    "target": "es5",
    "lib": ["es6", "dom"],
    "sourceMap": true,
    "allowJs": true,
    "jsx": "react",
    "moduleResolution": "node",
    "rootDir": "src",
    "forceConsistentCasingInFileNames": true,
    "noImplicitReturns": true,
    "noImplicitThis": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "suppressImplicitAnyIndexErrors": true,
    "noUnusedLocals": true,
    "experimentalDecorators": true
  },
  "exclude": [
    "node_modules",
    "build",
    "scripts",
    "acceptance-tests",
    "webpack",
    "jest",
    "src/setupTests.ts"
  ]
}

index.tsx

import * as React from 'react';
import * as ReactDOM from 'react-dom';
import App from './App';
import registerServiceWorker from './registerServiceWorker';
import './index.css';
import { observable, action, useStrict } from 'mobx';
import { Provider } from 'mobx-react';

useStrict(true);

export class Store {
  @observable test: number[] = [];
  @action
  add(): void {
    this.test.push(Math.round(Math.random() * 100));
  }
}

const store = new Store();

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root') as HTMLElement
);
registerServiceWorker();

App.tsx

import * as React from 'react';
import autobind from 'autobind-decorator';
import { observer, inject } from 'mobx-react';
import './App.css';
import { Store } from './index';

interface AppProps {
  store?: Store;
}

@inject('store')
@observer
class App extends React.Component<AppProps, {}> {
  render() {
    const store = this.props.store as Store;
    return (
      <div className="App">
        <p>{JSON.stringify(store.test)}</p>
        <button onClick={this.click}>추가</button>
      </div>
    );
  }
  @autobind
  click() {
    const store = this.props.store as Store;
    store.add();
  }
}

export default App;

@observable (by mobx)

observable 사용법 - 2가지 방식

  • observable(<value>)
    • 데코레이터 없이 사용하는 방식
    • @ 없이, 함수처럼 사용해서 리턴한 객체를 사용
  • @observable <클래스의 프로퍼티> => TypeScript
    • 데코레이터로 사용하는 법
    • 클래스 내부에 프로퍼티 앞에 붙여서 사용
    • 한 클래스 안에 여러개의 @observable 존재

observable 사용법 - 2가지 방식

import { observable } from 'mobx';

// array 에 사용
const list = observable([1, 2, 4]);

// boolean 에 사용
const isLogin = observable(true);

// literal 객체에 사용
const age = observable({
    age: 35
});

// 클래스의 멤버 변수에 데코레이터로 사용
class AgeStore {
    @observable
    private _age = 35;
}

const ageStore = new AgeStore();

@observer (by mobx-react)

observer 사용법 - 2가지 방식

  • observer(<컴포넌트>);
    • 데코레이터 없이 사용하는 방식
    • SFC 에 붙여서 사용
    • 어차피 리액트의 state 를 사용하지 않으면 SFC 로 해도 관계가 없다.
  • <컴포넌트 클래스> 에 @observer 달아서 처리
    • 리액트의 라이프사이클이 아니라, mobx 의 라이프 사이클
      • componentWillReact
      • componentWillUpdate
      • componentDidUpdate

observer 사용법 - 2가지 방식

const StatelessApp = observer(() => {
  function addAge(): void {
    ageState.addAge();
  }
  return (
    <div>
      {ageState.getAge()}
      <button onClick={() => addAge()}>한해가 지났다.</button>
    </div>
  );
});

@observer
class App extends React.Component<{}, {}> {
  constructor(props: {}) {
    super(props);
    this.addAge = this.addAge.bind(this);
  }
  render() {
    return (
      <div>
        {ageState.getAge()}
        <button onClick={() => this.addAge()}>한해가 지났다.</button>
      </div>
    );
  }
  addAge() {
    ageState.addAge();
  }
}

@computed  (by mobx)

computed 란 ?

  • getter 에만 붙일수 있다. (setter 부르면 getter 도 실행된다.)
  • 함수가 아니라 리액티브 하다는 것에 주목
  • 실제 컴포넌트에서 사용하는 (게터)값들에 달아서 사용하면 최소 범위로 변경할 수 있기 때문에 유용하다.
    • 40살이 넘었을때만 나이를 올리면 40살 이하일때는 재랜더링 대상이 아닌 것과 같은 경우
    • 내부적으로 고도의 최적화 => 어떻게 ?
      • 매번 재계산을 하지 않는다
      • 계산에 사용할 observable 값이 변경되지 않으면 재실행하지 않음.
      • 다른 computed 또는 reaction 에 의해 호출되지 않으면 재실행하지 않음.
      • observable 과 처리 방식의 차이로 인한 성능 이슈에 주목
        • observable 이 변했는데 computed 가 변하지 않을때 사용에 따른 차이

computed 사용법 - 2가지 방식

  • computed(<함수>);
    • 데코레이터 없이 사용하는 방식
    • 별 의미가 없다.
  • <클래스의 getter 메서드> 에 @computed 달아서 처리

computed 사용법 - 2가지 방식

class AgeState {
    constructor() {
        extendObservable(this, {
            _age: 35,
            age: computed(function() {
                return (this._age > 40) ? this._age : 0;
            })
        })
    }
}

class AgeState {
    @observable private _age: number = 35;
    @computed
    get age(): number {
        return (this._age > 40) ? this._age : 0;
    }
}

@action  (by mobx)

action - MobX 2.2

  • <action> wraps your original function automatically in a transaction. This means that the out-of-the-box performance of MobX will be even better.
  • <actions> integrate very well with the mobx-react-devtools. This means that you can trace any mutation back to the causing action with the devtools or debugger.
  • <actions> are always untracked. Which means that you cannot turn them into reactions accidentally by calling them from a reaction. While most people won’t run into this issue,
    • action makes the distinction between actions (something that modifies the state) and reactions (the state needs to trigger a side effect automatically) very clear.
  • there is strict mode. <Actions> are an opt-in concept. You may use an action to clearly express the intent of your code, getting transactions for free; but you are in no way obliged to do so. All existing code will continue working as is. Unless strict mode is enabled using ‘useStrict(true)’.
    • In strict mode, MobX will throw an exception on any attempt to modify state outside an action.

action 이란 ?

  • observable 을 수정하는 함수
    • 어디에 observable 처리된 객체를 수정하는 함수가 있는지 마킹
    • untracked, transaction 및 allowStateChanges로 래핑 한 후 리턴
  • 평소엔 옵셔널
    • useStrict 모드를 쓰면 필수
    • useStrict 모드에서 observable 을 변경하는 함수가, action 을 마킹하지 않으면 런타임 에러
  • computed 의 setter 는 액션이다.

useStrict(true);

action 사용법

ref.child('todos').on('value', action((snapshot: firebase.database.DataSnapshot) => {
  if (snapshot) {
    const list = snapshot.val();
    const todos = [];
    if (list !== null) {
      for (const key of Object.keys(list)) {
        todos.push({
          id: key,
          text: list[key]
        });
      }
    }
    this.todos = todos;
  }
}));

@action addTodo = (text: string) => {
  const ref = db.ref();
  ref.child('todos').push().set(text);
}

@inject 와 Provider

Provider

  • 네, 그 프로바이더가 맞습니다.
    • 네, 그래서 컨테이너라는 개념을 사용해도 좋습니다.
  • 프로바이더에 props 로 넣고, @inject 로 꺼내 쓴다고 생각하시면 됩니다.
    • 상당히 명시적이고, 편합니다.
    • 컨테이너를 쓰지 않아도 될것 같습니다.
      • props 로 바꿔줍니다.
      • this.props.store as IAgeState; => 중요한 부분

사용법

ReactDOM.render(
  <Provider store={ageState}>
    <App />
  </Provider>,
  document.getElementById('root') as HTMLElement
);

@inject('store')
@observer
class App extends React.Component<{ store?: IAgeState; }, {}> {
  render() {
    const store = this.props.store as IAgeState;
    return (
      <div className="App">
        <DevTools />
        <p className="App-intro">
          {store.age}
          <button onClick={() => store.addAge()}>한해가 지났다.</button>
          <button onClick={() => store.addAgeAsync()}>깃헙 비동기 호출</button>
        </p>
      </div>
    );
  }
}

autorun

autorun vs computed

  • 둘다 리액티브
    • 무언가 변경이 있을때 즉시 실행
  • autorun
    • 일단 한번 실행되고, 종속된 변수들이 변경되었을때 실행
    • 주로 로깅, UI 업데이트 같은 리액티브를 명령형으로 전환할때 사용
  • computed
    • observable 변수가 변경되었을때 실행

mobx-react-devtools

mobx-react-devtools

  • 이것은 컴포넌트입니다.
  • npm i mobx-react-devtools -D
  • import DevTools from 'mobx-react-devtools';
  • <DevTools />
  • https://github.com/mobxjs/mobx-react-devtools

mobx-react-devtools

MobX Developer Tools

간단 todoApp

프로젝트 준비

~/Project/workshop-201801 
➜ npx create-react-app mobx-ts-todo --scripts-version=react-scripts-ts

~/Project/workshop-201801 took 1m 47s 
➜ cd mobx-ts-todo/                                      

Project/workshop-201801/mobx-ts-todo is 📦 v0.1.0 via ⬢ v8.9.4 
➜ npm i mobx mobx-react -D
+ mobx@3.4.1
+ mobx-react@4.3.5
added 3 packages in 13.521s

Project/workshop-201801/mobx-ts-todo is 📦 v0.1.0 via ⬢ v8.9.4 
➜ npm i firebase -D

> grpc@1.8.4 install /Users/mark/Project/workshop-201801/mobx-ts-todo/node_modules/grpc
> node-pre-gyp install --fallback-to-build --library=static_library

[grpc] Success: "/Users/mark/Project/workshop-201801/mobx-ts-todo/node_modules/grpc/src/node/extension_binary/node-v57-darwin-x64-unknown/grpc_node.node" is installed via remote
+ firebase@4.9.0
added 144 packages in 32.816s

Project/workshop-201801/mobx-ts-todo is 📦 v0.1.0 via ⬢ v8.9.4 took 34s 
➜ npm install autobind-decorator -D                                         
+ autobind-decorator@2.1.0
added 1 package in 8.553s

todoApp 만들기 (1)

  • 폴더 만들기
    • stores, components
  • tsconfig.json 수정
    • experimentalDecorators 설정
  • 스토어 만들기
    • todoStore.tsx 만들기 => export default new TodoStore();
  • 프로바이더 설정
    • <App /> 감싸기

todoStore.tsx 최초

import { observable } from 'mobx';

export class TodoStore {
  @observable todos = [];
}

export default new TodoStore();

index.tsx 에 Provider 추가

import * as React from 'react';
import * as ReactDOM from 'react-dom';
import App from './App';
import registerServiceWorker from './registerServiceWorker';
import './index.css';

import { useStrict } from 'mobx';
import { Provider } from 'mobx-react';
import todoStore from './stores/todoStore';

useStrict(true);

ReactDOM.render(
  <Provider todoStore={todoStore}>
    <App />
  </Provider>,
  document.getElementById('root') as HTMLElement
);
registerServiceWorker();

todoApp 만들기 (2)

  • TodoInput 컴포넌트 만들기
    • input 태그에 ref 사용.
    • addTodo(text: string): void;
  • TodoList 컴포넌트 만들기
    • todos: { id: string; text: string; }[];
    • deleteTodo(id: string): void;

컴포넌트(dumb) - TodoInput.tsx

import * as React from 'react';
import { observer } from 'mobx-react';
import autobind from 'autobind-decorator';

interface TodoInputProps {
  addTodo(text: string): void;
}

@observer
class TodoInput extends React.Component<TodoInputProps, {}> {
  private _input: HTMLInputElement;
  render() {
    return (
      <div>
        <input
          type="text"
          ref={ref => (this._input = ref as HTMLInputElement)}
        />
        <button onClick={this._addTodo}>저장</button>
      </div>
    );
  }
  @autobind
  private _addTodo(): void {
    const input = this._input;
    if (input.value !== '') {
      this.props.addTodo(input.value);
      input.value = '';
    }
  }
}

export default TodoInput;

컴포넌트(dumb) - TodoList.tsx

import * as React from 'react';
import { observer } from 'mobx-react';

interface TodoListProps {
  todos: { id: string; text: string }[];
  deleteTodo(id: string): void;
}

@observer
class TodoInput extends React.Component<TodoListProps, {}> {
  render() {
    const list = this.props.todos.map(todo => (
      <li key={todo.id}>
        {todo.text}
        <button onClick={() => this.props.deleteTodo(todo.id)}>삭제</button>
      </li>
    ));
    return (
      <div>
        <ul>{list}</ul>
      </div>
    );
  }
}

export default TodoInput;

todoApp 만들기 (3)

  • Firebase 연결 및 설정
    • 보안 규칙 설정
    • npm i firebase (@types/firebase X)
  • todoStore 작성
    • firebase 연결부
    • 리액트 컴포넌트에서 사용하는 action 작성
    • firebase 가 적용되는 포인트에 action 처리
      • 트랜잭션
  • useStrict(true); 처리

파이어베이스 디비 보안규칙 변경

{
  "rules": {
    ".read": true,
    ".write": true
  }
}

스토어 - todoStore.tsx

import { observable, action } from 'mobx';
import * as firebase from 'firebase';
import autobind from 'autobind-decorator';

const config = {
  apiKey: 'AIzaSyD4Ywl6RRaFrSy8ZXL10hsSl6orA2PF5hc',
  databaseURL: 'https://mobx-ts-todo.firebaseio.com',
  projectId: 'mobx-ts-todo'
};
firebase.initializeApp(config);
const db: firebase.database.Database = firebase.database();

export class TodoStore {
  @observable todos: { id: string; text: string }[] = [];
  constructor() {
    const ref = db.ref();
    ref.child('todos').on(
      'value',
      action((snapshot: firebase.database.DataSnapshot) => {
        if (snapshot) {
          const list = snapshot.val();
          const todos = [];
          if (list !== null) {
            for (const key of Object.keys(list)) {
              todos.push({
                id: key,
                text: list[key]
              });
            }
          }
          this.todos = todos;
        }
      })
    );
  }
  @autobind
  @action
  addTodo(text: string) {
    const ref = db.ref();
    ref
      .child('todos')
      .push()
      .set(text);
  }
  @autobind
  @action
  deleteTodo(id: string) {
    const ref = db.ref();
    ref
      .child('todos')
      .child(id)
      .remove();
  }
}

export default new TodoStore();

컨테이너(Smart) - Todo.tsx

import * as React from 'react';
import { observer, inject } from 'mobx-react';
import TodoInput from '../components/TodoInput';
import TodoList from '../components/TodoList';
import { TodoStore } from '../stores/todoStore';

interface TodoProps {
  todoStore?: TodoStore;
}

@inject('todoStore')
@observer
class Todo extends React.Component<TodoProps, {}> {
  render() {
    const todoStore = this.props.todoStore as TodoStore;
    return (
      <div className="App">
        <TodoInput addTodo={todoStore.addTodo} />
        <TodoList todos={todoStore.todos} deleteTodo={todoStore.deleteTodo} />
      </div>
    );
  }
}

export default Todo;

[CODEBUSKING WORKSHOP] MobX with TypeScript

By Woongjae Lee

[CODEBUSKING WORKSHOP] MobX with TypeScript

코드버스킹 워크샵 - React with TypeScript 세번째 (2018년 1월 버전)

  • 1,324