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