본문 바로가기
Frontend/React

MobX 를 이용한 간단한 앱 만들기

by 디스코비스킷 2024. 1. 17.
반응형

01. MobX 란

redux이후로 많이 사용되는 상태관리 라이브러리이다.
MobX는,
간단하고 확장 가능한 상태 관리,
쉽고 확장성 있게 만들어주는 검증된 라이브러리다.

특징

  • 쉽다.
    미니멀하고 보일러 플레이트로부터 자유로운 코드를 통해 당신의 의도를 잘 담아내 보세요. 데이터를 변경하고 싶습니까? 자바스크립트 할당문을 사용하면 됩니다. 비동기 과정에서 데이터를 변경하고 싶습니까? 새로운 도구는 필요 없으며 MobX 시스템이 변경사항을 찾아내고 사용 중인 곳에 전달합니다.
  • 렌더링 최적화를 쉽게 할 수 있다.
    데이터의 모든 변경과 사용은 런타임에 추적되고 상태와 출력 사이의 모든 관계를 나타내는 의존 트리(dependency tree)를 만듭니다. 따라서 리액트 컴포넌트들처럼 상태에 따라 필요한 경우에만 연산이 실행됩니다. 그래서 memoization, selectors 등을 사용하여 컴포넌트 최적화 작업을 할 필요가 없습니다.
  • 구조가 자유롭다.
    UI 프레임워크 밖에서 애플리케이션 상태를 관리 할 수 있습니다. 따라서 코드 분리가 쉽고 다른 곳에서 사용하기 유용하며 무엇보다 쉽게 테스트 할 수 있습니다.

원래는 @데코레이터를 사용했지만 mobx 6부터는 데코레이터 사용을 지양하는 중.

작동원리

모든 이벤트(onClick, setInterval)는 observable state(myTimer.secondsPassed)를 변경시키는 action(myTimer.increase, myTimer.reset)을 호출합니다. observable state의 변경 사항은 모든 연산과 변경사항에 따라 달라지는 부수 효과(TimerView)에 전파됩니다.

02. Mobx로 카운터 앱 만들기

npx create-react-app ./
npm install mobx

countStore.js

import { action, computed, makeObservable, observable } from 'mobx'

// 애플리케이션 상태를 모델링합니다.
export default class counterStore {
  count = 0;

  constructor() {
      makeObservable(this, {
        count: observable,
      isNegative: computed,
      increase: action,
      decrease: action
    })
  }

  get isNegative() {
      return this.count < 0 ? 'Yes' : 'No';
  }

  increase() {
      this.count += 1;
  }
  decrease() {
      this.count -= 1;
  }
}

index.js

const store = new counterStore();

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <App myCounter={store} />
  </React.StrictMode>
);

reportWebVitals();

counterStore 인스턴스 생성후 App컴포넌트에 myCounter라는 props로 내려준다.

App.js

import './App.css';

function App({ myCounter }) {
  return (
    <div className="App" style={{ padding: "4rem" }}>
      카운트 : {myCounter.count}
      <br />
      <br />
      마이너스?: {myCounter.isNegative}
      <br />
      <br />
      <button onClick={() => myCounter.increase()}>+</button>
      <button onClick={() => myCounter.decrease()}>-</button>
    </div>
  );
}

export default App;

myCounter로 받아서 액션과 state를 사용할 수 있는데 액션버튼을 누를때 state 반영된것이 바로 보이지 않는다. 이때는 구독이 필요한데 observer를 사용한다. 이걸 사용하려면 다른 모듈을 설치해야한다. mobx-react나 mobx-react-lite를 설치하자.

npm install mobx-react

Observer HoC는 렌더링중에 사용되는 모든 Observable에 리액트 컴포넌트를 자동으로 구독한다. 결과적으로 관련 observable 항목이 변경되면 컴포넌트가 자동으로 다시 렌더링된다. 또한 관련 변경사항이 없을때 컴포넌트가 다시 렌더링되지 않도록한다. 따라서 컴포넌트에서 액세스할 수 있지만 실제로 읽지않는 Observable은 다시 렌더링되지 않는다.

App을 observer로 감싸면된다.
import { observer } from "mobx-react";
export default observer(App);


잘 반영이된다...

03. Mobx로 카운터 앱 만들기(데코레이터 사용)

6버전 이후부터는 데코레이터 사용을 지양하지만 이미 너무 많은 코드베이스와 튜토리얼이 데코레이터를 사용하기때문에 사용법을 익혀두면 좋다.

MobX 6 이전에는 observable, computed, action을 표시하기 위해 ES.next 데코레이터를 사용하도록 권장했습니다. 그러나 데코레이터는 현재 ES 표준이 아니며 표준화 과정에도 오랜 시간이 소요되고 있습니다. 또한 표준화되는 데코레이터는 기존의 시행되었던 방식과 다를 것으로 보입니다. MobX 6에서는 호환성을 위해 데코레이터에서 벗어나 makeObservable / makeAutoObservable을 사용할 것을 권장합니다.

