홍승아블로그

mobx

mobx란?

참고코드: GitHub - seungahhong/states-todos

설치

npm install mobx mobx-react

1. Mobx에 대한 개념, 특장점 설명

  • Mobx 주요개념

    • observable state
      • Observable State로 관찰 받고 있는 데이터이다. Mobx에서는 해당 State가 관찰하고 있다가 변화가 일어나면 computed values, render action을 발생시키는 역할을 담당
    import { observable } from 'mobx';
    
    class Todo {
      id = Math.random();
      @observable title = '';
      @observable finished = false;
    }
    • computed values
      • 기존에 상태가 변환에 따라 계산된(파생된) 값을 의미
      • state가 변경되었을 때만 새로 계산해서 계산값을 저장해놓고 사용
      • computed 내부 state가 변경되지 않았으면 기존에 계산해놨던 캐싱값을 그대로 다시 사용
    class TodoList {
      @observable todos = [];
      @computed get unfinishedTodoCount() {
        return this.todos.filter(todo => !todo.finished).length;
      }
    }
    • reaction
      • Reaction은 Compute Values와 비슷하지만 값을 계산하는 대신 콘솔, 네트워크 요청, 리액트 컴포넌트 트리 업데이트 등 다른 부수효과를 만들어낸다.
      • custom Reaction: autorun, reaction, when를 사용 가능
    autorun(() => {
      console.log('Tasks left: ' + todos.unfinishedTodoCount);
    });
    • actions
      • 상태를 변경시키는 모든 것을 의미한다. Mobx에서는 모든 사용자의 액션으로 상태를 변화 시킨다.
      • action.bound → class 형 컴포넌트의 this를 bind 해주기 위한 데코레이터
    // state가 항상 action으로만 변경되게끔 설정하는 옵션이다.
    configure({ enforceActions: "observed" })
    
    @action
    getTodoList(params)
    
    @action.bound
    getTodoList(params)
  • Mobx 특징

    • 단일 스토어로 제한하지 않는다.
    • 깔끔한 코드 작성이 가능하다.
      • redux state, dispatch 연결을 위한 mapStateToProps, mapDispatchToProps등의 보일러플레이트 코드 삭제가능(@inject)
    • 캡슐화나 정보은닉에 장점이 있다.(객체지향 개념)
      • state를 오직 메서드를 통해서만 변경가능(Model), get/set을 통해서 private 하게 관리가능
      • configure({enforceActions: ‘observed’})
    • state 불변성 유지 불필요한다.
      • redux에서 불변성을 유지하기 위해서 immer, spread 연산을 같은 불필요함이 사라진다.
    • 비동기 함수 호출을 위한 미들웨어 불필요
      • redux-thunk, redux-saga와 같은 미들웨어 불필요
    • 타입스크립트
      • 타입스크립트로 제작되었고, @type 패키지를 설치하지 않아도 적용가능
    • computed values(재계산/파생된), reaction 호출로 인해서 디버깅이 어렵다.
      • 분명히 좋은 기능인지는 알고 있지만, 개발 볼륨이 커지면서 자동 호출에 대한 디버깅이 어렵다.
      • 개발자의 역량에 따라서 잘 사용하면 좋은 기능인 것 같다.
  • 클래스형/함수형 컴포넌트 예제

    • 공통코드
    const counter = new CounterStore(); // 스토어 인스턴스를 만들고
    const counter1 = new CounterStore();
    
    ReactDOM.render(
      <React.StrictMode>
        <Provider counter={counter} counter1={counter1}>
          <App />
        </Provider>
      </React.StrictMode>,
      document.getElementById('root'),
    );
    • 클래스형 컴포넌트
    import React, { Component } from 'react';
    import { observer, inject } from 'mobx-react';
    
    @inject('counter')
    @inject('counter1')
    @observer
    class Counter extends Component {
      render() {
        const { counter } = this.props;
        return (
          <div>
            <h1>{counter.number}</h1>
            <button onClick={counter.increase}>+1</button>
            <button onClick={counter.decrease}>-1</button>
          </div>
        );
      }
    }
    
    export default Counter;
    • 함수형 컴포넌트(현재 버전에서는 inject 1개 이상 처리불가능)
    import React from 'react';
    import { observer, inject } from 'mobx-react';
    
    function FunctionCounter(props) {
      const { counter1 } = props;
      return (
        <div>
          <h1>{counter1.number}</h1>
          <button onClick={counter1.increase}>+1</button>
          <button onClick={counter1.decrease}>-1</button>
        </div>
      );
    }
    
    export default inject('counter', 'counter1')(observer(FunctionCounter));

