mobx
SeungAh Hong
What is mobx?
Reference code: GitHub - seungahhong/states-todos
Installation
npm install mobx mobx-react1. 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
thisfor class components.
// state가 항상 action으로만 변경되게끔 설정하는 옵션이다. configure({ enforceActions: "observed" }) @action getTodoList(params) @action.bound getTodoList(params) - observable state
-
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.tsxreduxToolkit ┣ 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