그러나 기존의 많은 코드베이스와 온라인 문서 및 튜토리얼 자료에서 데코레이터를 사용하고 있습니다. observable, action, computed와 같이 makeObservable의 주석으로 사용할 수 있는 것은 무엇이든 데코레이터로 사용할 수 있다는 것이 규칙입니다. 예시로 구체적인 형태를 살펴봅시다.

데코레이터 지원 활성화하기

바벨설정을 바꿔야하는데 eject까지 하지 않기위해 타입스크립트를 이용한다. 변환을 위한 설정이 필요하므로 Babel 또는 TypeScript를 사용해야 합니다. (customize-cra와 react app rewired를 이용해서도 가능)

  • TypeScript
    tsconfig.json에서 "experimentalDecorators": true"useDefineForClassFields": true 컴파일러 옵션을 활성화하세요.
  • Babel 7
    npm i --save-dev @babel/plugin-proposal-class-properties @babel/plugin-proposal-decorators로 데코레이터 지원 패키지를 설치한 후 .babelrc 파일에서 활성화하세요.(반드시 순서를 지켜주세요.)
{
    "plugins": [
        ["@babel/plugin-proposal-decorators", { "legacy": true }],
        ["@babel/plugin-proposal-class-properties", { "loose": false }]
        // MobX 4/5에서와 반대로, "loose"가 false여야 합니다!       ^
    ]
}

프로젝트 생성

폴더생성후
npm init -y

package.json에서 devDependencies, dependencies 작성후 설치

  "scripts": {
    "start": "webpack-dev-server  --hot --open",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "devDependencies": {
    "@babel/core": "^7.18.6",
    "@babel/plugin-proposal-class-properties": "^7.18.6",
    "@babel/plugin-proposal-decorators": "^7.18.6",
    "@babel/preset-env": "^7.18.6",
    "@babel/preset-react": "^7.18.6",
    "babel-loader": "^8.2.5",
    "babel-plugin-transform-decorators-legacy": "^1.3.5",
    "html-webpack-plugin": "^5.5.0",
    "webpack": "^5.73.0",
    "webpack-cli": "^4.10.0",
    "webpack-dev-server": "^4.9.3"
  },
  "dependencies": {
    "mobx": "^6.6.1",
    "mobx-react": "^7.5.2",
    "mobx-react-devtools": "^6.0.1",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },

.babelrc

{
  "presets": ["@babel/preset-env", "@babel/preset-react"],
  "plugins": [
    [
      "@babel/plugin-proposal-decorators",
      {
        "legacy": true
      }
    ],
    [
      "@babel/plugin-proposal-class-properties",
      {
        "loose": false
      }
    ]
  ]
}

webpack.config.js


const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
    mode: 'development',
    // 시작점
    entry: {
        main: path.resolve(__dirname, 'src/index.js'),
    },
    // 웹팩 작업을 통해 생성된 결과물
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: '[name][contenthash].js',
        clean: true,
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /node_modules/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ['@babel/preset-env']
                    }
                }
            }
        ]
    },
    devServer: {
        static: {
            directory: path.join(__dirname, 'dist'),
        },
        compress: true,
        port: 3000,
        open: true,
    },
    plugins: [
        new HtmlWebpackPlugin({
            filename: 'index.html',
            template: 'index.html',
        })
    ]
}

index.js

import React from 'react'
import { render } from 'react-dom'
import App from "./App"

ReactDOM.createRoot(document.getElementById("root")).render(
    <App />
);

이렇게 기본적으로 웹팩을 활용해서 직접 리액트 프로젝트를 작성했다.

이제 데코레이터를 사용해 카운터를 만든다.
countStore.js

import { makeObservable, observable, computed, action } from "mobx";

export default class counterStore {

    @observable count = 0;

    constructor() {
        makeObservable(this)
    }

    @computed get isNegative() {
        return this.count < 0 ? 'Yes' : 'No'
    }

    @action increase() {
        this.count++;
    }

    @action decrease() {
        this.count--;
    }
}

App.js는 rafce 스니펫이 아니라 rcc나 rccp, rce를 사용하면 쉽게 작성할 수있다.

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

@observer
export class App extends Component {
  render() {
    const myCounter = this.props.myCounter;
    return (
      <div className="App" style={{ padding: "4rem" }}>
        카운트 : {myCounter.count}
        <br />
        <br />
        마이너스?: {myCounter.isNegative}
        <br />
        <br />
        <button onClick={() => myCounter.increase()}>+</button>
        <button onClick={() => myCounter.decrease()}>-</button>
      </div>
    );
  }
}

export default App

index.js

import React from 'react'
import ReactDOM from 'react-dom/client'
import { render } from 'react-dom'
import App from "./App"
import counterStore from './counterStore'

const store = new counterStore();

ReactDOM.createRoot(document.getElementById("root")).render(
    <App myCounter={store} />
);

04. React Context를 이용한 Observable 공유하기

리액트 Context는 전체 하위 트리와 observable을 공유하는 좋은 메커니즘이다.
현재 상태에서 mobx의 observable 값을 여러 컴포넌트에 주려면 아래와 같이 하면된다.

