Posted on

If you're using MobX and looking to go all out on React Hooks you'll be happy to know about mobx-react-lite which provides MobX bindings via hooks. Don't let the "lite" tag throw you off, though. Most of the features that it "lacks" are now baked into React.

However, it doesn't support store injection anymore, for good reasons. But we still need a way to get our stores into our components, which we will explore through the article.

Setup

The easiest way to get started is using create-react-app which includes TypeScript support:

npx create-react-app mobx-sample --typescript

We then add our dependencies:

yarn add mobx mobx-react-lite
# or
npm install --save mobx mobx-react-lite

For reference, here's the relevant part of my package.json:

"@types/react": "16.8.19",
"@types/react-dom": "16.8.4",
"mobx": "^5.10.0",
"mobx-react-lite": "^1.4.0",
"react": "^16.8.6",
"react-dom": "^16.8.6",
"react-scripts": "3.0.1",
"typescript": "3.5.1"

Preparing an example app

Let's implement a very trivial app which lists some known cities:

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;

Now we should create a store to hold our data:

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>

We can use React.Context to hold our store instance, and later pass to consumers down the tree:

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={store}>
      {children}
    </storeContext.Provider>
  );
};

export default StoreProvider;

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;

Each component is now place, but we are rendering a hardcoded empty list. Let's connect the tsx›<CityList /> component to our store. We will access the store instance in tsx›<StoreProvider /> via the React.useContext hook:

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;

Now, rendering every available city^ha! all 3 of them!^ is bad UX. Let's add a search box so the user can find the relevant city quickly:

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

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;

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

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;

Woah, it works! but the store access code is duplicated across our components...


Reusing code

Let's write a hook that we can use across different components. The ideal hook would be:

  1. Useable (cuts down the repeated code in most cases)
  2. Generic (i.e. not bound to a certain context or store)
  3. Typesafe

We will start with a basic one:

import React from 'react';

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

Usable
We still have to wrap our code with useObserver everytime we use this hook.

Generic
While it does indeed accept any context, it assumes that the only value within the context is our store, which is not always the case.

Typesafe 👍
TypeScript will complain if you try to access a non-existing attribute on the returned value from the hook.

Okay, lets see how can we improve on this:

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

Usable
While it does indeed wrap our code with useObserver, wrapping the whole store means the component tree using this hook will re-render when any property in the store changes. Even if it's not used by the calling code.

Generic 👍
We can select any part of the context to be our store, and it's not bound by a certain store type.

Typesafe 👍
TypeScript will complain if you try to access a non-existing attribute on the returned value from the hook.

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 👍
Only the attributes we select via dataSelector() will be observed.

Generic 👍
We can select any part of the context to be our store, and it's not bound by a certain store type.

Typesafe 👍
TypeScript will complain if you try to access a non-existing attribute on the returned value from the hook.

For the trivial case where you only 1 store per context, you can have a specialized hook:

src/hook.ts

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

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

Whew, back to our example. We can use the useRootData hook to improve the code:

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;


Wrapping up

React Hooks is a big step into simplifying your components code. It also doesn't suffer from ambiguity like HOCs do. Consider this inject() example here:

const NameDisplayer = ({ name }) => <h1>{name}</h1>

const UserNameDisplayer = inject(stores => ({
  name: stores.userStore.name,
}))(NameDisplayer)

const App = () => <UserNameDisplayer name="Is this name used? Who knows." />

Which now can be re-written to:

const NameDisplayer = ({ name }) => <h1>{name}</h1>

const UserNameDisplayer = () => {
  const username = useRootData(store => store.name);

  return <NameDisplayer name={username} />
}

const App = () => <UserNameDisplayer />;

or

const NameDisplayer = ({ name }) => <h1>{name}</h1>

const UserNameDisplayer = ({ name }) => {
  const username = name || useRootData(store => store.name);

  return <NameDisplayer name={username} />
}

const App = () => <UserNameDisplayer name="disregard your stored value" />;

The point is there's no guesswork here. It lets you make choice upfront, do you want to accept a name prop, or not ? That's a lot better than guessing what gets injected where, and who overrides what behind the scenes.

Coupled with using TypeScript's generics (and the excellent type inference!) it makes for a very enjoyable experience. You get IDE autocompletion and of course the complaints when you access non-existing properties in your store.