Оптимизация управления состоянием данных в React с помощью React Query

markdown
# Оптимизация управления состоянием данных в React с помощью React Query

Если вы разрабатываете современные React-приложения, вы наверняка сталкивались с необходимостью получения, кэширования, синхронизации и обновления данных с бэкенда. Традиционные подходы с использованием Redux или контекста часто ведут к сложностям: избыточному коду, ручному управлению кэшем, избыточным рендерингам и сложностям обработки ошибок. Рассмотрим лучший способ.

## Проблема ручного управления данными

Типичная реализация с useEffect выглядит примерно так:

```jsx
function UserProfile({ userId }) {
  const [userData, setUserData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let isMounted = true;
    
    const fetchData = async () => {
      try {
        const response = await fetch(`/api/users/${userId}`);
        const data = await response.json();
        if (isMounted) {
          setUserData(data);
          setLoading(false);
        }
      } catch (err) {
        if (isMounted) {
          setError(err.message);
          setLoading(false);
        }
      }
    };

    fetchData();

    return () => {
      isMounted = false;
    };
  }, [userId]);

  if (loading) return <Spinner />;
  if (error) return <Error message={error} />;
  
  return (
    <div>
      <h1>{userData.name}</h1>
      <p>{userData.bio}</p>
    </div>
  );
}

Такой подход содержит несколько известных проблем:

  • Необходимость ручного отслеживания состояния загрузки и ошибок
  • Проблемы гонки при быстрой смене userId
  • Отсутствие встроенного кэширования
  • Неявные расчёты времени устаревания данных
  • Отсутствие автоматических повторных запросов
  • Необходимость вручную отменять запросы при размонтировании

Введение React Query

React Query (теперь TanStack Query) решает эти проблемы с помощью децентрализованного подхода к управлению данных. Основные понятия:

jsx
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <UserProfile userId="123" />
    </QueryClientProvider>
  );
}

function UserProfile({ userId }) {
  const { data, isLoading, error } = useQuery(
    ['user', userId],
    () => fetch(`/api/users/${userId}`).then(res => res.json())
  );

  if (isLoading) return <Spinner />;
  if (error) return <Error message={error.message} />;
  
  return (
    <div>
      <h1>{data.name}</h1>
      <p>{data.bio}</p>
    </div>
  );
}

Всего 5 строк логики вместо 30, но за кажущейся простотой скрывается мощная функциональность.

Автоматическое кэширование и инвалидация

Ключевое преимущество React Query - разумное кэширование. По умолчанию данные кэшируются на 5 минут (staleTime), но лучше настроить это явно:

jsx
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000 * 10, // 10 минут
      cacheTime: 60 * 1000 * 20, // 20 минут
    },
  },
});

Инвалидация данных:

jsx
function UserProfile({ userId }) {
  const queryClient = useQueryClient();
  
  const { data } = useQuery(['user', userId], fetchUser);

  const updateName = useMutation(
    (newName) => patchUser(userId, { name: newName }),
    {
      onSuccess: () => {
        // Инвалидируем конкретный запрос
        queryClient.invalidateQueries(['user', userId]);
        
        // Или всех связанных пользовательских запросов
        queryClient.invalidateQueries({ predicate: query => 
          query.queryKey[0] === 'user' 
        });
      }
    }
  );
}

Префетчинг данных

React Query упрощает реализацию префетчинга - стратегии, улучшающей UX:

jsx
function UserList() {
  const queryClient = useQueryClient();
  
  const usersQuery = useQuery('users', fetchUsers);
  
  const handleHover = (userId) => {
    queryClient.prefetchQuery(['user', userId], () => fetchUser(userId), {
      staleTime: 60 * 1000, // Помечаем как свежие на 60 секунд
    });
  };
  
  return (
    <ul>
      {usersQuery.data.map(user => (
        <li key={user.id} onMouseEnter={() => handleHover(user.id)}>
          {user.name}
        </li>
      ))}
    </ul>
  );
}

Оптимистичные обновления

Улучшаем восприятие скорости приложения при мутациях:

jsx
const updateUser = useMutation(
  (newUser) => patchUser(newUser),
  {
    onMutate: async (newUser) => {
      // Отменяем существующие запросы, чтобы избежать перезаписи
      await queryClient.cancelQueries(['user', newUser.id]);
      
      // Сохраняем предыдущее значение для отката
      const previousUser = queryClient.getQueryData(['user', newUser.id]);
      
      // Устанавливаем новое значение оптимистично
      queryClient.setQueryData(['user', newUser.id], newUser);
      
      return { previousUser };
    },
    onError: (err, newUser, context) => {
      // В случае ошибки возвращаем старые данные
      queryClient.setQueryData(['user', newUser.id], context.previousUser);
    },
    onSettled: () => {
      // Обновляем данные после успеха или ошибки
      queryClient.invalidateQueries(['user', newUser.id]);
    }
  }
);

Архитектурные соображения

React Query особенно эффективен при правильном разделении логики:

jsx
// api.js - централизованное API
const getUsers = async () => {
  const response = await fetch('/api/users');
  if (!response.ok) throw new Error('Failed to fetch users');
  return response.json();
};

const getUserById = async (userId) => {
  const response = await fetch(`/api/users/${userId}`);
  if (!response.ok) throw new Error(`Failed to fetch user ${userId}`);
  return response.json();
};

// hooks.js - кастомные хуки
export const useUsers = () => useQuery('users', getUsers);

export const useUser = (userId) => 
  useQuery(['user', userId], () => getUserById(userId));

export const useUpdateUser = () => {
  const queryClient = useQueryClient();
  
  return useMutation(
    (updatedUser) => patchUser(updatedUser),
    {
      onSuccess: () => {
        queryClient.invalidateQueries('users');
        queryClient.invalidateQueries(['user', updatedUser.id]);
      }
    }
  );
};

// UserProfile.jsx - компонент
function UserProfile({ userId }) {
  const { data: user } = useUser(userId);
  const { mutate: updateUser } = useUpdateUser();
  
  // ... остальная логика компонента
}

Распространенные ошибки и их решения

Слишком широкое кэширование данных

Проблема: Кэширование устаревших данных при изменениях в других частях приложения

Решение:

jsx
// При изменении пользователя инвалидируем оба пункта
const useUpdateUser = () => {
  const queryClient = useQueryClient();
  
  return useMutation(
    updateUserApi,
    {
      onSuccess: (updatedUser) => {
        // Инвалидация точного ключа
        queryClient.invalidateQueries(['user', updatedUser.id]);
        
        // Инвалидация групповых запросов
        queryClient.invalidateQueries({
          queryKey: ['users'],
          exact: false,
        });
      }
    }
  );
};

Игнорирование фонового обновления

Проблема: Потеря изменений при фоновом обновлении после оптимистичного

Решение:

jsx
onMutate: async (newUser) => {
  await queryClient.cancelQueries(['user', newUser.id]);
  
  // Сохранение текущих данных
  const previousUser = queryClient.getQueryData(['user', newUser.id]);
  
  // Установка новых данных + метка времени оптимистичного обновления
  queryClient.setQueryData(['user', newUser.id], {
    ...newUser,
    __timestamp: Date.now(),
  });
  
  return { previousUser };
},

onSettled: (data, error, variables, context) => {
  // Убеждаемся, что оптимистичное обновление новее фонового
  const currentData = queryClient.getQueryData(['user', variables.id]);
  
  if (currentData?.__timestamp < context?.__timestamp) return;
  
  queryClient.invalidateQueries(['user', variables.id]);
}

Некорректная обработка пагинации

Проблема: Неверное слияние данных при пагинации

Решение:

jsx
const { data, fetchNextPage } = useInfiniteQuery(
  'users',
  ({ pageParam = 0 }) => fetchUsers({ offset: pageParam, limit: 10 }),
  {
    getNextPageParam: (lastPage, allPages) => 
      lastPage.hasMore ? allPages.length * 10 : undefined,
  }
);

// Правильное отображение
return (
  <div>
    {data.pages.map((page, pageIndex) => (
      <React.Fragment key={pageIndex}>
        {page.users.map(user => (
          <UserCard key={user.id} user={user} />
        ))}
      </React.Fragment>
    ))}
    <button onClick={() => fetchNextPage()}>Загрузить еще</button>
  </div>
);

Когда не использовать React Query

Не следует применять этот инструмент для:

  • Реактивного UI состояния (лучше useState/useReducer)
  • Глобального состояния приложения (лучше Zustand, Recoil)
  • Данных, никогда не отправляемых на сервер
  • Простых статичных запросов без повторных вызовов

Практические рекомендации

  1. Уникальность ключей: Используйте продуктогенерирующие структуры для queryKey - массивы с идентификаторами ресурсов
  2. Разделение ответственности: Создавайте кастомные хуки для каждой конечной точки API
  3. Обработка ошибок: Централизованный перехват ошибок через
js
const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error) => {
      notifyError(error.message);
    }
  })
});
  1. Оптимизация рендеров: Для больших списков используйте observerMode
js
useQuery(queryKey, queryFn, { notifyOnChangeProps: ['data'] });
  1. Автивность данных: Настройте фонтовое обновление с refetchOnWindowFocus

React Query преобразует архитектуру вашего приложения, позволяя сконцентрироваться на бизнес-логике вместо ручных манипуляций с данными. Это не просто инструмент выборки данных, а комплексное решение для управления состоянием серверных данных, которое особенно ценно в мире микрофронтендов и распределенных систем, где актуальность и синхронизация данных становятся критически важными.

text