Перейти к основному содержимому

Все сценарии потери реактивности

Компонент не обновляется после изменения стейта? Эта глава призвана разобрать все возможные сценарии потери реактивности, поэтому тут вы точно должны найти ваш случай. Часть проблем больше связана с особенностями JS, нежели с Mobx.

1. Компонент не observer

Для работы реактивности компоненты должны подписываться на изменения и отписываться от них. Mobx не требует ручных подписок как RxJS, не требует использования хуков вроде useSelector для извлечения отдельных частей стейта. С Mobx достаточно оборачивать компоненты в observer. Взамен вам не нужно думать про вложенность, мемоизацию, селекторы и подписки. Ещё использование observer не более затратно чем использование React.memo, который вы будете вынуждены использовать для достижения такого же уровня производительности. Разберём примеры:

❌ Не будет работать, отсутствует подписка:

class Store {
count = 0

constructor() {
makeAutoObservable(this)
}

increment() {
this.count++
}
}

const store = new Store()

const Counter = () => {
return <button onClick={() => store.increment()}>Clicked times: {store.count}</button>
}

✅ Работает, так как есть observer:

class Store {
count = 0

constructor() {
makeAutoObservable(this)
}

increment() {
this.count++
}
}

const store = new Store()

// Добавлен observer 👇
const Counter = observer(() => {
return <button onClick={() => store.increment()}>Clicked times: {store.count}</button>
})

Если вам интересно как внутри устроен механизм подписок Mobx, то это в упрощённом виде описано в главе Mobx в 50 строчек кода. У Mobx есть ESLint-правило , проверяющее, что все компоненты обёрнуты в observer.

2. Потерян this

❌ У класса есть observer, а компонент не перерисовывается из-за потери this:

class Store {
count = 0

constructor() {
makeAutoObservable(this)
}

increment() {
this.count++
}
}

const store = new Store()

const Counter = observer(() => {
// Изменилась запись обработчик клика 👇
return <button onClick={store.increment}>Clicked times: {store.count}</button>
})

This потерялся из-за передачи метода стора в обработчик клика напрямую. Нередко у разработчиков возникают сложности с this, однако если знать простое правило, то проблем с this не возникнет. Достаточно знать, что this у методов теряется, если вызывать метод отдельно от объекта. Разберём это правило: вызов метода осуществляется через парные скобки, а отдельно от объекта значит, что при вызове мы уже не пишем объект. Таблица с примерами:

КодОбъяснениеРезультат
<button onClick={() => store.increment()}>Вызов функции с помощью скобок не отделён от объектаНет потери this
const increment = store.increment

<button onClick={increment}>
Вызов функции отделён от объектаПотерян this
<button onClick={store.increment}>Вызов функции отделён от объекта. Это то же самое, что и пример выше, но в укороченной формеПотерян this
const { increment } = store

<button onClick={increment}>
Вызов функции отделён от объектаПотерян this

Однако есть способы исправить все эти примеры.

✅ Опция autoBind в makeAutoObservable:

class Store {
count = 0

constructor() {
// Добавили 1 раз опцию на весь класс 👇
makeAutoObservable(this, {}, { autoBind: true })
}

increment() {
this.count++
}
}

const store = new Store()

const Counter = observer(() => {
// Можем вызывать метод отдельно от объекта или использовать деструктуризацию
return <button onClick={store.increment}>Clicked times: {store.count}</button>
})

Эта опция автоматически привязывает this для всех методов класса.

✅ Методы-стрелочные функции:

class Store {
count = 0

constructor() {
makeAutoObservable(this)
}

// Для каждого метода используем стрелочные функции
increment = () => {
this.count++
}
}

const store = new Store()

const Counter = observer(() => {
return <button onClick={store.increment}>Clicked times: {store.count}</button>
})

Плюсом этого подхода является независимость от Mobx, недостатком - необходимость использовать его во всех методах. Если вас интересует более академическое объяснение того как работает this, то с ним можно ознакомиться на сайте learn.javascript.ru.

3. Вложенные observable и сторонние не observer компоненты

Проблема часто возникает при использовании сторонних UI-китов. Например, вы используете компонент Table из UI-кита Ant Design. Этот компонент в качестве prop принимает массив объектов. Так как компонент Table не является observer, то он не умеет подписываться на изменения объектов внутри массива, например store.users.isActive = true. Компонент Table не такой умный как observer-компоненты, поэтому ему для перерисовки нужно, чтобы ссылка на массив с объектами изменилась. Похожая проблема может встречаться у компонента FlatList из React Native.

❌ Сторонний компонент не перерисовывается, так как он не observer, а потому не отслеживает изменения вложенных полей:

type User = {
id: number;
isActive: boolean;
}

class Store {
users: User[] = [
{ id: 1, isActive: false },
{ id: 2, isActive: false },
]

constructor() {
makeAutoObservable(this, {}, { autoBind: true })
}

markActive(user: User) {
user.isActive = true
}
}

const store = new Store()

const Component = observer(() => {
return <VendorTable
data={store.users}
columns={[
{
label: 'Mark active',
render: (user) => {
return <Switcher onClick={() => store.markActive(user.id)} />
}
}
]}
/>
})

Выходов есть несколько.

✅ toJS

