← Назад к вопросам

Как сделать чтобы запрос не строился столько же раз сколько нажата кнопка получения отчета?

2.3 Middle🔥 161 комментариев
#JavaScript Core

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI3 апр. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Как сделать чтобы запрос не строился столько же раз сколько нажата кнопка

Это проблема, когда пользователь многократно нажимает на кнопку "Получить отчет" и каждый клик отправляет новый запрос на сервер. Нужно предотвратить множественные одновременные запросы. Вот несколько проверенных решений.

Проблема: Множественные запросы

// ПЛОХО - каждый клик отправляет запрос
function ReportGenerator() {
  const handleGenerateReport = async () => {
    // Пользователь может нажать 5 раз за 0.5 сек
    // 5 запросов уйдут на сервер!
    const response = await fetch('/api/generate-report');
    const data = await response.json();
    console.log(data);
  };

  return <button onClick={handleGenerateReport}>Get Report</button>;
}

Решение 1: Отключение кнопки во время запроса

Это самое простое и эффективное решение.

function ReportGenerator() {
  const [isLoading, setIsLoading] = useState(false);

  const handleGenerateReport = async () => {
    if (isLoading) return; // Игнорируем повторные клики
    
    setIsLoading(true);
    try {
      const response = await fetch('/api/generate-report');
      const data = await response.json();
      console.log('Report ready:', data);
    } catch (error) {
      console.error('Error generating report:', error);
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <button 
      onClick={handleGenerateReport} 
      disabled={isLoading}
      className={isLoading ? 'opacity-50 cursor-not-allowed' : ''}
    >
      {isLoading ? 'Generating...' : 'Get Report'}
    </button>
  );
}

Решение 2: Дебоунсирование с useCallback

Дебоунс откладывает выполнение функции до тех пор, пока пользователь не закончит кликать.

function useDebounce<T extends (...args: any[]) => void>(
  callback: T,
  delay: number = 500
): T {
  const timeoutRef = useRef<NodeJS.Timeout>();

  return ((...args) => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }
    timeoutRef.current = setTimeout(() => {
      callback(...args);
    }, delay);
  }) as T;
}

function ReportGenerator() {
  const handleGenerateReport = useDebounce(async () => {
    const response = await fetch('/api/generate-report');
    const data = await response.json();
    console.log('Report:', data);
  }, 800);

  return <button onClick={handleGenerateReport}>Get Report</button>;
}

Решение 3: Throttling (Дросселирование)

Тротлинг обеспечивает максимум один запрос в N миллисекунд.

function useThrottle<T extends (...args: any[]) => void>(
  callback: T,
  delay: number = 1000
): T {
  const lastRunRef = useRef(Date.now());
  const timeoutRef = useRef<NodeJS.Timeout>();

  return ((...args) => {
    const now = Date.now();
    const timeSinceLastRun = now - lastRunRef.current;

    if (timeSinceLastRun >= delay) {
      // Прошло достаточно времени - выполняем сразу
      callback(...args);
      lastRunRef.current = now;
    } else {
      // Планируем выполнение
      if (timeoutRef.current) clearTimeout(timeoutRef.current);
      timeoutRef.current = setTimeout(() => {
        callback(...args);
        lastRunRef.current = Date.now();
      }, delay - timeSinceLastRun);
    }
  }) as T;
}

function ReportGenerator() {
  const handleGenerateReport = useThrottle(async () => {
    const response = await fetch('/api/generate-report');
    const data = await response.json();
    console.log('Report:', data);
  }, 2000); // Максимум один запрос в 2 секунды

  return <button onClick={handleGenerateReport}>Get Report</button>;
}

Решение 4: AbortController для отмены предыдущего запроса

Если новый запрос отправляется до завершения предыдущего, отменяем старый.

function ReportGenerator() {
  const [isLoading, setIsLoading] = useState(false);
  const abortControllerRef = useRef<AbortController | null>(null);

  const handleGenerateReport = async () => {
    // Отменяем предыдущий запрос если он еще выполняется
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }

    // Создаем новый AbortController
    abortControllerRef.current = new AbortController();
    setIsLoading(true);

    try {
      const response = await fetch('/api/generate-report', {
        signal: abortControllerRef.current.signal,
      });
      const data = await response.json();
      console.log('Report:', data);
    } catch (error: any) {
      if (error.name !== 'AbortError') {
        console.error('Error:', error);
      }
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <button 
      onClick={handleGenerateReport} 
      disabled={isLoading}
    >
      {isLoading ? 'Generating...' : 'Get Report'}
    </button>
  );
}

Решение 5: Custom Hook с комбинацией подходов

Комбинируем несколько техник для надежного решения.

interface UseAsyncActionOptions {
  debounce?: number;
  throttle?: number;
  timeout?: number; // Таймаут запроса
}

