@astral/mobx-query
v1.19.2
Published
Библиотека для кеширования запросов.
Downloads
984
Readme
@astral/mobx-query
Библиотека для кеширования запросов.
Особенности:
- ⚡️️️️ Реактивный кэш на основе mobx
- ️️️️️️⚡️️️️ Вдохновлено @tanstack/react-query
- ⚡️️️️ Декларативный способ описания queries и mutations
- ⚡️️️️ Реализация архитектурного подхода работы с данными Astral Architecture Guide
- ⚡️️️️ Фоновая подгрузка данных для работы с WebSocket
- ⚡️️️️ Возможность тестирования
Table of contents
- Installation
- Basic usage
- Core concepts
- QuerySet
- InfiniteQuerySet
- Создание InfiniteQuerySet
- Использование InfiniteQuerySet
- Загрузка данных
- Статус дозагрузки fetchMore
- Старт не с начала списка
- Запоминание последней успешной позиции
- Обратная дозагрузка данных
- Курсорная пагинация
- isEndReached. Определение конца списка
- isStartReached. Определение начала списка
- Изменение количества запрашиваемых записей
- MutationSet
- Кэширование QuerySet и InfiniteQuerySet
- Фоновая загрузка данных QuerySet и InfiniteQuerySet
- Изменение кэша QuerySet и InfiniteQuerySet
- Тестирование
- Core
Installation
npm install @astral/mobx-query --saveyarn add @astral/mobx-queryBasic usage
├── api/
| ├── _fakers/
| ├── endpoints/
| ├── fetchers/
| | ├── docs.ts
| | └── index.ts
| ├── services/
| | ├── CacheService/
| | | ├── CacheService.ts
| | | └── index.ts
| | └── index.ts
| └── index.ts Инициализация MobxQuery:
api/services/CacheService/CacheService.ts
import { MobxQuery } from '@astral/mobx-query';
// рекомендуется явно задавать параметры для MobXQuery
export const createCacheService = () =>
new MobxQuery({ enableAutoFetch: true, fetchPolicy: 'cache-first' });
export const cacheService = createCacheService();Определение fetcher и cacheGroups для docs:
api/services/Fetcher/docs.ts
const docsFetcher = {
queries: {
doc: cacheService.createQuerySet((id: string) => ({
execute: () => docsEndpoints.getDoc(id).then(({ data }) => data),
})),
},
infiniteQueries: {
docList: cacheService.createInfiniteQuerySet(
(filters: Omit<DocsDTO.DocListFilters, 'offset' | 'count'>) => ({
execute: ({ offset, count }) =>
docsEndpoint
.getDocList({ offset, count, ...filters })
.then(({ data }) => data.list),
}),
),
},
mutations: {
editDoc: cacheService.createMutationSet(
(params: DocsDTO.EditDocInput) => docsEndpoints.editDoc(params),
),
},
};
export type DocsFetcher = typeof docsFetcher;Использование в store:
import { type DocsFetcher } from '@example/api';
class DocStore {
constructor(
private readonly _docID: string,
private readonly _docsFetcher: DocsFetcher,
) {
makeAutoObservable(this, {}, { autoBind: true });
}
private get docQuery() {
return this._docsFetcher.queries.doc.create(this.docID);
}
public get docName() {
if (!this.docQuery.data) {
return '';
}
return `Название документа: ${this.docQuery.data.name}`;
}
public get isLoading() {
return this.docQuery.isLoading;
}
public get status() {
return this.docQuery.status;
}
}class DocManagerStore {
constructor(private readonly _docsFetcher: DocsFetcher) {}
public changeOrg = () => {
// инвалидирует все doc query, которые есть в кэше
this._docsFetcher.queries.doc.invalidate();
};
}Core concepts
Query предназначен для получения данных. Не должен производить изменения.
QuerySet - набор queries для данных, получаемых по одной и той же сущности с разными параметрами.
InfiniteQuery предназначен для получения бесконечного списка данных.
Mutation предназначен для изменения данных на сервере.
MutationSet - набор mutations для изменения данных. Необходим для консистенстности api с QuerySet.
QuerySet, InfiniteQuerySet и MutationSet создаются через методы MobxQuery:
createQuerySetcreateInfiniteQuerySetcreateMutationSet
Query, InfiniteQuery и Mutation создаются через методы MobxQuery:
createQuerycreateInfiniteQuerycreateMutation
Рекомендуется использовать именно Set'ы потому что это выскоуровневое api, скрывающее внутри себя сложность работы с кэшем и позволяющее работать с данными более декларативно. Set'ы используют внутри себя Query, InfiniteQuery и Mutation.
Методы createQuery, createInfiniteQuery и createMutation необходимо использовать для реализации собственных библиотек для работы с данными.
QuerySet
Query предназначен для получения данных. Не должен производить изменения.
QuerySet - набор queries для данных, получаемых по одной и той же сущности с разными параметрами.
Создание QuerySet
В примере ниже будет создан doc query, который будет получать данные по id документа. После успешного выполнения запроса данные будут закэшированы.
const docsFetcher = {
queries: {
doc: mobxQuery.createQuerySet((id: string) => ({
execute: () => docsEndpoints.getDoc(id).then(({ data }) => data),
})),
},
};Использование QuerySet
Для получения объекта query необходимо вызвать метод create.
Параметры create полностью идентичны параметрам, указанным при определении query:
const docsFetcher = {
queries: {
doc: mobxQuery.createQuerySet((id: string) => ({
execute: () => docsEndpoints.getDoc(id).then(({ data }) => data),
})),
},
};
// create принимает только один параметр - id: string
const docQuery = docsFetcher.queries.doc.create('docID');Метод create вернет объект query, который содержит всю информацию по запросу и методы работы с запросом.
Синхронный вызов запроса данных
Метод sync позволяет синхронно запустить запрос на получение данных:
const docQuery = docsFetcher.queries.doc.create(docID);
// есть callbacks на обработку success и error
docQuery.sync({
onSuccess: (data) => {
console.log(data);
},
onError: (error) => {
console.log(error);
},
});
// true
docQuery.isLoading;Асинхронный вызов запроса данных
Метод async позволяет асинхронно запустить запрос на получение данных:
const docQuery = docsFetcher.queries.doc.create(docID);
// передавать параметры запроса не нужно потому что они уже были переданы при вызове init
const response = await docQuery.async();
docQuery.isSuccess; // true
docQuery.data; // идентичен responseАвтоматический запрос данных
Если MobxQuery был создан с флагом enableAutoFetch: true, то данные будут автоматически запрошены при обращении к полю data:
const docQuery = docsFetcher.queries.doc.create(docID);
docQuery.data; // триггер запроса данных
docQuery.isLoading; // trueЕсли MobxQuery был создан с флагом enableAutoFetch: false, то автоматически запрос данных можно включить для текущего query:
const docQuery = docsFetcher.queries.doc.createWithConfig((
{ enableAutoFetch: true },
docID
);
docQuery.data; // триггер запроса данных
docQuery.isLoading; // trueInfiniteQuerySet
InfiniteQuery предназначен для получения бесконечного списка данных.
InfiniteQuerySet - набор InfiniteQuery для данных, получаемых по одной и той же сущности с разными параметрами.
Создание InfiniteQuerySet
В примере ниже будет создан docList InfiniteQuerySet. После успешного выполнения запроса данные будут закэшированы.
const docsFetcher = {
infiniteQueries: {
docList: mobxQuery.createInfiniteQuerySet(
(filters: Omit<DocsDTO.DocListFilters, 'offset' | 'count'>) => ({
execute: ({ offset, count }) =>
docsEndpoint
.getDocList({ offset, count, ...filters })
.then(({ data }) => data.list),
}),
),
},
};Использование InfiniteQuerySet
Для получения объекта InfiniteQuery необходимо вызвать метод create.
Параметры create полностью идентичны параметрам, указанным при определении InfiniteQuery:
const docsFetcher = {
infiniteQueries: {
docList: mobxQuery.createInfiniteQuerySet(
// Omit необходим для того, чтобы не передавать offset и count при вызове запроса из логики. Offset и count будут сформированы и переданы автоматически
(filters: Omit<DocsDTO.DocListFilters, 'offset' | 'count'>) => ({
execute: ({ offset, count }) =>
docsEndpoint
.getDocList({ offset, count, ...filters })
// fetch должен возвращать array
.then(({ data }) => data.list),
}),
),
},
};
// первым параметром create является - filters: Omit<DocsDTO.DocListFilters, 'offset' | 'count'>
const docListQuery = docsFetcher.infiniteQueries.docList.create({ search: 'test' });Загрузка данных
import { when } from 'mobx';
const docListQuery = docsFetcher.infiniteQueries.docList.create({ search: 'test' });
await docListQuery.async(); // запрос на получение первых 30 записей
docListQuery.data.length; // 30 записей
docListQuery.fetchMore(); // запрос на получение следующих 30 записей
// ждем загрузки
await when(() => !query.isLoading);
docListQuery.data.length; // 60 записейСтатус дозагрузки fetchMore
fetchMoreStatus содержит отдельные статусы дозагрузки следующей страницы. Это позволяет отдельно отслеживать состояние fetchMore, не смешивая его с общим статусом InfiniteQuery.
import { when } from 'mobx';
const createDocListInfiniteQuery = (filters: Omit<DocsDTO.DocListFilters, 'offset' | 'count'>) =>
cacheService.createInfiniteQuery(['docList', filters],
(params: { offset: number; count: number }) => docsEndpoints.getDocList({ offset: params.offset, count: params.count, ...filters })
);
const docListQuery = createDocListInfiniteQuery({ search: 'test' });
await docListQuery.async();
docListQuery.fetchMore();
docListQuery.fetchMoreStatus.isLoading; // true
docListQuery.fetchMoreStatus.isSuccess; // false
docListQuery.fetchMoreStatus.isError; // false
await when(() => !docListQuery.fetchMoreStatus.isLoading);
docListQuery.fetchMoreStatus.isLoading; // false
docListQuery.fetchMoreStatus.isSuccess; // true
docListQuery.fetchMoreStatus.error; // undefinedЕсли fetchMore завершится ошибкой, fetchMoreStatus.isError станет true, а fetchMoreStatus.error будет содержать ошибку. Значение offset будет возвращено к предыдущему успешному состоянию, поэтому повторный вызов fetchMore повторит запрос той же страницы.
Старт не с начала списка
Для сценариев восстановления позиции списка можно передать initialOffset или initialCursor.
Эти значения используются только для самого первого запроса нового инстанса InfiniteQuery.
После invalidate() следующий запрос снова начнется с начала списка.
Это полезно, когда нужно восстановить список с определенной позиции, например после возврата на страницу в SPA или при открытии ссылки, в которой уже закодирована нужная точка входа в список.
Offset-пагинация
Если API использует смещение, передайте initialOffset.
const docListQuery = docsFetcher.infiniteQueries.docList.createWithConfig(
{
initialOffset: 120,
},
{ search: 'test' },
);
await docListQuery.async(); // первый запрос будет выполнен с offset=120
docListQuery.invalidate();
await docListQuery.async(); // после invalidate запрос снова начнется с offset=0Курсорная пагинация
Если API использует курсор, передайте initialCursor.
const docListQuery = docsFetcher.infiniteQueries.docList.createWithConfig(
{
initialCursor: { id: 'doc-120' },
},
{ search: 'test' },
);
await docListQuery.async(); // первый запрос будет выполнен с cursor={ id: 'doc-120' }
docListQuery.invalidate();
await docListQuery.async(); // после invalidate запрос снова начнется без курсораЗапоминание последней успешной позиции
await docListQuery.async(); // запрос на получение первых 30 записей
docListQuery.fetchMore({
onSuccess: ({ offset, cursor }) => {
savedOffset = offset; // запоминаем offset последней успешной дозагрузки
savedCursor = cursor; // запоминаем cursor последней успешной дозагрузки
},
});
docsFetcher.infiniteQueries.docList.createWithConfig(
{ search: 'test' },
{
initialOffset: savedOffset,
initialCursor: savedCursor,
},
); // новый инстанс продолжит загрузку с сохраненной позицииconst searchParams = new URLSearchParams(window.location.search);
await docListQuery.async();
docListQuery.fetchMore({
onSuccess: ({ offset, cursor }) => {
searchParams.set('offset', String(offset));
searchParams.set('cursorId', cursor.id);
window.history.replaceState(
null,
'',
`?${searchParams.toString()}`,
);
},
});
const restoredQuery = docsFetcher.infiniteQueries.docList.createWithConfig(
{
initialOffset: Number(searchParams.get('offset') ?? 0),
initialCursor: searchParams.get('cursorId')
? { id: searchParams.get('cursorId')! }
: undefined,
},
{ search: 'test' },
);Обратная дозагрузка данных
По умолчанию fetchMore() загружает следующую страницу и добавляет ее в конец списка.
Вызов fetchMore({ direction: 'backward' }) загружает предыдущую страницу и добавляет ее в начало списка.
const docListQuery = docsFetcher.infiniteQueries.docList.createWithConfig(
{ initialOffset: 60 },
{ search: 'test' },
);
await docListQuery.async(); // будут загружены записи начиная с offset=60
docListQuery.fetchMore({ direction: 'backward' }); // будет запрошена предыдущая страницаПри fetchMore({ direction: 'backward' }) библиотека уменьшает offset, передает в execute первый элемент текущего списка как cursor и указывает direction: 'backward'.
Курсорная пагинация
type Doc = {
id: string;
title: string;
};
const docsFetcher = {
infiniteQueries: {
docList: mobxQuery.createInfiniteQuerySet(
(filters: Omit<DocsDTO.DocListFilters, 'count'>) => ({
execute: ({ count, cursor, direction }) =>
docsEndpoint
.getDocList({
count,
cursorId: cursor?.id,
direction,
...filters,
})
.then(({ data }) => data.list as Doc[]),
}),
),
},
};
const docListQuery = docsFetcher.infiniteQueries.docList.createWithConfig(
{ initialCursor: { id: 'doc-120' } },
{ search: 'test' },
);
await docListQuery.async(); // при первом запросе execute получит initialCursor, если он задан
docListQuery.fetchMore(); // execute получит последний элемент списка и direction: 'forward'
docListQuery.fetchMore({ direction: 'backward' }); // execute получит первый элемент списка и direction: 'backward'Во всех остальных запросах cursor будет undefined, включая первый запрос без initialCursor и повторный запрос после invalidate().
isEndReached. Определение конца списка
Флаг isEndReached будет установлен в true, если записи для загрузки закончились:
import { when } from 'mobx';
const docListQuery = docsFetcher.infiniteQueries.docList.create({ search: 'test' });
await docListQuery.async(); // запрос на получение первых 30 записей
docListQuery.data.length; // 30 записей
docListQuery.fetchMore(); // запрос на получение следующих 30 записей
// ждем загрузки
await when(() => !query.isLoading);
docListQuery.data.length; // 19 записей. Последняя страница содержит 9 записей, а не 30, как было запрошено, значит больше данных нет
docListQuery.isEndReached; // trueФлаг isEndReached устанавливается в true, когда количество полученных элементов меньше запрошенного количества, что означает отсутствие дополнительных данных на сервере.
isStartReached. Определение начала списка
Флаг isStartReached будет установлен в true, если при fetchMore({ direction: 'backward' }) сервер вернул меньше элементов, чем было запрошено.
Это означает, что более ранних данных больше нет, и повторные вызовы fetchMore({ direction: 'backward' }) не будут запускать новые запросы.
Изменение количества запрашиваемых записей
Для изменения конфигурации InfiniteQuery необходимо использовать метод createWithConfig:
const docListQuery = docsFetcher.infiniteQueries.docList.createWithConfig(
{ incrementCount: 10, enabledAutoFetch: true },
{ search: 'test' },
);Интерфейс InfiniteQueryConfig.
MutationSet
Mutation предназначен для отправки данных на сервер.
MutationSet - набор mutations, изменяющих одну и ту же сущность. Предоставляет интерфейс, идентичный QuerySet, для консистенстности api.
Создание и использование MutationSet
В примере ниже будет создан editDoc mutation, который отправляет данные на сервер для редактирования документа:
const docsFetcher = {
mutations: {
editDoc: mobxQuery.createMutationSet((params: DocsDTO.EditDocInput) => docsEndpoints.editDoc(params)),
},
};
const editDocMutation = docsFetcher.mutations.editDoc.create();
// params являются параметрами, указанными при определении mutation - DocsDTO.EditDocInput
await editDocMutation.async({ params: { id: 'docID', name: 'test' } });Метод create вернет объект mutation, который содержит всю информацию по запросу и методы работы с запросом.
Синхронный вызов mutation
Метод sync позволяет синхронно запустить запрос:
const editDocMutation = docsFetcher.mutations.editDoc.create();
// есть callbacks на обработку success и error
editDocMutation.sync({
params: { id: 'docID', name: 'test' },
onSuccess: (data) => {
console.log(data);
},
onError: (error) => {
console.log(error);
},
});
// true
editDocMutation.isLoading;Асинхронный вызов mutation
Метод async позволяет асинхронно запустить запрос:
const editDocMutation = docsFetcher.mutations.editDoc.create();
await editDocMutation.async({ params: { id: 'docID', name: 'test' } });Best practices для MutationSet
Mutation должен создаваться в конструкторе класса, а не в getter
✅ Valid
class DocStore {
private readonly creationDocMutation;
public constructor(
private readonly _docsFetcher: DocsFetcher,
) {
this.creationDocMutation = this._docsFetcher.mutations.createDoc.create();
}
}❌ Invalid
class DocStore {
private get creationDocMutation() {
return this._docsFetcher.mutations.createDoc.create();
}
}Описанный кейс является антипаттерном потому что если на creationDocMutation больше не будет подписчиков, то при следующем обращении getter заново создаст mutation. Это может привести к нежелательным побочным эффектам.
Кэширование QuerySet и InfiniteQuerySet
QuerySet и InfiniteQuerySet позволяет закэшировать данные, которые были получены ранее.
FetchPolicy
FetchPolicy определяет политику получения данных.
Существует два типа политики:
cache-first- если в кэше есть данные, они будут возвращены, если нет, то данные будут получены из сети, после чего ответ будет записан в кэшnetwork-only- данные всегда берутся из сети, при этом ответ записывается в кэш
Глобальная установка fetchPolicy
Для глобальной установки конкретной политики fetchPolicy необходимо передать параметр fetchPolicy при создании MobxQuery:
import { MobxQuery } from 'mobx-query';
export const createQuery = () => new MobxQuery({ fetchPolicy: 'cache-first' });Локальная установка fetchPolicy
Для каждого отдельного query можно установить свою fetchPolicy при инициализации.
Пример для Query:
const docQuery = docsFetcher.queries.doc.createWithConfig(({ fetchPolicy: 'network-only' }, 'docID');Пример для InfiniteQuery:
const docListQuery = docsFetcher.infiniteQueries.docList.createWithConfig(
{ incrementCount: 10, fetchPolicy: 'network-only' },
{ search: 'test' },
);Принцип работы fetchPolicy
cache-first:
const docQuery = docsFetcher.queries.doc.createWithConfig(({ fetchPolicy: 'cache-first' }, 'docID');
await docQuery.async(); // запрос будет выполнен потому что до этого запроса с такими параметрами не было
await docQuery.async(); // запрос не будет выполнен потому что данные уже есть в кэше. Promise будет завершен сразуnetwork-only:
const docQuery = docsFetcher.queries.doc.createWithConfig(({ fetchPolicy: 'network-only' }, 'docID');
await docQuery.async(); // запрос будет выполнен
await docQuery.async(); // запрос будет выполнен
const docQueryWithCache = docsFetcher.queries.doc.createWithConfig(({ fetchPolicy: 'cache-first' }, 'docID');
await docQueryWithCache.async(); // запрос не будет выполнен потому что прежде данные были получены с политикой `network-only` и записаны в кэшДля InfiniteQuery параметры initialOffset и initialCursor позволяют при первом запросе нового инстанса начать загрузку с нужной позиции.
После invalidate() эти стартовые значения больше не применяются, и следующий запрос начинается с начала списка.
Как работает кэш
Все данные, возвращаемые QuerySet и InfiniteQuerySet, кэшируются в едином хранилище @astral/mobx-query.
При первом вызове create в хранилище создается запись с ключем, состоящим из:
- Хэш от функции конфигурации
queryParams
Пример:
const docFetcher = {
queries: {
doc: mobxQuery.createQuerySet((id: string, filters: { search: string }) => ({
execute: () => docsEndpoints.getDoc(id, filters),
})),
},
};
// ключ кэша будет равен "12fj1d,doc,1,"{"search":"test"}""
// 12fj1d - это хэш от функции конфигурации
const docQuery = docFetcher.queries.doc.create('1', { search: 'test' });
// ключ кэша будет равен "12fj1d,doc,2,"{"search":"test2"}""
const docQuery = docsFetcher.queries.doc.create('1', { search: 'test2' });Автоматическая чистка кэша
Mobx-query кэш организован через WeekRef, поэтому не используемые данные автоматически удаляются сборщиком мусора.
Инвалидация кэша QuerySet и InfiniteQuerySet
Если данные в query стали неактуальными, то необходимо вызвать метод invalidate:
// инвалидация всех query с именем `doc`
docsFetcher.queries.doc.invalidate();После вызова invalidate данные в кэше будут помечены как невалидные и при следующем обращении к query будет выполнен запрос на сервер. Если при вызове метода invalidate на изменения .data подписан любой store, то произойдет моментальный перезапрос активных query:
const docQuery = docsFetcher.queries.doc.create('docID');
await docQuery.async(); // данные записаны в кэш
docsFetcher.queries.doc.invalidate();
docQuery.data; // триггер запроса данных потому что они были помечены как невалидные
docQuery.isLoading; // trueИнвалидация всех query по их имени
Для инвалидации всех query по их имени можно вызвать метод invalidate:
const docQuery1 = docsFetcher.queries.doc.create('1');
const docQuery2 = docsFetcher.queries.doc.create('2');
docsFetcher.queries.doc.invalidate(); // данные docQuery1 и docQuery2 помечены как невалидныеИнвалидация query по частичному совпадению параметров
Если необходимо инвалидировать данные по конкретным параметрам, то их необходимо передать в метод invalidate:
const docsFetcher = {
queries: {
doc: mobxQuery.createQuerySet((id: string) => ({
execute: () => docsEndpoints.getDoc(id).then(({ data }) => data),
})),
},
};
docsFetcher.queries.doc.invalidate('1'); // документ с id = 1 будет помечен как невалидныйПараметры invalidate полностью совпадают с параметрами, описанными при определении query:
const docsFetcher = {
queries: {
doc: mobxQuery.createQuerySet((id: string, search: string, filters: Filters) => ({
execute: () => docsEndpoints.getDoc(id, search, filters),
})),
},
};
docsFetcher.queries.doc.invalidate('1', 'test', { sort: 'asc' });Инвалидация query по частичному совпадению параметра-объекта
const docsFetcher = {
queries: {
doc: mobxQuery.createQuerySet((id: string, filters: { sort: string; search: string }) => ({
execute: () => docsEndpoints.getDoc(id, filters),
})),
},
};
const query1 = docsFetcher.queries.doc.create('1', { sort: 'asc', search: 'test' });
const query2 = docsFetcher.queries.doc.create('1', { sort: 'desc', search: 'test' });
docsFetcher.queries.doc.invalidate('1', { sort: 'asc' }); // данные только query1 будут помечены как невалидныеКастомная установка ключей кэширования
При определении QuerySet и InfiniteQuerySet можно указать параметр keys и name, которые будут использоваться для формирования ключей кэша.
name- название набора. Будет использоваться вместо хэша от функции конфигурацииkeys- будут использоваться вместо параметров функции конфигурации
const docsFetcher = {
queries: {
doc: mobxQuery.createQuerySet(
(id: string, search: string) => ({
keys: [id],
execute: () => docsEndpoints.getDoc(id, search),
}),
{ name: 'doc' }
),
},
};
// ключ кэша будет равен "doc,1"
const query = docsFetcher.queries.doc.create('1', 'test');
docsFetcher.queries.doc.invalidate('1'); // данные query будут помечены как невалидные
docsFetcher.queries.doc.invalidate('1', 'test'); // второй параметр будет проигнорированФоновая загрузка данных QuerySet и InfiniteQuerySet
Для фонового обновления данных возможно использовать флаг isBackground для query и infiniteQuery.
Данный метод хорошо подходит для инвалидации данных по событиям из WebSocket.
Пример для QuerySet:
const docQuery = docsFetcher.queries.doc.createWithConfig(({ isBackground: true }, 'docID');
docQuery.sync(); // запрос будет выполнен в фоновом режиме
docQuery.isLoading; // true. Первый запрос изменит статусные флаги
await when(() => docQuery.isSuccess);
docsFetcher.queries.doc.invalidate();
docQuery.isLoading; // false. При этом запрос данных уже выполняется в фоновом режиме, если где-то есть подписчик на изменения `.data`
docQuery.background.isLoading; // trueИзменение кэша QuerySet и InfiniteQuerySet
Для изменения данных в кэше необходимо использовать forceUpdate, содержащийся в query:
const docQuery = docsFetcher.queries.doc.create('docID');
// в кэше будет записано "{ name: 'test' }", а статустные флаги перейдут в состояние 'success'
docQuery.forceUpdate({ name: 'test' });Для массового изменения требуется использовать метод forceUpdate предоставляемый QuerySet или InfiniteQuerySet.
Первым аргументом является метод для анализа, и изменения данных. Начиная со второго, аргументы идентичны тем, что вы используете при созданнии и инвалидации.
const queryA = docsFetcher.queries.docsList.create(userId);
const queryB = docsFetcher.queries.docsList.create(userId, { isDesc: true });
docsFetcher.queries.docsList.forceUpdate(
(currentData = []) => [...currentData, 'дополнительный элемент'], userId
);
console.log(queryA.data); // ['дополнительный элемент']
console.log(queryB.data); // ['дополнительный элемент']Синхронизация данных между вкладками
Для синхронизации данных между вкладками необходимо воспользоваться флагом enabledSynchronization при создании MobxQuery.
Тогда все query с совпадающими ключами на разных вкладках будут одновременно изменять свои данные
new MobxQuery({
enabledSynchronization: true,
});Для точечного управления синхронизацией, можно передавать флаг enabledSynchronization при создании query
docsFetcher.queries.document.createWithConfig({
enabledSynchronization: true,
})Последовательное получение данных
Для последовательного получения данных используйте флаг enabledAutoFetch.
private get fileQuery() {
const fileId = this._documentStore.data?.FileId;
return this._docsFetcher.queries.file.createWithConfig(
{
enabledAutoFetch: Boolean(fileId),
},
fileId,
);
}
public get file() {
return this.fileQuery.data; // пока enabledAutoFetch==false, запроса данных не будет
}Тестирование
Реализация mockFetcher (один раз в проекте)
Для мокинга QuerySet и InfiniteQuerySet необходимо использовать mock следующего вида:
type Fetcher = {
queries: Record<string, QuerySet<any[], any>>;
infiniteQueries: Record<InfiniteQuerySet<any[], any>>;
mutations: Record<MutationSet<any, any>>;
};
const mockFetcher = <TFetcher extends Fetcher>(config: DeepPartial<TFetcher>) => config as TFetcher;Не используйте vitest-mock-extended для мокинга Fetcher.
Причина: vitest-mock-extended нарушает работу Mobx.
1 тест - 1 mobx-query instance
import { createCacheService } from '#shared/api/services/CacheService';
const setup = () => {
const cache = createCacheService();
const booksFetcherMock = mockFetcher<BooksFetcher>({
queries: {
list: cache.createQuerySet(...),
},
});
}Mock Query, Mutation
Для определения queries, mutations использовать соответствующие методы mobxQuery:
const booksFetcherMock = mockFetcher<BooksFetcher>({
queries: {
bookList: cacheService.createQuerySet((filters) => ({
execute: async () => fakeBookList,
})),
},
mutations: {
createBook: cacheService.createMutationSet(async (book) => undefined),
}
});Mock InfiniteQuery
Для определения InfiniteQueries использовать createInfiniteQuerySet:
const booksFetcherMock = mockFetcher<BooksFetcher>({
infiniteQueries: {
bookList: cacheService.createInfiniteQuerySet(
(filters) => ({
execute: ({ offset, count }) => fakeBookList,
}),
),
}
});Ожидание|триггер запроса данных при enabledAutoFetch (дефолтное поведение)
import { when } from 'mobx';
const booksFetcherMock = mockFetcher<BooksFetcher>({
queries: {
bookList: cacheService.createQuerySet((filters) => ({
execute: async () => fakeBookList,
})),
},
});
const sut = new Store(booksFetcherMock);
// Обращаемся к data из query -> триггер запроса данных -> ожидаем когда загрузятся данные
await when(() => Boolean(sut.data));
expect(sut.status.isSuccess).toBe(true);Core
Концепции, описанные ниже являются ядром библиотеки и используются внутри QuerySet, InfiniteQuerySet и MutationSet.
Query
Query позволяет получать данные из API и кешировать их. Query не должны производить изменения.
Создание Query
В примере ниже будет создан doc query, который будет получать данные по id документа. После успешного выполнения запроса данные будут закэшированы.
const createDocQuery = (id: string) =>
// первый параметр - ключи, по которому в кэше будет храниться ответ запроса
cacheService.createQuery(['doc', id],
(params: { id: string }) => docsEndpoints.getDoc(params.id)
); Использование Query
const createDocQuery = (id: string) =>
// первый параметр - ключи, по которому в кэше будет храниться ответ запроса
cacheService.createQuery(['doc', id],
(params: { id: string }) => docsEndpoints.getDoc(params.id)
);
const docQuery = createDocQuery('docID');docQuery является объектом, который содержит всю информацию по запросу и методы работы с запросом:
Interface Query
export type Query<TResult = unknown, TError = unknown, TIsBackground = boolean> = {
/**
* Текущие данные запроса
*/
data: TResult | undefined;
/**
* Флаг загрузки
*/
isLoading: boolean;
/**
* Флаг успешного выполнения запроса
*/
isSuccess: boolean;
/**
* Флаг наличия ошибки
*/
isError: boolean;
/**
* Текущая ошибка
*/
error: TError | undefined;
/**
* Флаг, обозначающий простаивание, т.е. запроса еще не было
*/
isIdle: boolean;
status: {
isLoading: boolean;
isSuccess: boolean;
isError: boolean;
error: TError | undefined;
isIdle: boolean;
}
/**
* Синхронизирует данные с сервером
* @param onSuccess - Callback успешного выполнения
* @param onError - Callback ошибки
*/
sync: ({ onSuccess, onError }: SyncParams<TResult, TError>) => void;
/**
* Асинхронный метод получения данных
*/
async: () => Promise<TResult>;
/**
* Метод инвалидации текущего query
*/
invalidate: () => void;
// Статусы, изменяющиеся после первого успешного запроса в режиме фоновой загрузки isBackground: true
background: {
/**
* Флаг обозначающий загрузку данных в фоновом режиме
*/
isLoading: boolean;
/**
* Флаг обозначающий, что последний запрос был зафейлен в фоновом режиме
*/
isError: boolean;
/**
* Данные о последней ошибке в фоновом режиме
*/
error?: TError;
/**
* Флаг, обозначающий успешность завершения последнего запроса в фоновом режиме
*/
isSuccess: boolean;
};
};Синхронный вызов запроса данных
Метод sync позволяет синхронно запустить запрос на получение данных:
const createDocQuery = (id: string) =>
// первый параметр - ключи, по которому в кэше будет храниться ответ запроса
cacheService.createQuery(['doc', id],
(params: { id: string }) => docsEndpoints.getDoc(params.id)
);
const docQuery = createDocQuery('docID');
// есть callbacks на обработку success и error
docQuery.sync({
onSuccess: (data) => {
console.log(data);
},
onError: (error) => {
console.log(error);
},
});
// true
docQuery.isLoading;Асинхронный вызов запроса данных
Метод async позволяет асинхронно запустить запрос на получение данных:
const createDocQuery = (id: string) =>
// первый параметр - ключи, по которому в кэше будет храниться ответ запроса
cacheService.createQuery(['doc', id],
(params: { id: string }) => docsEndpoints.getDoc(params.id)
);
const docQuery = createDocQuery('docID');
// передавать параметры запроса не нужно потому что они уже были переданы при вызове init
const response = await docQuery.async();
docQuery.isSuccess; // true
docQuery.data; // идентичен responseАвтоматический запрос данных
Если MobxQuery был создан с флагом enableAutoFetch: true, то данные будут автоматически запрошены при обращении к полю data:
const cacheService = new MobxQuery({ enableAutoFetch: true });
const createDocQuery = (id: string) =>
// первый параметр - ключи, по которому в кэше будет храниться ответ запроса
cacheService.createQuery(['doc', id],
(params: { id: string }) => docsEndpoints.getDoc(params.id)
);
docQuery.data; // триггер запроса данных
docQuery.isLoading; // trueЕсли MobxQuery был создан с флагом enableAutoFetch: false, то автоматически запрос данных можно включить для текущего query:
const createDocQuery = (id: string) =>
cacheService.createQuery(['doc', id],
(params: { id: string }) => docsEndpoints.getDoc(params.id),
{ enableAutoFetch: true }
);
docQuery.data; // триггер запроса данных
docQuery.isLoading; // trueInfiniteQuery
InfiniteQuery предназначен для получения бесконечного списка данных.
Создание InfiniteQuery
В примере ниже будет создан docList InfiniteQuery. После успешного выполнения запроса данные будут закэшированы.
const createDocListInfiniteQuery = (filters: Omit<DocsDTO.DocListFilters, 'offset' | 'count'>) =>
cacheService.createInfiniteQuery(['docList', filters],
(params: { offset: number; count: number }) => docsEndpoints.getDocList({ offset: params.offset, count: params.count, ...filters })
);Использование InfiniteQuery
const createDocListInfiniteQuery = (filters: Omit<DocsDTO.DocListFilters, 'offset' | 'count'>) =>
cacheService.createInfiniteQuery(['docList', filters],
(params: { offset: number; count: number }) => docsEndpoints.getDocList({ offset: params.offset, count: params.count, ...filters })
);
// первым параметром create является - filters: Omit<DocsDTO.DocListFilters, 'offset' | 'count'>
const docListQuery = createDocListInfiniteQuery({ search: 'test' });Executor InfiniteQuery получает объект { offset, count, cursor, direction }.
Если нужна только offset-пагинация, можно использовать только offset и count.
cursor передается только при fetchMore() или при самом первом запросе, если задан initialCursor. В обычном первом запросе без initialCursor и после invalidate() значение cursor будет undefined.
Загрузка данных
import { when } from 'mobx';
const createDocListInfiniteQuery = (filters: Omit<DocsDTO.DocListFilters, 'offset' | 'count'>) =>
cacheService.createInfiniteQuery(['docList', filters],
(params: { offset: number; count: number }) => docsEndpoints.getDocList({ offset: params.offset, count: params.count, ...filters })
);
const docListQuery = createDocListInfiniteQuery({ search: 'test' });
await docListQuery.async(); // запрос на получение первых 30 записей
docListQuery.data.length; // 30 записей
docListQuery.fetchMore(); // запрос на получение следующих 30 записей
// ждем загрузки
await when(() => !query.isLoading);
docListQuery.data.length; // 60 записейСтатус дозагрузки fetchMore
Дополнительные возможности
- Старт не с начала списка
- Запоминание последней успешной позиции
- Обратная дозагрузка данных
- Курсорная пагинация
isEndReached. Определение конца списка
Флаг isEndReached будет установлен в true, если записи для загрузки закончились:
import { when } from 'mobx';
const createDocListInfiniteQuery = (filters: Omit<DocsDTO.DocListFilters, 'offset' | 'count'>) =>
cacheService.createInfiniteQuery(['docList', filters],
(params: { offset: number; count: number }) => docsEndpoints.getDocList({ offset: params.offset, count: params.count, ...filters })
);
const docListQuery = createDocListInfiniteQuery({ search: 'test' });
await docListQuery.async(); // запрос на получение первых 30 записей
docListQuery.data.length; // 30 записей
docListQuery.fetchMore(); // запрос на получение следующих 30 записей
// ждем загрузки
await when(() => !query.isLoading);
docListQuery.data.length; // 19 записей. Последняя страница содержит 9 записей, а не 30, как было запрошено, значит больше данных нет
docListQuery.isEndReached; // trueФлаг isEndReached устанавливается в true, когда количество полученных элементов меньше запрошенного количества, что означает отсутствие дополнительных данных на сервере.
isStartReached. Определение начала списка
Изменение количества запрашиваемых записей
Для изменения конфигурации InfiniteQuery необходимо использовать третий параметр config:
const createDocListInfiniteQuery = (filters: Omit<DocsDTO.DocListFilters, 'offset' | 'count'>) =>
cacheService.createInfiniteQuery(['docList', filters],
(params: { offset: number; count: number }) => docsEndpoints.getDocList({ offset: params.offset, count: params.count, ...filters }),
{ incrementCount: 10 }
);Интерфейс InfiniteQueryConfig
export type InfiniteQueryConfig<TResult = unknown> = {
/**
* Количество записей, которое будет загружено при первом и следующих запросах
* @default 30
*/
incrementCount?: number;
/**
* Обработчик ошибки
*/
onError?: (error: unknown) => void;
/**
* Флаг, отвечающий за автоматический запрос данных при обращении к полю data
*/
enabledAutoFetch?: boolean;
/**
* Политика кэширования данных.
*/
fetchPolicy?: FetchPolicy;
/**
* Стартовое значение offset, от которого требуется сделать самый первый запрос.
* @default 0
*/
initialOffset?: number;
/**
* Стартовый элемент, от которого требуется сделать самый первый запрос.
*/
initialCursor?: Partial<TResult>;
/**
* Режим фонового обновления
* @default false
*/
isBackground?: boolean;
};Переодическая инвалидация (Поллинг)
Для обеспечения автоматической инвалидации данных по указанному времени используйте настройку pollingTime при создании query.
Значение необходимо указывать в милисекундах.
const TEN_MINUTES = 10 * 60 * 1000;
docsfetcher.queries.document.createWithConfig(
{
pollingTime: TEN_MINUTES,
},
documenId,
);
docsfetcher.infiniteQueries.list.createWithConfig(
{
pollingTime: TEN_MINUTES,
},
);Mutation
Mutation предназначен для отправки данных на сервер с целью произведения изменений.
Создание и использование mutations
В примере ниже будет создан editDoc mutation, который отправляет данные на сервер для редактирования документа:
const createEditDocMutation = (params: DocsDTO.EditDocInput) =>
cacheService.createMutation((params: DocsDTO.EditDocInput) => docsEndpoints.editDoc(params));
const editDocMutation = createEditDocMutation();
// params являются параметрами, указанными при определении mutation - DocsDTO.EditDocInput
await editDocMutation.async({ params: { id: 'docID', name: 'test' } });Интерфейс Mutation
type Mutation<TResult, TError = unknown, TExecutorParams = void> = {
/**
* Синхронный метод выполнения мутации
*/
sync: (options: {
onSuccess?: (res: TResult) => void;
onError?: (e: TError) => void;
params?: TExecutorParams;
}) => void;
/**
* Асинхронный метод выполнения мутации
*/
async: (params: TExecutorParams) => Promise<TResult>;
/**
* Флаг загрузки
*/
isLoading: boolean;
/**
* Флаг успешного выполнения запроса
*/
isSuccess: boolean;
/**
* Флаг наличия ошибки
*/
isError: boolean;
/**
* Текущая ошибка
*/
error: TError | null;
/**
* Флаг, обозначающий простаивание, т.е. запроса еще не было
*/
isIdle: boolean;
// Статусы, изменяющиеся после первого успешного запроса в режиме фоновой загрузки isBackground: true
background: {
/**
* Флаг обозначающий загрузку данных в фоновом режиме
*/
isLoading: boolean;
/**
* Флаг обозначающий, что последний запрос был зафейлен в фоновом режиме
*/
isError: boolean;
/**
* Данные о последней ошибке в фоновом режиме
*/
error?: TError;
/**
* Флаг, обозначающий успешность завершения последнего запроса в фоновом режиме
*/
isSuccess: boolean;
};
};Синхронный вызов mutation
Метод sync позволяет синхронно запустить запрос:
const createEditDocMutation = (params: DocsDTO.EditDocInput) =>
cacheService.createMutation((params: DocsDTO.EditDocInput) => docsEndpoints.editDoc(params));
const editDocMutation = createEditDocMutation();
// есть callbacks на обработку success и error
editDocMutation.sync({
params: { id: 'docID', name: 'test' },
onSuccess: (data) => {
console.log(data);
},
onError: (error) => {
console.log(error);
},
});
// true
editDocMutation.isLoading;Асинхронный вызов mutation
Метод async позволяет асинхронно запустить запрос:
const createEditDocMutation = (params: DocsDTO.EditDocInput) =>
cacheService.createMutation((params: DocsDTO.EditDocInput) => docsEndpoints.editDoc(params));
const editDocMutation = createEditDocMutation();
await editDocMutation.async({ params: { id: 'docID', name: 'test' } });Особенности инвалидации Queries
Как при создании query, так и при инвалидации, нужно использовать массив ключей. Предполагается, что query может быть инвалидирован по нескольким ключам.
const query = mobxQuery.createQuery(
['key one', 'key two'], // ключ - массив строк
() => Promise.resolve('foo'),
{ enabledAutoFetch: true }
);
mobxQuery.invalidate(['key two']); // query будет инвалидирован
mobxQuery.invalidate(['key one']); // query будет инвалидированНо, стоит учитывать, что ключом является цельный элемент массива, а не составляющие элемента.
const query = mobxQuery.createQuery(
[['key one', 'key two']], // ключ - двумерный массив строк
() => Promise.resolve('foo'),
{ enabledAutoFetch: true }
);
mobxQuery.invalidate(['key one']); // ключ не совпадает, query НЕ будет инвалидированИнвалидация будет происходить только для query, поле data которых считывается в данный момент. Для query, data которых будут отрендерены позже, запрос произойдет только в момент использования. Для превентивного обновления данных потребуется последовательное использование sync/async методов сразу после invalidate.
Массовая инвалидация
Для инвалидации всех query необходимо использовать метод invalidateQueries:
mobxQuery.invalidateQueries();Изменение кэша
Для изменения данных в кэше необходимо использовать forceUpdate, содержащийся в query:
const query = mobxQuery.createQuery(['key'], () => Promise.resolve('1'));
// в кэше будет записано '2', а статустные флаги перейдут в состояние 'success'
query.forceUpdate('2');