Используем toJS для конвертации observable значений в чистый JS объект или массив, что даёт новую ссылку на каждый рендер:

import { toJS } from 'mobx'

type User = {
// ...
}

class Store {
// ...
}

const store = new Store()

const Component = observer(() => {
return <VendorTable
// Добавлен toJS 👇
data={toJS(store.users)}
columns={[
// ...
]}
/>
})

✅ Пишем собственный компонент Table, обёрнутый в observer

import { toJS } from 'mobx'

type User = {
// ...
}

class Store {
// ...
}

const store = new Store()

const Component = observer(() => {
return <MyTable
data={store.users}
columns={[
// ...
]}
/>
})

Написание собственного компонента таблицы может окупить себя на длинной дистанции в сложном проекте. Таблицы это комплексные компоненты, часто требующие самостоятельной разработки. Вот лишь малый список того, что вас могут попросить сделать: массовое редактирование строк, иерархические таблицы, возможность сворачивать/разворачивать строки по высоте, синхронизация с URL, бесконечный скроллинг или полностью кастомный дизайн, что может быть затруднительно при использовании UI-китов вроде Ant Design. В таком случае можно написать собственный компонент Table с использованием observer для поддержки реактивности.

4. Render-props

Эта проблема может возникнуть с компонентами, у которых props являются функциями. В документации React этому дано название render prop. После появления хуков такие ситуации встречаются всё реже, но давайте рассмотрим одну из них на примере React Final Form:

❌ Компонент не перерисовывается при изменении store.languages:

import { Form } from 'react-final-form'

const MyForm = observer(() => {
return (
<Form
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<Dropdown
label={'language'}
options={store.languages}
value={store.language}
onChange={store.changeLanguage}
/>
</form>)}
/>
)
})

В этом примере MyForm это observer, но функция, передающаяся как проп render, им не является, а значит она не может выполнить подписку на observable значения. Как следствие, даже если поле store.languages обновится после загрузки данных с сервера, то компонент всё равно не перерисуется. Есть несколько вариантов решений.

✅ Используем Observer из mobx-react-lite/mobx-react

import { Form } from 'react-final-form'
import { Observer } from 'mobx-react-lite'

const MyForm = observer(() => {
return (
<Form
render={({ handleSubmit }) => (
<Observer>
{() => <form onSubmit={handleSubmit}>
<Dropdown
label={'language'}
options={store.languages}
value={store.language}
onChange={store.changeLanguage}
/>
</form>}
</Observer>)}
/>
)
})

Этот компонент доступен как в mobx-react-lite, так и в mobx-react (так как второй экспортирует первый).

✅ Отказываемся от render props в пользу хуков:

import { Observer } from 'mobx-react-lite'
import { useForm } from 'react-final-form-hooks'

const MyForm = observer(() => {
const { handleSubmit } = useForm();

return (
<form onSubmit={handleSubmit}>
<Dropdown
label={'language'}
options={store.languages}
value={store.language}
onChange={store.changeLanguage}
/>
</form>
)
})

Мы рассмотрели основные сценарии потери реактивности. Если освоить инструмент, который вы используете, то проблем, в общем случае, возникать не должно, так как все вышеперечисленные проблемы (кроме this) об одном и том же - отсутствие подписки.

5. Устаревшая конфигурация бандлера

Если используется устаревшая конфигурация Babel / TypeScript, то makeAutoObservable может игнорировать неинициализированные поля:

class Store {
count?: number

constructor() {
makeAutoObservable(this)
}
}

В таком случае изменения поля count не будет вызывать перерисовку компонента. Для проверки бандлера используйте следующий скрипт, который нужно добавить в код приложения:

if (!new class { x }().hasOwnProperty('x')) throw new Error('Transpiler is not configured correctly');

Обратите внимание, что скрипт нужно вставить в код, а не в консоль браузера. А вот если после перезапуска кода в консоли бразуера видно исключение, то есть разные способы решения проблемы:

✅ TypeScript

В tsconfig.json добавьте параметр useDefineForClassFields: true:

{
"compilerOptions": {
"useDefineForClassFields": true
}
}

Либо проверьте значение target в секции compilerOptions. Должно быть указано ESNext или ES2022 (и выше)

✅ Babel
    // Babel < 7.13.0
"plugins": [["@babel/plugin-proposal-class-properties", { "loose": false }]],

// Babel >= 7.13.0 (https://babeljs.io/docs/en/assumptions)
"plugins": [["@babel/plugin-proposal-class-properties"]],
"assumptions": {
"setPublicClassFields": false
}
}

Минимальная версия Babel должна быть 7.12.

✅ Инициализация значения

Если никакие другие способы не помогли, то инициализируйте поле в конструкторе:

class Store {
count?: number = undefined // Или count = 0

constructor() {
makeAutoObservable(this)
}
}

Безопасно ли менять настройки бандлера?

Да. Классовые поля появились в TS и Babel до стандартизации в ES. useDefineForClassFields: true исправляет неконсистентность со стороны TS. С опцией useDefineForClassFields для вышеописанного класса TypeScript сгенерирует следующий JS:

class Store {
count
}

Без этой опции поле будет отсутствовать:

class Store {
}

makeAutoObservable не может пометить несуществующее поле observable, поэтому компонент не будет реагировать на изменения.