ReactDOM.createRoot(document.getElementById("root")).render(\
  <React.StricMode>
    <App myCounter={store} />
    <BApp myCounter={store} />
    <CApp myCounter={store} />
  </React.StricMode>
);

이렇게 해도 되지만,
React Context를 이용하면 Provider로 감싼 전체 하위 트리의 컴포넌트에 observable을 공유할 수 있다.

  1. Context파일 만들기
    context/counterContext.js
    // Context 생성
    

import { createContext, useContext } from "react";

export const CounterContext = createContext();

// Provider 생성
export const CounterProvider = CounterContext.Provider;

// Store에 있는 value를 사용하기 위한 Hooks
export const useCounterStore = () => useContext(CounterContext);


2. index.js에서 Provider에 store연결

```js
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import reportWebVitals from './reportWebVitals';
import counterStore from './countStore';
import { CounterProvider } from './context/counterContext';

const store = new counterStore();

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <CounterProvider value={store}>
      <App />
    </CounterProvider>   
  </React.StrictMode>
);

reportWebVitals();
  1. App컴포넌트에서 store사용
    const myCounter = useCounterStore();

05. Mobx 를 이용한 Todo 앱 만들기

타입스크립 템플릿으로 리액트앱 생성
npx create-react-app ./ --template typescript
npm install mobx mobx-react

스토어 생성

TodoStore.ts

import { action, computed, makeObservable, observable } from "mobx";

interface TodoItem {
    id: number;
    title: string;
    completed: boolean;
}
export default class todoStore {
    todos: TodoItem[] = [];

    constructor() {
        makeObservable(this, {
            todos: observable,
            addTodo: action,
            toggleTodo: action,
            status: computed
        })
    }

    addTodo(title: string) {
        const item: TodoItem = {
            id: getId(),
            title, 
            completed: false
        }

        this.todos.push(item)
    }

    toggleTodo(id: number) {
        const index = this.todos.findIndex((item) => item.id === id); // -1 or 1
        if (index > -1) {
            this.todos[index].completed = !this.todos[index].completed;
        }
    }

    get status() {
        let completed = 0;
        let remaining = 0;

        this.todos.forEach((todo) => {
            if(todo.completed) {
                completed++;
            } else {
                remaining++;
            }
        })

        return { completed, remaining }
    }
}

let id = 0;
function getId() {
    return id++;
}

findIndex() 메서드

주어진 판별 함수를 만족하는 배열의 첫 번째 요소에 대한 인덱스를 반환한다. 만족하는 요소가 없으면 -1을 반환한다.

let a = [1,2,3]
a.findIndex(item => item === 5) // -1 
a.findIndex(item => item === 2) // 1

06. Todo 앱 UI 생성하기

UI생성

TodoList.tsx

import React, { useState } from 'react'
import TodoItemUI from "./TodoItem";
import TodoStore from './TodoStore'
import { observer } from 'mobx-react';

interface TodoListProps {
    todoStore: TodoStore;
}

const TodoList: React.FC<TodoListProps> = observer( ({ todoStore }) => {
  const [value, setValue] = useState<string>("");

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (value) {
      todoStore.addTodo(value);
    }
    setValue("");
  };

  const handleClick = (id: number) => {
    todoStore.toggleTodo(id);
  }
  return (
    <div style={{ padding: "4rem" }}>
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          value={value}
          onChange={(e) => setValue(e.target.value)}
        />
        <input type="submit" value="Submit" />
      </form>
      <hr />
      <div>Completed: {todoStore.status.completed}</div>
      <div>Remaning: {todoStore.status.remaining}</div>
      <ul>
        {todoStore.todos.map((todo) => (
          <TodoItemUI todo={todo} handleClick={handleClick} />
        ))}
      </ul>
    </div>
  );

});

export default TodoList

TodoItem.tsx

import React from 'react'
import { TodoItem } from './TodoStore'

interface TodoItemProps {
  todo: TodoItem;
  handleClick: (id: number) => void;
}
const TodoItemUI = ({ todo, handleClick }: TodoItemProps) => {
  return (
    <li key={todo.id}>
      <span onClick={() => handleClick(todo.id)}>
        {todo.completed ? "[x]" : "[ ]"}
      </span>&nbsp;
      {todo.id}
      {todo.title}
    </li>
  );
};

export default TodoItemUI;

TodList컴포넌트는 observer로 감싸주었는데,
TodoItemUI컴포넌트에서 observer함수가 필요하지 않다.

다만, 다음과 같은 경우라면 observer 함수를 사용할 수 있다.

  • TodoItemUI 컴포넌트에서 MobX 상태를 사용해야 하는 경우
  • TodoItemUI 컴포넌트에서 MobX 상태 변화를 감지해야 하는 경우

이러한 경우에는 observer 함수를 사용하여 MobX 옵저버로 감싸주면 된다.

반응형

최근댓글

최근글

© Copyright 2023 jngmnj