Skip to content

mobx

SeungAh Hong3min read

What is mobx?

Reference code: GitHub - seungahhong/states-todos

Installation

npm install mobx mobx-react

1. Concepts and key strengths of Mobx

  • Core concepts of Mobx

    • observable state
      • This is data that is being observed as Observable State. In Mobx, this state is watched, and when a change occurs it is responsible for triggering computed values and render actions.
    import { observable } from 'mobx';
     
    class Todo {
      id = Math.random();
      @observable title = '';
      @observable finished = false;
    }
    • computed values
      • These are values that are computed (derived) from the existing state as it changes.
      • The value is only recomputed when the state changes, and the computed result is stored and reused.
      • If the internal state of a computed value has not changed, the previously computed cached value is reused as-is.
    class TodoList {
      @observable todos = [];
      @computed get unfinishedTodoCount() {
        return this.todos.filter(todo => !todo.finished).length;
      }
    }
    • reaction
      • A Reaction is similar to Computed Values, but instead of computing a value it produces other side effects such as console output, network requests, or updating the React component tree.
      • custom Reaction: you can use autorun, reaction, and when.
    autorun(() => {
      console.log('Tasks left: ' + todos.unfinishedTodoCount);
    });
    • actions
      • This refers to anything that changes state. In Mobx, all state changes happen through user actions.
      • action.bound → a decorator used to bind this for class components.
    // state가 항상 action으로만 변경되게끔 설정하는 옵션이다.
    configure({ enforceActions: "observed" })
     
    @action
    getTodoList(params)
     
    @action.bound
    getTodoList(params)
  • Characteristics of Mobx

    • It is not restricted to a single store.
    • It allows for cleaner code.
      • You can remove boilerplate code such as mapStateToProps and mapDispatchToProps that Redux requires for connecting state and dispatch (@inject).
    • It has advantages in encapsulation and information hiding (object-oriented concepts).
      • State can only be changed through methods (Model), and it can be managed privately via get/set.
      • configure({enforceActions: 'observed'})
    • There is no need to maintain state immutability.
      • Unnecessary operations for maintaining immutability in Redux, such as immer or spread operations, are eliminated.
    • No middleware is needed for asynchronous function calls.
      • Middleware such as redux-thunk or redux-saga is not required.
    • TypeScript
      • It is written in TypeScript, so it works without installing the @type packages.
    • Debugging is difficult due to computed values (recomputed/derived) and reaction calls.
      • While these are clearly good features, as the development volume grows, debugging automatic calls becomes difficult.
      • Depending on the developer's skill, it seems to be a good feature when used well.
  • Class component / function component examples

    • Shared code
    const counter = new CounterStore(); // 스토어 인스턴스를 만들고
    const counter1 = new CounterStore();
     
    ReactDOM.render(
      <React.StrictMode>
        <Provider counter={counter} counter1={counter1}>
          <App />
        </Provider>
      </React.StrictMode>,
      document.getElementById('root'),
    );
    • Class component
    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;
    • Function component (in the current version, more than one inject cannot be handled)
    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. Code comparison: 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: In Mobx, to change an observable value correctly after performing a promise operation inside an action function, you normally have to create another action function or call an external action function. However, by using runInAction you can change the observable value directly inside the function.
    ///////// 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: '실패했습니다...',
          };
       },
      }
    });
  • Folder structure

    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 guide

  • 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: In Mobx, to change an observable value correctly after performing a promise operation inside an action function, you normally have to create another action function or call an external action function. However, by using runInAction you can change the observable value directly inside the function.
    ///////// 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,
      });
    };
  • Folder structure

    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

References