import { App } from 'antd'; import { type Dispatch, type SetStateAction, useCallback, useEffect, useRef, useState } from 'react'; import { ApiError } from '../../api/client'; /** * Показ ошибки запроса уведомлением antd. `ApiError` уже несёт текст с сервера (например, «Нельзя снять последнего * администратора» для 409 `LAST_ADMIN`); прочие исключения — общий текст. `message` берём из `App.useApp()`, а не * статический `message.error`: так уведомление получает тему из `ConfigProvider` (статический вызов её не видит). */ export function useNotifyError(): (e: unknown) => void { const { message } = App.useApp(); return useCallback( (e: unknown) => { // отмена запроса (AbortController) — не ошибка для пользователя if (e instanceof DOMException && e.name === 'AbortError') { return; } message.error(e instanceof ApiError ? e.message : 'Не удалось выполнить запрос'); }, [message], ); } /** * Флаг «компонент ещё на экране». Проверяется после `await` в обработчиках кнопок, чтобы не менять состояние * компонента, который пользователь уже закрыл (ушёл со страницы, пока шёл запрос). */ export function useMountedRef(): { readonly current: boolean } { const mounted = useRef(false); useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); return mounted; } type AsyncData = { /** Последние данные с сервера; `null` — ещё не загружены или загрузка не удалась. */ data: T | null; /** Замена данных без запроса (например, строка из ответа `PUT`). */ setData: Dispatch>; loading: boolean; /** Повторная загрузка; промис не отклоняется — ошибка уже показана уведомлением. */ reload: () => Promise; }; /** * Загрузка данных при появлении компонента и по `reload()`. * * `loader` должен быть стабильным (функция уровня модуля или `useCallback`), иначе загрузка пойдёт на каждой отрисовке. * Защита от устаревших ответов: у каждого запуска свой номер; применяется только ответ последнего запуска. Ответ, * пришедший после более нового `reload()` или после ухода со страницы, отбрасывается. */ export function useAsyncData(loader: () => Promise): AsyncData { const notifyError = useNotifyError(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const latestRun = useRef(0); const reload = useCallback(async () => { latestRun.current += 1; const run = latestRun.current; setLoading(true); try { const result = await loader(); if (run === latestRun.current) { setData(result); } } catch (e: unknown) { if (run === latestRun.current) { notifyError(e); } } finally { if (run === latestRun.current) { setLoading(false); } } }, [loader, notifyError]); useEffect(() => { void reload(); return () => { // уход со страницы: ответы запросов «в полёте» станут устаревшими и будут отброшены latestRun.current += 1; }; }, [reload]); return { data, setData, loading, reload }; }