React에서 MobX를 제대로 사용했나?

@Hyunjoo · August 01, 2023 · 12 min read

지난 편에는 MobX Core 개념에 대해 다루어 보았습니다. 이제는 React에서 어떻게 사용할 수 있는지에 대해 알아보도록 하겠습니다. 자세한 내용은 공식문서 - MobX and React를 참조하시기 바랍니다.

✅ 과제 코드가 외부에 공개될 수도 있으므로 공식문서에 나온 예제로 예시를 보여드립니다.
✅ 해당 예제는 MobX 6 버전을 기반으로 작성되었습니다.


observer 컴포넌트 사용하기

MobX는 React와 독립적으로 작동하지만, 일반적으로 React와 함께 사용합니다. 필수적으로 mobx-react-lite 또는 mobx-react 패키지를 설치해야 합니다.
mobx-reactmobx-react-lite보다 큰 패키지이고 추가 기능을 제공합니다.

  1. React 클래스 컴포넌트를 지원합니다.
  2. Provider 그리고 inject를 제공합니다. React.createContext가 더 이상 필요하지 않습니다.
  3. 명확한 observable propTypes.

사용방법

import { observer } from "mobx-react-lite" // 또는 "mobx-react". 
const MyComponent = observer(props => ReactElement)

observer Higher-Order Component (observer 고차 컴포넌트: 이하 observer)를 사용하면 됩니다. 예제를 보면서 자세히 설명하겠습니다.

// TodosStore.ts
export class TodosStore {  
	todos: Todo[] = []
	
	constructor() {  
		makeObservable(this, {  
			todos: observable,
			getTotalTodos: computed
		})  
	}  
  
	get getTotalTodos() {  
		return this.todos.length;  
	}
}
// TodoListView.tsx
import { observer } from "mobx-react-lite"

const todosStore = new TodosStore();

const TodoListView = observer(() => {
	return (
		<TodoList todos={todos} />
	)
})

이제 예제가 점점 길어지네요. 전편에 다루었던 Doubler 클래스의 이름만 살짝 바꾸었습니다.
이 observer는 React 컴포넌트를 자동으로 MobX의 Observable들과 연결하여 렌더링 시 자동으로 구독하게 만들어줍니다. 그 결과로, 컴포넌트는 Observable이 변경될 때만 다시 렌더링됩니다. 또한, 컴포넌트와 관련없는 변경이 있을 때에는 리렌더링이 발생하지 않습니다. 이로 인해, 컴포넌트에 읽지 않은 Observable에 대해서는 불필요한 리렌더링을 방지하는데 도움이 됩니다.

memouseCallback을 사용하여 불필요한 리렌더링을 방지하는 데 추가적인 코드를 작성할 필요가 없습니다. observer가 이를 자동으로 처리해주기 때문입니다.


observer 컴포넌트에서 state 사용하기

외부 state 사용하기

observer 컴포넌트에서 외부 state 사용할 때는 3가지 방법을 사용할 수 있습니다.

  1. props로 받아서 사용
  2. 전역 변수 사용

    • 클래스 인스턴스를 export 하는 방식.
    • 단위 테스트를 복잡하게 할 수 있어서 React Context를 사용하는 것을 권장함
  3. React context를 사용하여 전역 객체로 사용하기 자세한 내용은 공식문서를 참조하시기 바랍니다.

저는 React context를 사용하였고 todos의 아이템을 표시할 때는 props를 사용했습니다.

// useStore.ts
const rootStoreContext = createContext({  
	todosStore: new TodosStore(),  
	problemsStore: new ProblemsStore(),  
});  
  
export const useStore = () => useContext(rootStoreContext);

// App.tsx
const App = () => {
	return <UiComponent />
}

// UI 컴포넌트
const UiComponent = observer(() => {
	const { todosStore } = useStore(); // 아래 설명의 시점
	return <></>
})

뭔가 허전하네요... 아....? 😅😅😅 <Provider />가 없습니다. 하하하하 그래도 작동은 했는데 왜 되었을까요?

추측을 하자면 다음과 같습니다.

  1. UiComponent 컴포넌트에서 import 되는 순간 TodosStore 와 ProblemsStore의 클래스 인스턴스가 생성이 됩니다.
  2. createContext로 Context 객체를 생성하고 rootStoreContext에 할당합니다.
  3. 호출하는 컴포넌트에 대한 컨텍스트 값을 반환하는 useStore 함수를 생성하고 export 합니다.

    1. React 공식문서-useContext의 returns에 따르면 트리에서 가장 가까운 SomeContext.Provider에 전달된 값을 리턴합니다. 이러한 Provider가 없으면 createContext에 전달한 값이 defaultValue가 되고 이 값을 리턴합니다.
    2. 결과적으로 가까운 SomeContext.Provider가 없었으므로 rootStoreContext를 리턴합니다.
    3. 그래서 UiComponent에서는 rootStoreContext 객체에 접근을 할 수 있었던 것이었습니다.

🫣🫣🫣🫣🫣🫣🫣🫣🫣🫣 부끄럽습니다. 호다닥 변경해보겠습니다.

// useStore.ts
export const todosStore = new TodosStore();  
export const problemsStore = new ProblemsStore();  
export const rootStoreContext = createContext<RootStore>({} as RootStore);

export const useStore = () => useContext(rootStoreContext);

// App.tsx
const App = () => {
	return (
		<rootStoreContext.Provider  
			value={{ problemStore, similarityProblemsStore }}>
			<UiComponent />
		</rootStoreContext.Provider>
	)
}

// UI 컴포넌트
const UiComponent = observer(() => {
	const { todosStore } = useStore(); // 아래 설명의 시점
	return <></>
})

