Доступ к состоянию
Существуют различные способы предоставить компонентам доступ к MobX сторам - через синглтон (экспорт из модуля) или React Context.
Синглтон
Движок JS позволяет создать синглтон - объект, существующий в единственном экземпляре на всё приложение. Экземпляр доступен из любого места приложения и предоставляет общий доступ к состоянию для всех компонентов, где будет использоваться. Пример:
// Пример стора, отвечающего за отображение баннера на сайте
class BannerStore {
isOpen = false
constructor() {
makeAutoObservable(this)
}
open() {
this.isOpen = true
}
close() {
this.isOpen = false
}
}
export const bannerStore = new BannerStore()
Подключаем его к компоненту:
import { bannerStore } from "./banner-store";
export const Page = observer(() => {
return (
<div>
{bannerStore.isOpen && <Banner />}
...
</div>
);
})
Это самый простой способ, но у него есть ряд недостатков:
SSR
Если запустить сервер на Node.js, то каждый модуль будет проинициализирован ровно один раз. Если создать синглтон с помощью экспорта из модуля, то состояние стора будет общим для всех пользователей одновременно. Стор создаётся один раз на время жизни сервера и состояние стора становится общим для всех запросов. Пример:
-- Запрос 1 - Алиса запрашивает главную страницу
-- на сервере Алиса проходит аутентификацию.
-- В AuthStore флаг isLoggedIn меняется на true, а observable поле user меняется на { name: 'Alice' }
-- Запрос 2 - Анонимный пользователь запрашивает главную страницу
-- сервер отдаёт страницу с залогиненным пользователем Алисой
Это неправильное поведение. Поэтому в SSR-окружении сторы должны создаваться на каждый запрос. Подробнее работа с SSR рассмотрена в отдельной главе.
Тестирование
Так как стор является синглтоном, то его состояние будет общим для всех тестовых сценариев. Например, вы хотите протестировать React-компонент, использующий BannerStore. Пример сценария - баннер по умолчанию закрыт, но пользователь может его открыть после нажатия на кнопку.
describe("Page", () => {
it("allows to open banner", () => {
const wrapper = mount(<Page />);
expect(wrapper.text()).not.toContain("Banner");
wrapper.find("button").simulate("click");
expect(wrapper.text()).toContain("Banner");
});
});
Но если мы хотим написать ещё один сценарий возникнет неожиданная проблема - после рендера баннер будет открыт. Он остался открытым после предыдущего теста. Хорошая практика при написании тестов - каждый тест должен быть независимым. Тесты не должны влиять друг на друга, это позволит быстрее обнаружить проблему когда тесты упадут и нужно будет выяснять причину. Для того чтобы в каждом теста предусловия были одинаковые мы можем добавить метод reset
в BannerStore:
class BannerStore {
...
+ reset() {
+ this.isOpen = false
+ }
}
Этот метод можно вызывать в начале каждого теста:
describe('Page', () => {
+ beforeEach(() => {
+ bannerStore.reset()
+ })
...
Но в таком случае повышается риск человеческой ошибки. У каждого стора должен быть метод reset
, а в этот метод каждый раз нужно будет добавлять новые поля для восстановления изначального значения.
Не особенность Mobx
Данная проблема не специфична для Mobx или классов. В JS модули могут быть stateful, то есть иметь состояние. Простой пример stateful модуля:
export let count = 0
export const increase = () => count++
Теперь значение переменной count
будет общим для всех файлов, которые импортируют эту переменную.
React Context API
Рекомендуемый способ связывать MobX и React - Context API. Контекст - это встроенный в React механизм для передачи данных через дерево компонентов без необходимости передавать пропсы на промежуточных уровнях. Пример использования:
Шаг 1. Создаём контекст:
import { createContext } from "react";
export const UserThemeContext = createContext<"light" | "dark" | null>(null)
Шаг 2. Инициализируем контекст:
const Page = () => {
return <UserThemeContext.Provider value={isDarkMode() ? 'dark' : 'light'}>
<Child />
</UserThemeContext.Provider>
}
Шаг 3. Используем значение из контекста:
const Child = () => {
const theme = useContext(UserThemeContext)
// Теперь у переменной theme значение 'dark' | 'light' | null
};
На основании этого подхода можем создать контекст для всех сторов в нашем приложении.
Шаг 1 - Создаём стор, в котором будут находиться все глобальные сторы:
// root-store.ts
export class RootStore {
bannerStore = new BannerStore()
authStore = new AuthStore()
}
Шаг 2 - Создаём контекст и хук для его использования:
// root-store-context.ts
import { RootStore } from "./root-store"
export const RootStoreContext = createContext<RootStore | null>(null)
export const useStore = () => {
const context = useContext(RootStoreContext);
if (context === null) {
throw new Error(
"You have forgotten to wrap your root component with RootStoreProvider"
);
}
return context;
};
Тут мы добавили проверку на null
. Тип контекста указан как RootStore | null
, поэтому если убрать if
, то во всех
местах где будет использоваться стор нужно добавлять проверку на null, чтобы избежать ошибки компиляции TypeScript. У
контекста может быть значение null, если разработчик забыл передать контексту value
, поэтому эту ошибку лучше
обработать.
Шаг 4 - Инициализация стора:
// app.tsx
import { RootStoreContext } from "./root-store-context"
import { RootStore } from "./root-store"
const App = () => {
return (
<RootStoreContext.Provider value={new RootStore()}>
<Child />
</RootStoreContext.Provider>
);
};
Шаг 5 - Получаем доступ к сторам:
const Child = observer(() => {
const { authStore, bannerStore } = useStore()
//
});
При использовании useStore
между фигурных скобок работает автодополнение сторов благодаря TypeScript.
Зачем MobX если есть контекст?
React Context API - это не стейт-менеджер, а транспорт данных для компонентов без использования пропсов. Поэтому контекст не обладает удобствами MobX, такими как мемоизация и лаконичная работа с вложенными структурами данных. Контекст непроизводительный, продемонстрируем это на простом примере. Допустим, в системе есть 2 компонента - форма редактирования профиля и компонент, отображающий аватар и имя текущего пользователя.
const Avatar = () => {
const { avatar, userName } = useContext(UserContext)
// Рендерим только avatar и userName
};
const UserForm = () => {
const { avatar, userName, age, dateOfBirth, changeProfile } =
useContext(UserContext)
// Рендерим форму редактирования пользователя и вызываем changeProfile при сохранении данных
};
У такого подхода есть проблема. Компонент Avatar
будет перерисовываться на каждое изменение данных в контексте. Даже
если поля avatar и userName не поменялись. Контекст не умеет отслеживать точечные изменения. Если данные контекста
используются во многих местах на странице или же контекст хранит много данных, то производительность приложения
снизится. Реактивная библиотека MobX может отслеживать точечные изменения, что предотвратит ухудшение
производительности. Пример:
const Avatar = observer(() => {
const { authStore } = useStore()
// Рендерим только avatar и userName
});
const UserForm = observer(() => {
const { authStore } = useStore()
// Рендерим форму редактирования пользователя и вызываем changeProfile при сохранении данных
});
Если контекст непроизводительный, то ухудшает ли он производительность в связке с MobX?
Нет. Контекст приводит к лишним перерисовкам компонентов только если значение контекста меняется. Точнее - если объект внутри контекста пересоздаётся. Сторы MobX внутри контекста не будут пересоздаваться, ссылки на сторы будут оставаться прежними на протяжении всей жизни приложения, а потому их изменение не будет приводить к нежелательным перерисовкам компонентов.
Вывод
Мы рассмотрели разные способы связывания React-компонентов и Mobx-сторов. Синглтон - самый простой способ. Если приложению нужен SSR, то нужно использовать - React Context.