MobX와 React Hooks를 조합하려고 하면 mobx-react-lite를 자연스럽게 접하게 됩니다.

그러나, 기존의 모든 기능이 구현되어 있지는 않아서 직접 구현을 하셔야 합니다.

예를 들면, 외부에 스토어를 생성하고 사용할때,
리액트에 안에서 사용할 수 있도록 해주는 inject나 Provider를 제공하지 않습니다.

Hooks로 코드를 작성하더라도 여전히 외부에 스토어를 작성하는 방법은 많이 쓰일것이기 때문에,
어떻게 외부에 스토어를 두고 상태를 관리하는지 알아보겠습니다.

Setup

npx create-react-app mobx-sample --typescript
또는
yarn create react-app mobx-sample --typescript

yarn add mobx mobx-react-lite

new : src/city.tsx

import React from 'react';

export const CityView: React.FC<{cities: string[]}> = ({ cities }) => {
    return (
        <ul>
            { cities.map(city => <li>{city}</li>)}
        </ul>
    );
}

export const CityList = () => {
    return <CityView cities={[]} />
}

export default CityList;

edit: src/App.tsx

import React from 'react';
import CityList from './city';
import './App.css';

const App: React.FC = () => {
    return (
        <div className="App">
            <header className="App-header">
                <CityList />
            </header>
        </div>
    )
}

export default App;

new: src/store.ts

const Cities = [
    'Amsterdam',
    'London',
    'Madrid',
];

export const createStore = () => {
    const store = {
        get allCities() {
            return Cities;
        },
    };

    return store;
};

export type TStore = ReturnType<typeof createStore>

new: src/context.tsx

import React from 'react';
import { useLocalStore } from 'mobx-react-lite';
import { createStore, TStore } from './store';

export const storeContext = React.createContext<TStore | null>(null);

export const StoreProvider: React.FC = ({ children }) => {
    const store = useLocalStore(createStore);

    return (
        <storeContext.Provider value={value}>
               {children}
        </storeContext.Provider>
    );
};

export default StoreProvider;

edit: src/App.tsx

import React from 'react;
import CityList from './city';
import StoreProvider from './context';
import './App.css';

const App: React.FC = () => (
    <StoreProvider>
        <div className="App">
            <header className="App-header">
                <CityList />
            </header>
        </div>
    </StoreProvider>
);

export default App;

edit: src/city.tsx

import React from 'react';
import { useObserver } from 'mobx-react-lite';
import { storeContext } from './context';

export const CityView: React.FC<{ cities: string[] }> = ({ cities }) => {
    return (
        <ul>
            {cities.map(city => <li>{city}</li>)}
        </ul>
    );
}

export const CityList = () => {
    const store = React.useContext(storeContext);
    if(!store) throw Error("Store shouldn't be null");
    return useObserver(() => {
        return <CityView cities={store.allCities} />
    });
}

export default CityList;

edit: src/store.ts

import { observable } from 'mobx';

export const createStore = () => {
    const store = {
        query: observable.box(''),
        setQuery(query: string) {
            store.query.set(query.toLowerCase());
        },
        get filteredCities() {
            return Cities.filter(
                city => city.toLowerCase().includes(store.query.get())
            )
        }
    }

    return store;
};

export type TStore = ReturnType<typeof createStore>;

edit: src/city.tsx

import React from 'react';
import { useObserver } from 'mobx-react-lite';
import { storeContext } from './context';

export const CityView: React.FC<{ cities: string[] }> = ({ cities }) => {
  return (
    <ul>
      {cities.map(city => <li>{city}</li>)}
    </ul>
  );
}

export const CityList = () => {
  const store = React.useContext(storeContext);
  if (!store) throw Error("Store shouldn't be null");

  return useObserver(() => {
    return <CityView cities={store.filteredCities} />
  });
}

export default CityList;

new: src/search.tsx

import React from 'react';
import { useObserver } from 'mobx-react-lite';
import { storeContext } from './context';

