TIL 77 | MobX + React Hooks + TypeScript

hyounglee·2020년 11월 8일
0

TypeScript

목록 보기
5/5

Using MobX with React Hooks and TypeScript
mobx-react와 React Hooks API 함께 사용하기

mobx-react가 제공하는 observer로 래핑된 함수형 컴포넌트에서 useState와 같은 훅 API를 사용하려 하면 React는 '훅은 함수형 컴포넌트에서만 사용할 수 있다'는 오류를 발생시킨다. observer API는 클래스형 컴포넌트를 리턴하는 HOC이기 때문이다. 이 때문에 MobX와 React Hooks를 조합하기 위해서는 mobx-react-lite를 사용하게 된다. 하지만 기존의 MobX와는 약간의 차이가 있어 직접 구현해야 하는 부분들이 존재한다.

실습

도시 리스트를 보여주는 간단한 앱을 만들어보자.

초기 세팅

$ yarn create react-app mobx-practice --template typescript
$ yarn add mobx mobx-react-lite

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;

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;

데이터를 저장할 store를 추가한다.

src/store.ts

// initial values와 사용할 함수를 선언하여 export
const Cities = [
  'Amsterdam',
  'London',
  'Madrid'
];

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

  return store;
};

export type TStore = ReturnType<typeof createStore>

여기에서 React.Context를 사용하면 store의 인스턴스를 자손들에게 제공할 수 있다.

React Context 간단 정리

src/context.tsx

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

// context와 provider를 생성하고 export
// context에서는 사용할 store를 불러온다.
export const storeContext = React.createContext<TStore | null>(null);

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

  return (
    // 불러온 store를 provider에 넣어준다.
    <storeContext.Provider value={store}>
      {children}
    </storeContext.Provider>
  );
};

export default StoreProvider;

src/App.tsx (edit)

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;

이제 각 컴포넌트에서 store에 접근이 가능하지만, 현재는 빈 배열이 하드코딩 되어있다. <CityList /> 컴포넌트를 store에 연결하여 <StoreProvider />의 인스턴스에 React.useContext hook으로 접근하도록 수정해보자.

src/city.tsx (edit)

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;

이제 모든 도시들을 렌더링 할 수 있다. 여기에 search box를 추가해서 user가 원하는 도시를 빠르게 찾을 수 있도록 수정해보자.

src/store.ts (edit)

import {observable} from 'mobx';

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

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>

src/city.tsx (edit)

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;

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;

src/App.tsx (edit)

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. 타입 안정성

아래 세 가지 버전을 각각 살펴보자

ver. 1

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를 허용하지만 store내에 context 외에 다른 값이 존재할 수 있다.

타입안정성 ✔︎
타입스크립트가 만약 존재하지 않는 속성에 접근하려고 하면 에러를 발생할 것이다.

ver. 2

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 중에서 일부로 선택이 가능하고, 특정 스토어 타입에 강제되지 않는다.

타입안정성 ✔︎
타입스크립트가 만약 존재하지 않는 속성에 접근하려고 하면 에러를 발생할 것이다.

ver. 3

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);
  });
};

재사용성 ✔︎
dataSelector()로 선택된 부분만 관찰하게 된다.

제네릭 ✔︎
스토어를 context 중에서 일부로 선택이 가능하고, 특정 스토어 타입에 강제되지 않는다.

타입안정성 ✔︎
타입스크립트가 만약 존재하지 않는 속성에 접근하려고 하면 에러를 발생할 것이다.

응용

하나의 컨텍스트에 하나의 스토어를 사용하는 경우에는 특별한 훅을 사용할 수 있다.

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

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

위의 useRootData 훅을 사용해서 이전에 만들어둔 city 코드를 개선해보자!

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;

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;

추가 정리

useLocalStore의 역할?

https://mobx-react.js.org/state-local

useLocalStore<T, S>(initializer: () => T, source?: S): T

Local observable state can be introduced by using the useLocalStore hook, that runs its initializer function once to create an observable store and keeps it around for a lifetime of a component.

All properties of the returned object will be made observable automatically, getters will be turned into computed properties, and methods will be bound to the store and apply mobx transactions automatically. If new class instances are returned from the initializer, they will be kept as is.

storeContext 필요한 이유?

...

profile
(~˘▾˘)~♫•*¨*•.¸¸♪ ❝ 쉽게만 살아가면 재미없어 빙고 .ᐟ ❞

0개의 댓글