기능상에는 문제가 없었지만 UiComponent 안에 Provider가 더 있었다면 문제가 발생했을거라 생각합니다. useContextdefaultValue가 어떻게 설정되는지 알 수 있었던 기회였습니다.

observer 컴포넌트에서 만들어서 사용하기

이 내용은 내부에서 observable state를 생성하여 사용하는 것에 대한 내용입니다.

const TimerView = observer(() => {  
	// 객체에 observable()을 직접 씌워서 사용하는 방법
	const [timer] = useState(() =>  
		observable({  
			secondsPassed: 0,  
			increaseTimer() {  
				this.secondsPassed++  
			}  
		})  
	)
	// const [store] = useState(() => observable({ /* something */}))
	// `mobx-react-lite` 패키지의 `useLocalObservable`hook을 사용하는 방법
	const timer = useLocalObservable(() => ({  
		secondsPassed: 0,  
		increaseTimer() {  
			this.secondsPassed++  
		}  
	}))
	
	return <span>Seconds passed: {timer.secondsPassed}</span>  
})

observable state를 내부에서 만들어 사용할 필요까지 있을까?

공식문서에도 나온 내용을 집고 넘어가야 할 듯합니다.

로딩 state, 선택 등과 같은 UI state만 캡쳐하는 state는 useState hook을 사용하는 것이 더 좋습니다. 그렇게 하면 추후에 React suspense 기능을 사용할 수 있게 됩니다.

이 내용에 의하면 단순하게 몇번째 index의 아이템이 선택되었거나 어떤 탭이 선택되었다는 state는 useState를 사용하라는 말입니다.


항상 observer 컴포넌트 안에서 observable을 읽기

observer를 언제 사용해야 하는지에 대한 것입니다. observer는 감싸고 있는 컴포넌트만 개선하며, 감싸고 있는 컴포넌트를 호출하는 컴포넌트를 개선하지 않습니다. 또한 모든 컴포넌트를 observer로 감싸는 행동은 비효율적이지 않기 때문에 걱정하실 필요가 없습니다. 그래도 observable 데이터가 필요없는 곳에서는 감싸지 않아야 할 것입니다.

가능한 한 늦게 객체에서 값 읽기

observer 컴포넌트로 observable 데이터를 보낼 때는 풀어서 보내지 마세요. ❌
observer 컴포넌트 내부에서 읽는 것이 아니라 외부에서 읽히면 추적되지 않습니다. 그래서 변경사항에 반응하지 않습니다.

class Todo { 
	title = "test" 
	done = true 
	
	constructor() { 
		makeAutoObservable(this) 
	} 
}

나쁜 예 ❌
return <ObserverComponent done={todo.done} title={todo.title} />

좋은 예 ✅ // 안에서 풀어서 사용하세요. 
return <ObserverComponent todo={todo} />

반대로 일반 컴포넌트로 observable 데이터를 보낼 때는 풀어서 일반 데이터로 보냅니다. ✅

나쁜 예 ❌ 
return <NonObserverComponent todo={todo} />

좋은 예 ✅
return <NonObserverComponent data={{
	title: todo.title,
	done: todo.done
}} />

문제해결 방법

공식문서 보시면 절망을 외치는 저의 모습이 담겨져있었습니다.

도와주세요. 컴포넌트가 리렌더링 되지 않아요...

대부분이 observer를 붙이지 않아서 생기는 문제였습니다. 대부분의 컴포넌트들이 observable한 데이터를 사용하다보니 observer는 거의 다 붙습니다.
그리고 디버깅을 위해 MobX 설정은 꼭 해보시기 바랍니다.


리액트 컴포넌트 렌더링 최적화 🚀

공식문서에서 실제로 이렇게 써져있습니다.

실제로 문제가 발생한 경우에만 성능을 우선시하세요!

워낙 알아서 하니까 신경쓰지 말라는 이야기죠. 🤣 그래도 알아두면 피와 살이 될 것입니다.

컴포넌트를 최소화하여 사용하세요.

이 내용은 컴포넌트의 재사용성을 생각해서라도 필요하다고 봅니다.

전용 컴포넌트들을 사용하여 리스트를 렌더링하세요.

// 나쁜 예 ❌
const MyComponent = observer(({ todos, user }) => (  
	<div>  
		{user.name}  
		<ul>  
			{todos.map((todo) => (  
				<TodoView todo={todo} key={todo.id} />  
			))}  
		</ul>  
	</div>  
));  

// 좋은 예 ✅
const MyComponent = observer(({ todos, user }) => (  
	<div>  
		{user.name}  
		<TodosView todos={todos} />  
	</div>  
))  
  
const TodosView = observer(({ todos }) => (  
	<ul>  
	{todos.map(todo => (  
		<TodoView todo={todo} key={todo.id} />  
	))}  
	</ul>  
))

나쁜 예에서는 user.name을 변경하면 map을 다시 돌면서 TodosView 컴포넌트들의 가상 DOM을 하나씩 하나씩 비교를 합니다.

좋은 예에서는 user.name을 변경하면 TodosView 컴포넌트의 가상 DOM과 비교합니다.

다시 렌더링은 되지는 않지만 여러개를 비교하느냐 한개만 비교하느냐의 차이인 겁니다.

배열 인덱스를 키로 사용하지 마세요.

이 내용 역시 React와 비슷하군요.

역참조는 최대한 늦게 하세요.

// 느린 예시
<DisplayName name={person.name} />

// 빠른 예시
<DisplayName person={person} />
@Hyunjoo
Hello :) I'm Lanky.