Безопасная типизация 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