Формы
Формы лучше проектировать как отдельный сценарий, а не как набор инпутов. У формы должны быть понятные default values, схема валидации, преобразование данных для API и отдельная обработка ошибок.
Практики:
- хранить значения формы отдельно от server state;
- валидировать на клиенте для скорости, но не считать это заменой backend-валидации;
- показывать ошибку рядом с полем, а общую ошибку отправки рядом с submit-действием;
- блокировать только действие отправки, а не весь экран;
- не терять введённые данные при ошибке запроса;
- приводить пустые строки, даты и числа к формату API в одном adapter-файле.
import { z } from 'zod';
export const CreateUserSchema = z.object({
name: z.string().trim().min(1, 'Введите имя'),
email: z.string().trim().email('Некорректный email'),
role: z.enum(['admin', 'manager', 'viewer']),
});
export type CreateUserValues = z.infer<typeof CreateUserSchema>;
export function mapCreateUserValues(values: CreateUserValues) {
return {
name: values.name,
email: values.email.toLowerCase(),
role: values.role,
};
}UX состояния
Для каждого интерактивного блока стоит заранее описывать состояния:
idle- данные ещё не запрашивались или действие не начато;loading- первичная загрузка;pending- пользовательское действие выполняется;empty- данных нет, но ошибки нет;success- данные доступны;error- запрос или действие упали;disabled- действие сейчас недоступно.
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'empty' }
| { status: 'error'; error: string };Хороший UX не оставляет пользователя в неизвестности: после клика кнопка меняет состояние, после ошибки есть понятная причина и повтор, после успеха интерфейс показывает результат.
Кэширование
Кэш нужен не только для скорости, но и для стабильного UX: меньше миганий, меньше повторных запросов, лучше работа back/forward и вкладок.
Что стоит кэшировать:
- справочники и редко меняющиеся данные;
- списки и карточки, которые пользователь часто открывает повторно;
- результаты фильтров и пагинации;
- текущего пользователя, права, feature flags.
Что не стоит кэшировать без осторожности:
- платежи и балансы;
- одноразовые токены;
- данные с жёсткими требованиями к свежести;
- персональные данные без понятной политики очистки.
const usersQuery = useQuery({
queryKey: ['users', filters],
queryFn: () => getUsers(filters),
staleTime: 60_000,
gcTime: 10 * 60_000,
});Ключ кэша должен содержать все параметры, которые влияют на результат: фильтры, сортировку, страницу, id, локаль.
Debounce
Debounce откладывает вызов до паузы во вводе. Подходит для поиска, autosave, resize, фильтров и expensive calculations.
function debounce<TArgs extends unknown[]>(
callback: (...args: TArgs) => void,
delayMs: number,
) {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
return (...args: TArgs) => {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
callback(...args);
}, delayMs);
};
}const searchUsers = debounce((query: string) => {
void queryClient.invalidateQueries({ queryKey: ['users', query] });
}, 400);Throttling
Throttle ограничивает частоту вызова. Подходит для scroll, mousemove, drag, resize и аналитики.
function throttle<TArgs extends unknown[]>(
callback: (...args: TArgs) => void,
intervalMs: number,
) {
let lastCall = 0;
return (...args: TArgs) => {
const now = Date.now();
if (now - lastCall < intervalMs) return;
lastCall = now;
callback(...args);
};
}Оптимистичные обновления
Оптимистичное обновление сразу показывает ожидаемый результат, а при ошибке откатывает состояние. Подходит для лайков, чекбоксов, reorder, быстрых CRUD-действий и добавления комментариев.
function useToggleTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: toggleTodo,
onMutate: async ({ id, completed }) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
queryClient.setQueryData<Todo[]>(['todos'], (todos = []) =>
todos.map((todo) => (todo.id === id ? { ...todo, completed } : todo)),
);
return { previousTodos };
},
onError: (_error, _variables, context) => {
queryClient.setQueryData(['todos'], context?.previousTodos);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}Пессимистичные обновления
Пессимистичное обновление ждёт ответа сервера и только потом меняет UI. Это безопаснее для платежей, удаления аккаунта, изменения прав доступа и необратимых операций.
const mutation = useMutation({
mutationFn: deleteProject,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
},
});
<button disabled={mutation.isPending} onClick={() => mutation.mutate(projectId)}>
Удалить
</button>;Обработка ошибок
Ошибки лучше разделять по уровню:
- validation error - рядом с конкретным полем;
- request error - рядом с формой, кнопкой или секцией;
- empty state - не ошибка, а отдельное состояние;
- render error - Error Boundary;
- unknown error - fallback и логирование.
function toErrorMessage(error: unknown) {
if (error instanceof Error) return error.message;
return 'Произошла неизвестная ошибка';
}function ErrorBlock({ error, onRetry }: { error: unknown; onRetry: () => void }) {
return (
<div role="alert">
<p>{toErrorMessage(error)}</p>
<button onClick={onRetry}>Повторить</button>
</div>
);
}Undo и idempotency
Для обратимых операций часто удобнее сделать действие сразу и дать отмену, чем показывать confirm-модалку.
function removeWithUndo<T>(items: T[], index: number) {
const item = items[index];
return {
nextItems: items.filter((_, itemIndex) => itemIndex !== index),
undo: (currentItems: T[]) => [
...currentItems.slice(0, index),
item,
...currentItems.slice(index),
],
};
}Для create-запросов полезно отправлять idempotencyKey, чтобы повторный клик или retry не создали дубликат.
function createIdempotencyKey(prefix: string) {
return `${prefix}_${crypto.randomUUID()}`;
}Autosave
Autosave должен быть заметным и предсказуемым: пользователь видит saving, saved, error, а данные не исчезают при сетевой ошибке.
type AutosaveStatus = 'idle' | 'saving' | 'saved' | 'error';
function getAutosaveLabel(status: AutosaveStatus) {
const labels: Record<AutosaveStatus, string> = {
idle: '',
saving: 'Сохраняется...',
saved: 'Сохранено',
error: 'Не удалось сохранить',
};
return labels[status];
}