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)
- observable state
-
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