define-query
v5.0.0
Published
Define TanStack Query options once — queries, mutations, optimistic updates, and row state.
Downloads
1,483
Maintainers
Readme
define-query
Тонкий хелпер без залежностей, який дозволяє визначити запити й мутації один раз і використовувати їх із рідними хуками TanStack Query.
Він не обгортає й не підміняє useQuery / useMutation. Натомість:
defineQuery/defineInfiniteQuery→ фабрики, що повертають рідніqueryOptions/infiniteQueryOptions.defineMutation→ фабрика, що повертає рідніmutationOptionsіз вшитими draft-апдейтами, звіркою temp-id, rollback і синхронізацією сусідніх запитів.
Ментальна модель
defineQuery(config) → (params) => queryOptions(...)
defineInfiniteQuery(config) → (params) => infiniteQueryOptions(...)
defineMutation(config) → (params) => mutationOptions(...)Уся поверхня TanStack лишається твоєю (isPending, error, fetchStatus, mutate, mutateAsync, …). Ліба лише будує об'єкти опцій для рідних хуків.
| Шар | Відповідальність |
|-----|------------------|
| Query | key, fetch, опційний spread, опційні options (проксяться в TanStack) |
| Mutation | request, опційний validate, одна draft-форма, опційний sync |
| Draft-форма | object, insertInto / prependInto, updateIn, removeFrom або dropQuery |
| Spread / sync | Query spread після успішного network fetch; mutation sync після успішного request |
| UI state | Рідний useMutation і useQuery (draft у кеші) |
Опційне поле query у defineMutation — який кеш оновлює мутація, а не обов'язково endpoint API. addComment → postCommentsQuery, renamePost → postQuery тощо. Без query — тонка мутація (request + опційний sync).
Налаштування
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>define-query підключається до QueryClient автоматично: при першому network fetch query з spread і при першій мутації. Ручний bootstrap не потрібен.
Зарезервований meta: не задавай options.meta['define-query'] на query-фабриках — цей ключ належить бібліотеці.
Швидкий старт
import { useMutation, useQuery } from '@tanstack/react-query';
import { defineMutation, defineQuery } from 'define-query';
const postQuery = defineQuery({
key: (id: string) => ['post', id] as const,
fetch: (id) => api.getPost(id),
options: { staleTime: 30_000 },
});
const renamePost = defineMutation({
query: postQuery,
name: 'rename',
request: (id: string, title: string) => api.patchPost(id, { title }),
draft: ({ data, input }) => ({ ...data, title: input }),
sync: (on) => [
on(timelineQuery).mergeIn('items', { set: (_item, { input }) => ({ title: input }) }),
],
});
function Post({ id }: { id: string }) {
const { data } = useQuery(postQuery(id));
const rename = useMutation(renamePost(id));
if (!data) return null;
return (
<input
defaultValue={data.title}
onBlur={(e) => rename.mutate(e.target.value)}
/>
);
}Ключі
Використовуй factory.key(params) — нормалізований стабільний ключ:
postQuery.key(id); // те саме, що postQuery(id).queryKey
addComment.key(postId); // те саме, що addComment(postId).mutationKey
queryClient.invalidateQueries({ queryKey: postQuery.key(id) });
useIsMutating({ mutationKey: addComment.key(postId) });Запити
defineQuery
Params мають бути плоским об'єктом або скаляром. Вкладені структури згортаються в {} з console warning у будь-якому середовищі.
Для nested filter state (таблиці, складний пошук) — serializeParams: перетворює params лише для keying; fetch отримує raw params:
const tableQuery = defineQuery({
serializeParams: (p) => ({
filterKey: stableFilterKey(p.filter),
page: p.page,
}),
key: (k) => ['table', k] as const,
fetch: (p) => api.list(p),
});Якщо output serializeParams лишається вкладеним — console warning; flatten/stringify у util. Bound mutation успадковує hook від query; name-only mutation може мати власний serializeParams.
queryClient.invalidateQueries({ queryKey: postCommentsQuery.key(postId) });defineInfiniteQuery
const items = flattenInfiniteField(timeline.data, 'items');Query spread
spread на defineQuery / defineInfiniteQuery — після кожного успішного network fetch (не після ручного setQueryData). Підключається автоматично при першому fetch. Повторюється на refetch, prefetch і invalidate.
| Операція | Ефект |
|----------|-------|
| on(query).set(...) | Upsert одного sibling entry |
| on(query).setEach(field, ...) | Upsert для кожного елемента списку |
Mutation sync — окремо: після успішного request, інший набір ops (bump, mergeIn, removeFrom, set, invalidate). Той самий синтаксис on(query), різні тригери.
Мутації
defineMutation(config) повертає (params) => mutationOptions. Обов'язковий унікальний name. Обирай одну draft-форму, коли є query.
| query | mutationKey |
|---------|----------------|
| заданий | [...queryKey, name] |
| відсутній | [name] або [name, params] |
query обов'язковий для draft-форм. Для лише request + sync — можна опустити.
Тонка мутація (без query)
const reportSpam = defineMutation({
name: 'reportSpam',
request: (postId: string, reason: string) => api.report(postId, reason),
sync: (on) => [on(postQuery).invalidate({ params: ({ params }) => params })],
});DraftCtx — єдиний API для draft
type DraftCtx<TData, TInput> = {
data: TData;
input: TInput;
tempId?: string; // insert/prepend
item?: TItem; // update — знайдений рядок
};| Форма | draft | settle |
|-------|---------|----------|
| object | (ctx) => TData | (ctx & { response }) => TData |
| insertInto / prependInto | (ctx) => TItem | (response) => TItem |
| update | (ctx) => Partial<TItem> | (ctx & { response }) => Partial<TItem> |
| removeFrom / dropQuery | — | — |
Приклади
// object
draft: ({ data, input }) => ({ ...data, title: input }),
// insert
draft: ({ input, tempId }) => ({ id: tempId!, text: input }),
// update
draft: ({ input }) => ({ text: input.text }),Infinite:
insertInto— остання сторінка,prependInto— перша. Порожнійpagesbootstrap'иться першою сторінкою з елементом.
Без кешу: якщо даних у кеші ще немає — draft пропускається (console warning), виконується лише
request.
List paths
List-мутації (insertInto / prependInto / updateIn / removeFrom) і sync list ops приймають:
| Форма кешу | Синтаксис | Приклад |
|------------|-----------|---------|
| Об'єкт з масивом (top-level або nested) | dot-path string | 'items', 'response.nodes' |
| Root-масив (TData = Post[]) | true | prependInto: true |
Шляхи перевіряються TS — невалідні ключі або non-array targets дають compile error. Обраний path звужує тип елемента в draft / match / settle.
// nested list (GraphQL-style)
defineMutation({
query: graphCommentsQuery,
name: 'add',
request: (id, text) => api.add(id, text),
insertInto: 'response.nodes',
draft: ({ input, tempId }) => ({ id: tempId!, text: input }),
});
// API повертає Post[] без обгортки
defineMutation({
query: postsQuery,
name: 'create',
request: (_, input) => api.create(input),
prependInto: true,
draft: ({ input, tempId }) => ({ id: tempId!, title: input.title }),
});Той самий синтаксис — для mergeIn, removeFrom, setEach, flattenInfiniteField. bump приймає dot-path до вкладених числових полів (без true).
Конкурентність
Паралельні мутації на одному списку безпечні: rollback по rowId / tempId. Два edit одного рядка — останній успішний response виграє при settle.
Валідація
validate(input) виконується до draft — fail.validation(...) відхиляє мутацію без змін у кеші. Той самий fail.validation(...) можна кинути з request (наприклад, 422 від API); у разі будь-якої помилки draft відкочується.
Кожне поле — string або string[]; .field(key) повертає перше повідомлення.
Клієнтська — validate
const createPost = defineMutation({
query: timelineQuery,
name: 'create',
request: (_params, { title, body }) => api.createPost({ title, body }),
validate: ({ title, body }) => {
if (!title.trim()) throw fail.validation({ title: ['Заголовок не може бути порожнім'] });
if (!body.trim()) throw fail.validation({ body: ['Текст не може бути порожнім'] });
},
prependInto: 'items',
draft: ({ input, tempId }) => ({ id: tempId!, title: input.title, body: input.body }),
});Працює і без query — для тонких мутацій, де потрібні лише перевірки форми:
const reportSpam = defineMutation({
name: 'reportSpam',
validate: reason => {
if (!reason.trim()) throw fail.validation({ reason: 'Обовʼязкове поле' });
},
request: (postId, reason) => api.report(postId, reason),
});Серверна — request
request: async (postId, text) => {
const res = await api.addComment(postId, text);
if (res.error) throw fail.validation(res.error); // напр. { text: ['Занадто коротко'] }
return res;
},UI — поле vs banner
function CreateForm({ params }: { params: { q: string } }) {
const create = useMutation(createPost(params));
function submit(e: React.FormEvent) {
e.preventDefault();
create.reset(); // скинути попередні помилки
create.mutate({ title, body });
}
const titleError = create.error?.field('title'); // лише validation
const bodyError = create.error?.field('body');
const banner = create.error?.banner(); // network / server — null для validation
return (
<form onSubmit={submit}>
{titleError && <span>{titleError}</span>}
{bodyError && <span>{bodyError}</span>}
{banner && <span>{banner}</span>}
</form>
);
}Для одного рядка вводу — field('text') і banner() покривають validation і мережеві помилки:
<>
{add.error?.field('text') && <span>{add.error.field('text')}</span>}
{add.error?.banner() && <span>{add.error.banner()}</span>}
</>Помилки
import { fail } from 'define-query';
throw fail.validation({ text: ['Порожньо'] });
throw fail.network('Offline');
throw fail.server('Помилка сервера');mutation.error — DefineQueryMutationError:
add.error?.field('text'); // поле форми
add.error?.banner(); // toast/banner (не validation)isMutationError — лише для unknown (спільні helpers).
Mutation sync
Після успішного request. Див. таблицю ops у README.md.
Утиліти
| Експорт | Призначення |
|---------|-------------|
| factory.key(params) | Стабільний ключ query / mutation |
| factory.with(params, overrides) | Per-call TanStack options поверх factory (queries і mutations) |
| flattenInfiniteField(data, field) | Сплющити список по infinite-сторінках |
| getPendingInput / matchPending | In-flight input мутації |
| remapIds(client, input) | Temp → server id у ручних cache writes |
| isTempId / createTempId | Temp id |
| isMutationError | Type guard для помилки мутації |
| error.display({ fields?, fallback? }) | Перше повідомлення для UI |
| fail | validation / network / server |
| QueryParams<TQuery> | Infer params для typed sync/spread params |
Живі приклади: ../define-query-demo/src/demo/queries/, ../define-query-demo/src/demo/feed/, ../define-query-demo/src/demo/thread/.