const Search: React.FC = () => {
  const store = React.useContext(storeContext);
  if (!store) throw Error("Store shouldn't be null");

  const { query, setQuery } = store;

  return useObserver(() => {
    return <input value={query.get()} onChange={e => setQuery(e.target.value)} />;
  });
}

export default Search;

edit: src/App.tsx

import React from 'react';
import Search from './search';
import CityList from './city';
import StoreProvider from './context';
import './App.css';

const App: React.FC = () => (
  <StoreProvider>
    <div className="App">
      <header className="App-header">
        <Search />
        <CityList />
      </header>
    </div>
  </StoreProvider>
);

export default App;

재사용가능하게 만들기

  1. 재사용성
  2. 제네릭
  3. 타입안정성

v1

import React from 'react';

export const useStore = <ContextData>(
  context: React.Context<ContextData>
) => {
  const store = React.useContext(context);
  if (!store) {
    throw new Error();
  }
  return store;
};

재사용성 ❌
여전히 모든 사용처에서 useObserver로 감싸주어야한다

제네릭 ❌
context를 허용하지만 context내에 store말고 다른 값이 존재할수도 있습니다

타입안정성 👍
hooks로 가져온 값중 없는 값을 사용하려고 하면 typescript에서 에러가 발생합니다

v2

import React from 'react';
import { useObserver } from 'mobx-react-lite';

export const useStore = <ContextData, Store>(
  context: React.Context<ContextData>,
  storeSelector: (contextData: ContextData) => Store,
) => {
  const value = React.useContext(context);
  if (!value) {
    throw new Error();
  }
  const store = storeSelector(value);
  return useObserver(() => store);
};

재사용성 ❌
useObserver로 전체 스토어를 감싸주었기 때문에, 스토어 내 어떤 값이라도 바뀌면 리렌더링이 발생합니다(호출한 곳에서 사용하지 않을때도)

제네릭 👍
스토어를 context중 일부로 선택가능하고, 일부 스토어 타입에 강제되지 않습니다.

타입안정성 👍
hooks로 가져온 값중 없는 값을 사용하려고 하면 typescript에서 에러가 발생합니다

v3

import React from 'react';
import { useObserver } from 'mobx-react-lite';

export const useStoreData = <Selection, ContextData, Store>(
  context: React.Context<ContextData>,
  storeSelector: (contextData: ContextData) => Store,
  dataSelector: (store: Store) => Selection
) => {
  const value = React.useContext(context);
  if (!value) {
    throw new Error();
  }
  const store = storeSelector(value);
  return useObserver(() => {
    return dataSelector(store);
  });
};

Usable 👍
dataSelector로 감싼 부분만 관찰하게 됬습니다.

Generic 👍
스토어를 context중 일부로 선택가능하고, 일부 스토어 타입에 강제되지 않습니다.

Typesafe 👍
hooks로 가져온 값중 없는 값을 사용하려고 하면 typescript에서 에러가 발생합니다

src/hook.ts

import { storeContext } from "./context";
import { TStore } from "./store";

export const useRootData = <Selection>(
  dataSelector: (store: TStore) => Selection
) =>
  useStoreData(storeContext, contextData => contextData!, dataSelector);

edit: src/city.tsx

import React from 'react';
import { useRootData } from './hook';

export const CityView: React.FC<{ cities: string[] }> = ({ cities }) => {
  return (
    <ul>
      {cities.map(city => <li>{city}</li>)}
    </ul>
  );
}

export const CityList = () => {
  const cities = useRootData(store => store.filteredCities);

  return <CityView cities={cities} />
}

export default CityList;

edit: src/search.tsx

import React from 'react';
import { useRootData } from './hook';

const Search: React.FC = () => {
  const { query, setQuery } = useRootData(store => ({
    query: store.query.get(),
    setQuery: store.setQuery
  }));

  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

export default Search;

원문 - Using MobX with React Hooks and TypeScript