2. Mobx vs ReduxToolkit 코드비교

  • mobx(observable, observer) vs Redux(createSlice options, useSelector/connect)

    ///////// mobx /////////
    // TodoStore.ts
    const initialState: TodoState = {
      todoItem: [],
      loading: false,
      message: '',
    };
    
    class TodosStore {
      @observable store: TodoState = initialState;
    
      constructor() {
        makeObservable(this); // v6 추가(안할경우 리렌더링이 안됨)
      }
      ...
    }
    
    // RootContainer.tsx
    @observer
    class RootContainer extends React.Component<{}, {}> {
      private todosStore: todosStore;
    
      constructor(props: any) {
        super(props);
        this.todosStore = new todosStore();
      }
    
      render() {
        return (
          <ErrorBoundary FallbackComponent={ErrorFallback}>
            <Provider store={this.todosStore}>
              <TodoContentContainer />
            </Provider>
          </ErrorBoundary>
        );
      }
    }
    
    // TodoContentContainer.tsx
    @inject('store')
    @observer
    class TodoContentContainer extends React.Component<TodoStoreProps, TodoStoreState> {
    constructor(props: TodoStoreProps) {
        super(props);
    
        this.state = {
          fetchNumber: 1,
        }
    
        this.todosStore = props.store!; // 접미에 붙는 느낌표(!) 연산자인 단언 연산자는 해당 피연산자가 null, undeifned가 아니라고 단언
      }
    
      render() {
        const { store: todosStore } = this.props.store!;
      }
    }
    ///////// Redux /////////
    // feature/index.ts
    const initialState: TodoState = {
      todoItem: [],
      loading: false,
      message: '',
    };
    
    const todos = createSlice({
      name: 'todos',
      initialState,
      reducers: { }, // key값으로 정의한 이름으로 자동으로 액션함수 생성
      extraReducers: { // 사용자가 정의한 이름의 액션함수가 생성
      ...
    }
    
    // store/index.js
    const store = configureStore({
      reducer: {
        todos: todos,
      },
      middleware: getDefaultMiddleware().concat(logger),
    });
    
    // RootContainer.tsx
    function RootContainer() {
      return (
        <ErrorBoundary FallbackComponent={ErrorFallback}>
          <Provider store={store}>
            <TodoContentContainer />
          </Provider>
        </ErrorBoundary>
      );
    }
    
    // TodoContentContainer.tsx
    const TodoContentContainer = (props: RootState) => {
      const todos: TodoState = useSelector((state: RootState) => props.todos, shallowEqual);
      const { todoItem, loading, message } = todos;
    }
    
    //// connect
    const mapStateToProps = (state) => ({
      todo: state.todos,
    });
    
    export default connect(mapStateToProps, null)(TodoContentContainer);
  • mobx(computed) vs Redux(createSelector)

    ///////// mobx /////////
    // TodoStore.ts
    @computed
    get getTodoLength(){
      return this.store.todoItem.length;
    }
    
    // TodoContentContainer.tsx
    <div>길이: {this.props.store?.getTodoLength}</div>
    ///////// Redux /////////
    // feature/index.ts
    const getTodo = (state: RootState) => state.todos;
    export const getTodoItemState: Selector<RootState, number> = createSelector(
      getTodo,
      (state: TodoState) => state.todoItem.length,
    );
    
    // TodoContentContainer.tsx
    const todoLength = useSelector(getTodoItemState);
    <div>길이: {todoLength}</div>;
  • mobx(action, action.bound, runInAction, flow) vs Redux(dispatch, action, actionType)

    • runInAction: mobx는 액션 함수 내부에서 promise 작업을 한 이후 다시 액션 함수를 만들거나, 외부의 액션 함수를 호출해야 정상적으로 observable 값을 바꿀 수 있으나 runInAction 을 사용하면 함수 내부에서 observable 값을 값을 바꿀 수 있습니다.
    ///////// mobx /////////
    // TodosStore.ts
    @action
    fetchAsyncTodoAction(args: number | undefined) {
      this.store.loading = true;
      return fetchTodo(args).then(
        this.fetchTodoSuccess,
        this.fetchTodoError,
      )
    }
    
    @action.bound
    fetchTodoSuccess(res: AxiosResponse) {
      const { data : todoItem } = res;
      this.store.loading = false;
      this.store.todoItem = Array.isArray(todoItem) ? todoItem : [].concat(todoItem);
      this.store.message = '성공했습니다...';
    }
    
    @action.bound
    fetchTodoError() {
      this.store.loading = false;
      this.store.message = '실패했습니다...';
    }
    
    @action
    fetchAsyncRunInActionTodoAction = async (args: number | undefined) => {
      try {
        this.store.loading = true;
        const res = await fetchTodo(args);
        runInAction(() => { // pending 할 시 success/fail 함수를 생성하지 않고 observable 상태값을 변경
          const { data : todoItem } = res;
          this.store.loading = false;
          this.store.todoItem = Array.isArray(todoItem) ? todoItem : [].concat(todoItem);
          this.store.message = '성공했습니다...';
        });
    
      } catch(e) {
        runInAction(() => {
          this.store.loading = false;
          this.store.message = '실패했습니다...';
        });
      }
    };
    
    @flow // generator 문법
    *fetchAsyncFlowTodoAction(args: number | undefined) {
      try {
        this.store.loading = true;
        const res: AxiosResponse<TodoItemState[]> = yield fetchTodo(args);
    
        const { data : todoItem } = res;
        this.store.loading = false;
        this.store.todoItem = Array.isArray(todoItem) ? todoItem : ([] as TodoItemState[]).concat(todoItem);
        this.store.message = '성공했습니다...';
      } catch(e) {
        this.store.loading = false;
        this.store.message = '실패했습니다...';
      }
    }
    
    // TodoContentContainer.tsx
    this.todosStore.fetchAsyncTodoAction(undefined);
    this.todosStore.fetchAsyncRunInActionTodoAction(undefined);
    this.todosStore.fetchAsyncFlowTodoAction(undefined);
    
    ///////// Redux /////////
    // constants/index.ts
    export const FETCH_TODO = 'FETCH_TODO' as const;
    export const FETCH_ASYNC_TODO = 'FETCH_ASYNC_TODO' as const;
    
    // feature/index.ts
    export const fetchTodoAction = createAction(FETCH_TODO, (todoItem: TodoItemState[]) => ({ payload: { todoItem }}));
    export const fetchAsyncTodoAction = createAsyncThunk(FETCH_ASYNC_TODO, async (args: number | undefined, thunkAPI) => {
      const { data: todoItem } = await fetchTodo(args);
      return {
        todoItem: Array.isArray(todoItem) ? todoItem : [].concat(todoItem),
      };
    });
    
    const todos = createSlice({
        ...
        extraReducers: { // 사용자가 정의한 이름의 액션함수가 생성
        [FETCH_TODO]: (state, action) => {
          return {
            ...state,
            todoItem: action.payload.todoItem,
          };
        },
       [fetchAsyncTodoAction.pending.type]: (state: TodoState, action: TodoAction) => {
         return {
            ...state,
            loading: true,
          };
       },
       [fetchAsyncTodoAction.fulfilled.type]: (state: TodoState, action: TodoAction) => {
         return {
            ...state,
            loading: false,
            todoItem,
            message: '성공했습니다...',
          };
       },
       [fetchAsyncTodoAction.rejected.type]: (state: TodoState, action: TodoAction) => {
         return {
            ...state,
            loading: false,
            message: '실패했습니다...',
          };
       },
      }
    });
  • 폴더구조

    mobx
     ┣ components
     ┃ ┣ types
     ┃ ┃ ┗ todos.ts
     ┃ ┗ TodosContentItemComponent.tsx
     ┣ containers
     ┃ ┣ RootContainer.tsx
     ┃ ┗ TodoContentContainer.tsx
     ┣ services
     ┃ ┣ __test__
     ┃ ┃ ┗ todo.service.tsx
     ┃ ┗ index.tsx
     ┗ states
     ┃ ┗ stores
     ┃ ┃ ┗ TodosStore.tsx
    reduxToolkit
     ┣ components
     ┃ ┣ types
     ┃ ┃ ┗ todos.ts
     ┃ ┗ TodosContentItemComponent.tsx
     ┣ containers
     ┃ ┣ RootContainer.tsx
     ┃ ┗ TodoContentContainer.tsx
     ┣ services
     ┃ ┣ __test__
     ┃ ┃ ┗ todo.service.tsx
     ┃ ┗ index.tsx
     ┣ states
     ┃ ┣ constants
     ┃ ┃ ┗ index.ts
     ┃ ┣ features
     ┃ ┃ ┣ __test__
     ┃ ┃ ┃ ┗ Todo.test.tsx
     ┃ ┃ ┗ index.ts
     ┃ ┣ store
     ┃ ┃ ┗ index.ts
     ┃ ┗ types
     ┃ ┃ ┗ index.ts

3. Migration From Mobx to react-query 가이드

  • mobx(observable, observer, computed) vs react-query(useQuery, useMutation)

    ///////// mobx /////////
    // TodoStore.ts
    const initialState: TodoState = {
      todoItem: [],
      loading: false,
      message: '',
    };
    
    class TodosStore {
      @observable store: TodoState = initialState;
    
      constructor() {
        makeObservable(this); // v6 추가(안할경우 리렌더링이 안됨)
      }
      ...
    }
    
    // RootContainer.tsx
    @observer
    class RootContainer extends React.Component<{}, {}> {
      private todosStore: todosStore;
    
      constructor(props: any) {
        super(props);
        this.todosStore = new todosStore();
      }
    
      render() {
        return (
          <ErrorBoundary FallbackComponent={ErrorFallback}>
            <Provider store={this.todosStore}>
              <TodoContentContainer />
            </Provider>
          </ErrorBoundary>
        );
      }
    }
    
    // TodoContentContainer.tsx
    @inject('store')
    @observer
    class TodoContentContainer extends React.Component<TodoStoreProps, TodoStoreState> {
    constructor(props: TodoStoreProps) {
        super(props);
    
        this.state = {
          fetchNumber: 1,
        }
    
        this.todosStore = props.store!; // 접미에 붙는 느낌표(!) 연산자인 단언 연산자는 해당 피연산자가 null, undeifned가 아니라고 단언
      }
    
      render() {
        const { store: todosStore } = this.props.store!;
      }
    }
    
    ///////// react-query /////////
    // fetch
    // TodoContentContainer.tsx
    
    import { useMutation, useQueryClient } from 'react-query'
    const queryClient = useQueryClient()
    const { data: response } = useFetchTodo({ id: ?? suspense: true });
    
    // constants/index.js
    export const GET_URL = 'https://jsonplaceholder.typicode.com/todos';
    export const POST_URL = 'https://jsonplaceholder.typicode.com/posts';
    
    // hooks/useTodo.js
    const { data, error } = useQuery(
        [GET_URL, id],
        () => fetcher(fetchTodos(id)),
        {
          suspense: !!suspense,
          useErrorBoundary: true,
        }
      );
    
    // mutation
    const createMutation = useMutation(createTodo, {
      onSuccess: (data) => {
        filterRef.current = {
          type: "CREATE_TODO",
          number: fetchNumber,
        };
        queryClient.setQueriesData([GET_URL, fetchNumber], data);
      },
    });
    
  • mobx(computed) vs react-query(useMemo)

    ///////// mobx /////////
    // TodoStore.ts
    @computed
    get getTodoLength(){
      return this.store.todoItem.length;
    }
    
    // TodoContentContainer.tsx
    <div>길이: {this.props.store?.getTodoLength}</div>
    
    ///////// react-query /////////
    // useMemo를 활용?? useQuery에서 재계산 로직추가??
    // useTodo.js
    const todoLength = useMemo(() => {
        return Array.isArray(data?.data) ? data?.data?.length : 1;
      }, [data]);
    <div>길이: {todoLength}</div>
  • mobx(action, action.bound, runInAction, flow) vs react-query(invalidateQueries, mutation)

    • runInAction: mobx는 액션 함수 내부에서 promise 작업을 한 이후 다시 액션 함수를 만들거나, 외부의 액션 함수를 호출해야 정상적으로 observable 값을 바꿀 수 있으나 runInAction 을 사용하면 함수 내부에서 observable 값을 값을 바꿀 수 있습니다.
    ///////// mobx /////////
    // TodosStore.ts
    @action
    fetchAsyncTodoAction(args: number | undefined) {
      this.store.loading = true;
      return fetchTodo(args).then(
        this.fetchTodoSuccess,
        this.fetchTodoError,
      )
    }
    
    @action.bound
    fetchTodoSuccess(res: AxiosResponse) {
      const { data : todoItem } = res;
      this.store.loading = false;
      this.store.todoItem = Array.isArray(todoItem) ? todoItem : [].concat(todoItem);
      this.store.message = '성공했습니다...';
    }
    
    @action.bound
    fetchTodoError() {
      this.store.loading = false;
      this.store.message = '실패했습니다...';
    }
    
    @action
    fetchAsyncRunInActionTodoAction = async (args: number | undefined) => {
      try {
        this.store.loading = true;
        const res = await fetchTodo(args);
        runInAction(() => { // pending 할 시 success/fail 함수를 생성하지 않고 observable 상태값을 변경
          const { data : todoItem } = res;
          this.store.loading = false;
          this.store.todoItem = Array.isArray(todoItem) ? todoItem : [].concat(todoItem);
          this.store.message = '성공했습니다...';
        });
    
      } catch(e) {
        runInAction(() => {
          this.store.loading = false;
          this.store.message = '실패했습니다...';
        });
      }
    };
    
    @flow // generator 문법
    *fetchAsyncFlowTodoAction(args: number | undefined) {
      try {
        this.store.loading = true;
        const res: AxiosResponse<TodoItemState[]> = yield fetchTodo(args);
    
        const { data : todoItem } = res;
        this.store.loading = false;
        this.store.todoItem = Array.isArray(todoItem) ? todoItem : ([] as TodoItemState[]).concat(todoItem);
        this.store.message = '성공했습니다...';
      } catch(e) {
        this.store.loading = false;
        this.store.message = '실패했습니다...';
      }
    }
    
    // TodoContentContainer.tsx
    this.todosStore.fetchAsyncTodoAction(undefined);
    this.todosStore.fetchAsyncRunInActionTodoAction(undefined);
    this.todosStore.fetchAsyncFlowTodoAction(undefined);
    ///////// react-query /////////
    // fetch
    // TodoContentContainer.tsx
    
    import { useMutation, useQueryClient } from 'react-query';
    const queryClient = useQueryClient();
    
    // constants/index.js
    export const GET_URL = 'https://jsonplaceholder.typicode.com/todos';
    export const POST_URL = 'https://jsonplaceholder.typicode.com/posts';
    
    // hooks/useTodo.js
    const handleFetchTodosAction = event => {
      queryClient.invalidateQueries([GET_URL]);
    };
    
    // mutation
    const handleCreateTodoAction = event => {
      createMutation.mutate({
        userId: fetchNumber,
        title: 'create',
        completed: false,
      });
    };
  • 폴더구조

    mobx
     ┣ components
     ┃ ┣ types
     ┃ ┃ ┗ todos.ts
     ┃ ┗ TodosContentItemComponent.tsx
     ┣ containers
     ┃ ┣ RootContainer.tsx
     ┃ ┗ TodoContentContainer.tsx
     ┣ services
     ┃ ┣ __test__
     ┃ ┃ ┗ todo.service.tsx
     ┃ ┗ index.tsx
     ┗ states
     ┃ ┗ stores
     ┃ ┃ ┗ TodosStore.tsx
    
    react-query
     ┣ components
     ┃ ┗ TodosContentItemComponent.jsx
     ┣ constants
     ┃ ┗ index.js
     ┣ containers
     ┃ ┣ RootContainer.jsx
     ┃ ┗ TodoContentContainer.jsx
     ┣ hooks
     ┃ ┗ useTodo.js
     ┗ services
     ┃ ┣ __test__
     ┃ ┃ ┗ todo.service.jsx
     ┃ ┗ index.jsx

참고페이지

이전글
webassembly
다음글
redux