mobx
November 6, 2019

Безопасная типизация MobX в React.js

Одна из первых проблем, которая может возникнуть при использовании связки React.js + MobX + TypeScript - это типизация пропсов компонентов, которые подключены к MobX сторам с помощью функции inject. Рассмотрим различные варианты типизации пропсов, их достоинства и недостатки. Статья рассчитана на тех, кто имеет опыт работы с React и TypeScript, но только знакомится с MobX.

Например, у нас есть стор, отвечающий за состояние плеера в приложении:

class PlayerStore {
  @observable isPlaying = false;
}

Компонент плеера на JavaScript может выглядеть так:

@inject('playerStore')
@observer
class Player extends Component {
  render() {
    const { isPlaying } = this.props.playerStore;
    return <div>{isPlaying ? 'Playing' : 'Paused'}</div>
  }
}

Для описания пропсов в TypeScript попробуем явно указать тип стора:

type Props = {
  playerStore: PlayerStore;
}

@inject('playerStore')
@observer
class Player extends Component<Props> {
  render() {
    const { isPlaying } = this.props.playerStore;
    return <div>{isPlaying ? 'Playing' : 'Paused'}</div>
  }
}

Однако теперь при вызове <Player/> возникнет ошибка компиляции: playerStore не был передан как пропс, TypeScript ничего не знает о том, что инъекцию выполняет декоратор inject

Для решения проблемы можем воспользоваться различными способами.

Способ 1. Декоратор inject и Non-Null Assertion оператор

Помечаем playerStore как опциональный и глушим ошибку c помощью Non-Null Assertion оператора:

type Props = {
  playerStore?: PlayerStore;
}

@inject('playerStore')
@observer
class Player extends Component<Props> {
  render() {
    const playerStore = this.props.playerStore!;
    return <div>{playerStore.isPlaying ? 'Playing' : 'Paused'}</div>
  }
}

Оператор ! исключил null и undefined из типа PlayerStore | undefined, поэтому компилятор не будет просить нас проверить this.props.playerStore на undefined. Стоит отметить, что этот оператор лучше избегать, так как из-за него теряется польза строгой проверки на null и вероятность получить ошибку в рантайме увеличивается.

Способ 2. Декоратор inject и метод для получения injected props

Разделяем интерфейсы пропсов на внешние (которые нужно передать из вне) и внутренние (переданные с помощью функции inject):

type Props = {
  someProp: string;
}

type InjectedProps = Props & {
  playerStore?: PlayerStore;
}

@inject('playerStore')
@observer
class Player extends Component<Props> {
  get injected() {
    return this.props as InjectedProps;
  }

  render() {
    const { playerStore } = this.injected;
    return <div>{playerStore.isPlaying ? 'Playing' : 'Paused'}</div>
  }
}

Очередной способ обмануть компилятор, требующий шаблонного кода для каждого компонента, подключенного к стору.

Способ 3. Функция compose из пакета recompose

Такой подход часто используют для типизации компонентов, использующих Redux:

type InjectedProps = {
  playerStore?: PlayerStore;
}

type OwnProps = {
  someProp: string;
}

type Props = InjectedProps & OwnProps;

class Player extends Component<Props> {
  get injected() {
    return this.props as InjectedProps;
  }

  render() {
    const { playerStore } = this.props;
    return <div>{playerStore.isPlaying ? 'Playing' : 'Paused'}</div>
  }
}

export default compose<Props, OwnProps>(
  inject('playerStore'),
  observer,
)(Player)

Функция compose позволяет вызывать hoc1(hoc2(hoc3(Component)))) как compose(hoc1, hoc2, hoc3)(Component) и переопределить тип пропсов, которые компонент выставляет наружу. Из преимуществ этого подхода - код компонента ничего не знает про MobX, из недостатков - много шаблонного кода.

Способ 4. Хуки + mobx-react-lite

Если версия Реакта в вашем приложении позволяет использовать хуки, то самым удобным способом будет использование mobx-react-lite. Этот пакет - официальная легковесная альтернатива mobx-react, основанная на хуках, которая в будущем заменит mobx-react. В пакете mobx-react-lite нет функции inject, вместо неё рекомендуется использовать встроенный в реакт Context API, ставший стабильным в версии 16.3

import { useContext, createContext } from 'react';
import { observer } from 'mobx-react-lite';

export const PlayerStoreContext = createContext(new PlayerStore());

...

const Player = observer(() => {
  const playerStore = useContext(PlayerStoreContext);
  return <div>{playerStore.isPlaying ? 'Playing' : 'Paused'}</div>
})

В больших приложениях распространённой практикой является создание корневого стора, имеющего доступ ко всем остальным сторам:

class RootStore {
  playerStore = new PlayerStore();
  userStore = new UserStore();
}

Благодаря этому можно создать контекст для RootStore и избавиться от необходимости создавать отдельный контекст для каждого стора:

import { useContext, createContext } from 'react';
import { observer } from 'mobx-react-lite';

export const RootStoreContext = createContext(new RootStore());

...

const Player = observer(() => {
  const { playerStore } = useContext(RootStoreContext);
  return <div>{playerStore.isPlaying ? 'Playing' : 'Paused'}</div>
})

Для упрощения импортов удобно использовать простую функцию, скрывающую способ получения корневого стора:

const rootStore = new RootStore();

export const useStore = () => React.useContext(rootStore);

const Player = observer(() => {
  const { playerStore } = useStore();
  return <div>{playerStore.isPlaying ? 'Playing' : 'Paused'}</div>
})

Итог

Подход с хуками наиболее лаконичный и безопасный с точки зрения типов. Можно порадоваться тому, что типизировать React приложение тайпскриптом становится всё проще и хуки этому очень поспособствовали. Пример репозитория, в котором используется вышеописанный подход: https://github.com/kubk/react-mobx-starter
Вступайте в телеграм-группу, посвящённую Mobx: https://t.me/mobxjs_ru