function useAsyncAction<T>(
  asyncFn: () => Promise<T>,
  options: UseAsyncActionOptions = {}
) {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  const [data, setData] = useState<T | null>(null);

  const abortControllerRef = useRef<AbortController | null>(null);
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);
  const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
  const lastRunRef = useRef(0);
  const isMountedRef = useRef(true);

  // Cleanup на unmount
  useEffect(() => {
    return () => {
      isMountedRef.current = false;
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
      if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
      if (timeoutRef.current) clearTimeout(timeoutRef.current);
    };
  }, []);

  const execute = useCallback(async () => {
    // Throttle проверка
    if (options.throttle) {
      const now = Date.now();
      if (now - lastRunRef.current < options.throttle) {
        return;
      }
      lastRunRef.current = now;
    }

    // Отменяем предыдущий запрос
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }

    abortControllerRef.current = new AbortController();
    setIsLoading(true);
    setError(null);

    try {
      // Обертка для отмены по таймауту
      const promise = asyncFn();
      
      if (options.timeout && !options.timeout) {
        timeoutRef.current = setTimeout(
          () => abortControllerRef.current?.abort(),
          options.timeout
        );
      }

      const result = await promise;
      
      if (isMountedRef.current) {
        setData(result);
      }
    } catch (err) {
      if (isMountedRef.current && (err as any).name !== 'AbortError') {
        setError(err as Error);
      }
    } finally {
      if (isMountedRef.current) {
        setIsLoading(false);
      }
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
    }
  }, [asyncFn, options]);

  // Debounce обертка
  const debouncedExecute = useCallback(() => {
    if (debounceTimerRef.current) {
      clearTimeout(debounceTimerRef.current);
    }

    if (options.debounce) {
      debounceTimerRef.current = setTimeout(execute, options.debounce);
    } else {
      execute();
    }
  }, [execute, options.debounce]);

  return {
    isLoading,
    error,
    data,
    execute: debouncedExecute,
  };
}

// Использование
function ReportGenerator() {
  const { isLoading, error, data, execute } = useAsyncAction(
    async () => {
      const response = await fetch('/api/generate-report');
      if (!response.ok) throw new Error('Failed to generate report');
      return response.json();
    },
    {
      debounce: 500,    // Дебоунс 500мс
      throttle: 2000,   // Минимум 2сек между запросами
      timeout: 30000,   // Таймаут 30сек
    }
  );

  return (
    <div>
      <button onClick={execute} disabled={isLoading}>
        {isLoading ? 'Generating...' : 'Get Report'}
      </button>
      {error && <p className="text-red-600">Error: {error.message}</p>}
      {data && <pre>{JSON.stringify(data, null, 2)}</pre>}
    </div>
  );
}

Решение 6: React Query (рекомендуется для production)

React Query автоматически обрабатывает дублирующиеся запросы.

import { useMutation } from '@tanstack/react-query';

function ReportGenerator() {
  const mutation = useMutation({
    mutationFn: async () => {
      const response = await fetch('/api/generate-report');
      return response.json();
    },
  });

  return (
    <div>
      <button 
        onClick={() => mutation.mutate()} 
        disabled={mutation.isPending}
      >
        {mutation.isPending ? 'Generating...' : 'Get Report'}
      </button>
      {mutation.error && <p>Error: {mutation.error.message}</p>}
      {mutation.data && <pre>{JSON.stringify(mutation.data, null, 2)}</pre>}
    </div>
  );
}

Сравнение подходов

const comparison = {
  'Отключение кнопки': {
    сложность: 'Простая',
    эффективность: 'Высокая',
    UX: 'Хороший',
    использование: 'Рекомендуется для простых случаев',
  },
  'Дебоунс': {
    сложность: 'Средняя',
    эффективность: 'Средняя',
    UX: 'Может быть задержка',
    использование: 'Для форм и поиска',
  },
  'Тротлинг': {
    сложность: 'Средняя',
    эффективность: 'Высокая',
    UX: 'Хороший, но может быть задержка',
    использование: 'Для частых операций',
  },
  'AbortController': {
    сложность: 'Средняя',
    эффективность: 'Высокая',
    UX: 'Отличный',
    использование: 'Когда нужно отменить старый запрос',
  },
  'React Query': {
    сложность: 'Низкая (если уже используется)',
    эффективность: 'Очень высокая',
    UX: 'Отличный',
    использование: 'Рекомендуется для production',
  },
};

Лучшие практики

  1. Всегда отключай кнопку во время загрузки
  2. Используй AbortController для отмены старых запросов
  3. Добавляй дебоунс для поиска и фильтров
  4. Рассмотри React Query для более сложных сценариев
  5. Показывай пользователю статус (loading, success, error)
  6. Логируй попытки отправить дублирующиеся запросы
  7. Установи таймауты на запросы
  8. Тестируй быстрые клики и плохое соединение

Выбор подхода зависит от сложности приложения и требований. Для большинства случаев комбинация отключения кнопки + AbortController - это оптимальное решение.