RTK Query на мобільних платформах
RTK Query на мобільних платформах
🎯 Мета навчального матеріалу
- Зрозуміти різницю між клієнтським та серверним станом у мобільних застосунках
- Навчитися працювати з RTK Query від найпростіших запитів до складних сценаріїв
- Опанувати систему кешування та автоматичної інвалідації даних
- Створити повноцінний мобільний застосунок з серверною взаємодією
🔑 Що ви дізнаєтесь
- Базові концепції: Query та Mutation ендпоінти, хуки для компонентів
- Кешування: Автоматичне збереження даних, теги інвалідації
- Мобільна специфіка: Робота з фокусом застосунку, відновлення мережі
- Просунуті техніки: Оптимістичні оновлення, пагінація, WebSockets
Чому потрібен RTK Query? Розбір реальної проблеми
Уявіть, що ви розробляєте мобільний застосунок Nomad для планування подорожей. У вас є екран зі списком поїздок, і ви вирішили використовувати Redux для керування станом. Давайте подивимось, з якими проблемами ви зіткнетеся при ручному підході.
Проблема 1: Завантаження списку поїздок (традиційний підхід)
Спочатку створюємо слайс (slice) для зберігання поїздок:
// src/store/tripsSlice.ts — ТРАДИЦІЙНИЙ ПІДХІД
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
// Визначаємо структуру даних поїздки
interface Trip {
id: string
title: string
region: string
startDate: string
}
// Описуємо стан слайса з усіма необхідними полями
interface TripsState {
trips: Trip[] // Масив завантажених поїздок
isLoading: boolean // Чи відбувається зараз завантаження?
error: string | null // Текст помилки, якщо щось пішло не так
}
// Початковий стан при запуску застосунку
const initialState: TripsState = {
trips: [],
isLoading: false,
error: null,
}
// Створюємо асинхронну дію для завантаження даних з сервера
export const fetchTrips = createAsyncThunk(
'trips/fetchTrips',
async () => {
const response = await fetch('http://localhost:3000/trips')
if (!response.ok) {
throw new Error('Не вдалося завантажити поїздки')
}
return response.json()
}
)
// Створюємо слайс з обробкою всіх можливих станів запиту
const tripsSlice = createSlice({
name: 'trips',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
// Запит почався — встановлюємо прапорець завантаження
.addCase(fetchTrips.pending, (state) => {
state.isLoading = true
state.error = null
})
// Запит успішно завершився — зберігаємо дані
.addCase(fetchTrips.fulfilled, (state, action) => {
state.isLoading = false
state.trips = action.payload
})
// Запит завершився помилкою — зберігаємо повідомлення
.addCase(fetchTrips.rejected, (state, action) => {
state.isLoading = false
state.error = action.error.message || 'Невідома помилка'
})
},
})
export default tripsSlice.reducer
Тепер у компоненті нам потрібно:
// app/screens/TripsListScreen.tsx
import React, { useEffect } from 'react'
import { View, Text, FlatList, ActivityIndicator } from 'react-native'
import { useDispatch, useSelector } from 'react-redux'
import { fetchTrips } from '@/store/tripsSlice'
export function TripsListScreen() {
const dispatch = useDispatch()
// Отримуємо дані зі стору
const { trips, isLoading, error } = useSelector((state) => state.trips)
// При монтуванні компонента запускаємо завантаження
useEffect(() => {
dispatch(fetchTrips())
}, [dispatch])
if (isLoading) {
return <ActivityIndicator size="large" />
}
if (error) {
return <Text>Помилка: {error}</Text>
}
return (
<FlatList
data={trips}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <Text>{item.title}</Text>}
/>
)
}
Що ми вже написали: близько 80 рядків коду лише для одного простого GET-запиту! І це тільки початок проблем...
Проблема 2: Додавання нової поїздки
Коли користувач створює нову поїздку, нам потрібно:
// Додаємо ще один thunk для створення поїздки
export const createTrip = createAsyncThunk(
'trips/createTrip',
async (newTrip: Omit<Trip, 'id'>) => {
const response = await fetch('http://localhost:3000/trips', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTrip),
})
return response.json()
}
)
// Розширюємо extraReducers ще трьома кейсами
extraReducers: (builder) => {
// ... попередні кейси для fetchTrips
builder
.addCase(createTrip.pending, (state) => {
state.isLoading = true
})
.addCase(createTrip.fulfilled, (state, action) => {
state.isLoading = false
// Додаємо нову поїздку до списку
state.trips.push(action.payload)
})
.addCase(createTrip.rejected, (state, action) => {
state.isLoading = false
state.error = action.error.message || 'Помилка створення'
})
}
Проблема: Кожен новий ендпоінт вимагає ще 10-15 рядків однотипного коду!
Проблема 3: Синхронізація між екранами
Тепер уявіть типовий сценарій користувача:
Крок 1: Користувач відкриває список поїздок
Застосунок завантажує дані з сервера, показує 5 поїздок. Дані зберігаються у Redux store.
Крок 2: Користувач переходить на інший екран
Відкривається форма "Створити нову поїздку". Користувач заповнює поля та натискає "Зберегти".
Крок 3: Користувач повертається назад
Що має показати екран списку? Старі 5 поїздок чи нові 6?
При традиційному підході у вас є три варіанти:
// Після створення поїздки перезавантажуємо весь список
async function handleCreateTrip(newTrip) {
await dispatch(createTrip(newTrip))
await dispatch(fetchTrips()) // Повторний запит до сервера
navigation.goBack()
}
Недолік: Зайвий мережевий запит. Якщо у списку було 100 поїздок, ми завантажуємо усі 101 знову, хоча потрібна лише одна нова.
// Просто додаємо нову поїздку до масиву в сторі
.addCase(createTrip.fulfilled, (state, action) => {
state.trips.push(action.payload)
})
Недолік: Якщо інший користувач теж додав поїздку через веб-версію, ми про це не дізнаємось. Дані застаріють (stale data).
// Відслідковуємо час останнього оновлення
interface TripsState {
trips: Trip[]
lastFetched: number | null
// ... інші поля
}
// Перезавантажуємо лише якщо пройшло більше 5 хвилин
useEffect(() => {
const now = Date.now()
const fiveMinutes = 5 * 60 * 1000
if (!lastFetched || now - lastFetched > fiveMinutes) {
dispatch(fetchTrips())
}
}, [lastFetched])
Недолік: Дуже багато коду для простої задачі! І це ще не враховує інші сценарії...
Проблема 4: Мобільна специфіка
У мобільному застосунку користувач може:
- Згорнути застосунок (перейти в інший додаток)
- Втратити інтернет (зайти в метро)
- Відновити з'єднання (вийти з метро)
При ручному підході вам потрібно писати такий код:
import { AppState } from 'react-native'
import NetInfo from '@react-native-community/netinfo'
// Відслідковуємо стан застосунку
useEffect(() => {
const subscription = AppState.addEventListener('change', (nextAppState) => {
if (nextAppState === 'active') {
// Застосунок знову активний — перезавантажити дані?
dispatch(fetchTrips())
}
})
return () => subscription.remove()
}, [])
// Відслідковуємо стан мережі
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected) {
// Інтернет відновлено — перезавантажити дані?
dispatch(fetchTrips())
}
})
return () => unsubscribe()
}, [])
Проблема: Цей код потрібно дублювати у кожному компоненті, який працює з серверними даними!
Підсумок проблем традиційного підходу
- Створювати окремий thunk для кожного ендпоінта (GET, POST, PUT, DELETE)
- Вручну описувати стани
pending,fulfilled,rejectedдля кожної дії - Самостійно керувати кешуванням та застарілими даними
- Дублювати логіку відслідковування фокусу та мережі в кожному компоненті
- Писати складну логіку синхронізації між різними екранами
- Обробляти паралельні запити та дедуплікацію вручну
Рішення: RTK Query автоматизує все це
RTK Query (RTK Query) — це офіційне розширення Redux Toolkit, яке повністю автоматизує роботу з серверними даними. Ось як виглядає той самий функціонал з RTK Query:
// src/services/tripsApi.ts — ПІДХІД RTK QUERY
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
interface Trip {
id: string
title: string
region: string
startDate: string
}
// Описуємо API один раз — RTK Query згенерує все інше автоматично
export const tripsApi = createApi({
reducerPath: 'tripsApi',
baseQuery: fetchBaseQuery({ baseUrl: 'http://localhost:3000' }),
tagTypes: ['Trip'], // Для автоматичної інвалідації кешу
endpoints: (builder) => ({
// Ендпоінт для отримання списку
getTrips: builder.query<Trip[], void>({
query: () => '/trips',
providesTags: ['Trip'], // Цей запит надає дані типу Trip
}),
// Ендпоінт для створення нової поїздки
createTrip: builder.mutation<Trip, Omit<Trip, 'id'>>({
query: (newTrip) => ({
url: '/trips',
method: 'POST',
body: newTrip,
}),
invalidatesTags: ['Trip'], // Автоматично оновити getTrips
}),
}),
})
// RTK Query автоматично створює хуки для кожного ендпоінта
export const { useGetTripsQuery, useCreateTripMutation } = tripsApi
Тепер у компоненті весь код зводиться до:
// app/screens/TripsListScreen.tsx — З RTK QUERY
import { useGetTripsQuery } from '@/services/tripsApi'
export function TripsListScreen() {
// Один хук замінює весь попередній код!
const { data: trips, isLoading, error } = useGetTripsQuery()
if (isLoading) return <ActivityIndicator />
if (error) return <Text>Помилка завантаження</Text>
return (
<FlatList
data={trips}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <Text>{item.title}</Text>}
/>
)
}
Що RTK Query робить автоматично:
⚡ Автоматичний кеш
useGetTripsQuery() на інших екранах миттєво повертають закешовані дані без нових мережевих запитів.🔄 Розумна інвалідація
createTrip, RTK Query автоматично бачить тег invalidatesTags: ['Trip'] та перезавантажує усі запити з providesTags: ['Trip']. Список оновлюється сам!📱 Мобільна оптимізація
refetchOnFocus (перезавантаження при поверненні з фону) та refetchOnReconnect (при відновленні інтернету). Вмикається одним рядком конфігурації.🎯 Дедуплікація запитів
useGetTripsQuery(), RTK Query відправить лише один реальний HTTP-запит. Всі компоненти отримають той самий результат.- Клієнтський стан — це дані, які живуть лише у вашому застосунку (налаштування теми, поточна вкладка, текст у полі вводу). Вони завжди актуальні, бо належать вам.
- Серверний стан — це дані, першоджерело яких знаходиться на віддаленому сервері. Вони можуть застаріти, можуть змінитися іншими користувачами, вимагають мережевих запитів та спеціальної логіки кешування.
Розуміння різниці: Клієнтський стан vs Серверний стан
Перед тим як почати працювати з RTK Query, критично важливо зрозуміти фундаментальну різницю між двома типами даних у вашому застосунку. Це визначить, коли використовувати звичайний Redux слайс, а коли — RTK Query.
Що таке клієнтський стан?
Клієнтський стан (Client State) — це дані, які повністю належать вашому застосунку та існують тільки на пристрої користувача. Ці дані завжди актуальні, бо їх джерело знаходиться у самому застосунку.
Практичні приклади клієнтського стану
Давайте розглянемо конкретні приклади з реального мобільного застосунку:
// src/store/themeSlice.ts
import { createSlice } from '@reduxjs/toolkit'
interface ThemeState {
mode: 'light' | 'dark'
fontSize: 'small' | 'medium' | 'large'
}
const themeSlice = createSlice({
name: 'theme',
initialState: {
mode: 'light',
fontSize: 'medium',
} as ThemeState,
reducers: {
toggleTheme: (state) => {
// Це чисто локальна зміна — не потрібен сервер
state.mode = state.mode === 'light' ? 'dark' : 'light'
},
setFontSize: (state, action) => {
state.fontSize = action.payload
},
},
})
// src/store/uiSlice.ts
import { createSlice } from '@reduxjs/toolkit'
interface UIState {
activeTab: 'home' | 'trips' | 'profile'
isMenuOpen: boolean
selectedTripId: string | null
}
const uiSlice = createSlice({
name: 'ui',
initialState: {
activeTab: 'home',
isMenuOpen: false,
selectedTripId: null,
} as UIState,
reducers: {
setActiveTab: (state, action) => {
state.activeTab = action.payload
},
openMenu: (state) => {
state.isMenuOpen = true
},
closeMenu: (state) => {
state.isMenuOpen = false
},
},
})
::
// Навіть простіше — через useState
function CreateTripForm() {
const [title, setTitle] = useState('')
const [region, setRegion] = useState('')
const [date, setDate] = useState(new Date())
// Ці дані живуть тільки у компоненті
// Вони зникають при розмонтуванні
return (
<View>
<TextInput
value={title}
onChangeText={setTitle}
placeholder="Назва поїздки"
/>
{/* ... інші поля */}
</View>
)
}
::
::
Ключові характеристики клієнтського стану:
- ✅ Синхронний доступ: дані доступні миттєво, без затримок
- ✅ Повний контроль: тільки ваш код може змінити ці дані
- ✅ Не застаріває: якщо користувач встановив темну тему, вона залишається темною
- ✅ Локальність: дані зникають при видаленні застосунку
- 🛠️ Інструменти:
createSlice,useState,useReducer
Що таке серверний стан?
Серверний стан (Server State) — це дані, джерело істини яких знаходиться на віддаленому сервері. Ваш застосунок зберігає лише тимчасову копію (кеш) цих даних.
Практичні приклади серверного стану
// Ці дані живуть на сервері в PostgreSQL
// Ваш застосунок отримує лише копію
interface Trip {
id: string
title: string
region: string
startDate: string
createdBy: string // Інший користувач міг створити
updatedAt: string // Міг змінити хтось інший
}
// Проблема: між запитами дані могли змінитися!
const trips1 = await fetch('/api/trips') // 10 поїздок
// ... користувач згортає застосунок на 5 хвилин
const trips2 = await fetch('/api/trips') // тепер 12 поїздок?
// Дані профілю можуть змінитися через:
// - Веб-версію застосунку
// - Адмін-панель
// - Інший мобільний пристрій користувача
interface UserProfile {
id: string
name: string
email: string
avatarUrl: string
subscriptionStatus: 'free' | 'premium'
}
// Питання: скільки часу можна довіряти закешованим даним?
::
// Сценарій 1: Користувач відкрив застосунок
const profile = await fetchProfile()
// { subscriptionStatus: 'free' }
// Користувач переходить на сайт і купує Premium
// Сценарій 2: Користувач повертається в застосунок
// Застосунок показує старі дані з кешу!
console.log(profile.subscriptionStatus) // 'free' ❌ ЗАСТАРІЛО!
// Правильно: перезавантажити дані
const freshProfile = await fetchProfile()
// { subscriptionStatus: 'premium' } ✅
::
::
Ключові характеристики серверного стану:
- ⏱️ Асинхронний доступ: потрібен мережевий запит (затримка 100-500мс)
- 🌐 Розподілена зміна: дані можуть змінити інші користувачі або системи
- ⚠️ Може застаріти: локальна копія може не відповідати серверу
- 🔄 Потребує синхронізації: треба знати, коли перезавантажувати дані
- 📦 Вимагає кешування: для швидкості не робити запит кожного разу
- 🛠️ Інструменти: RTK Query, TanStack Query, SWR, Apollo Client
Візуальне порівняння: життєвий цикл даних
Практичний сценарій: Чому важлива ця різниця?
Уявіть застосунок для планування подорожей. Давайте подивимось, як обидва типи стану працюють разом:
// src/screens/TripDetailsScreen.tsx
import { useSelector } from 'react-redux'
import { useGetTripByIdQuery } from '@/services/tripsApi'
function TripDetailsScreen({ tripId }) {
// 1. КЛІЄНТСЬКИЙ СТАН: налаштування теми (з Redux slice)
const theme = useSelector((state) => state.theme.mode)
// 2. СЕРВЕРНИЙ СТАН: деталі поїздки (з RTK Query)
const { data: trip, isLoading } = useGetTripByIdQuery(tripId)
if (isLoading) return <ActivityIndicator />
return (
<View style={{ backgroundColor: theme === 'dark' ? '#000' : '#fff' }}>
{/* Тема (клієнтський стан) — застосовується миттєво */}
<Text>{trip.title}</Text>
{/* Деталі поїздки (серверний стан) — можуть оновитись при refetch */}
</View>
)
}
- Якщо дані живуть тільки у вашому застосунку →
createSliceабоuseState - Якщо дані приходять з віддаленого сервера → RTK Query (або інший інструмент серверного стану)
Порівняння бібліотек для серверного стану
Тепер, коли ми розуміємо природу серверного стану, давайте порівняємо інструменти для роботи з ним:
| Критерій | RTK Query | TanStack Query | SWR | Apollo Client |
|---|---|---|---|---|
| Екосистема | Частина Redux Toolkit | Незалежна бібліотека | Від команди Vercel | Для GraphQL |
| Інтеграція з Redux | ✅ Нативна (єдині DevTools) | ⚠️ Окреме підключення | ❌ Окреме дерево стану | ❌ Окреме дерево |
| Розмір бандлу | ~9KB (якщо Redux вже є) | ~13KB | ~5KB | ~33KB |
| Інвалідація кешу | Теги (декларативно) | Ключі (імперативно) | Ключі (імперативно) | __typename (авто) |
| Генерація хуків | ✅ Автоматична | ❌ Ручна | ❌ Ручна | ⚠️ З GraphQL схеми |
| TypeScript | ✅ Відмінна підтримка | ✅ Відмінна підтримка | ✅ Добра підтримка | ✅ З кодогенерацією |
| Коли обирати | У вас вже є Redux | Redux не потрібен | Простий REST API | Тільки GraphQL |
RTK Query обирайте, якщо:
- Ваш застосунок вже використовує Redux для клієнтського стану (налаштування, UI, авторизація)
- Ви хочете один інструмент для всього (Redux DevTools показує і клієнтський, і серверний стан)
- Вам потрібна декларативна система інвалідації через теги
- Ви цінуєте автоматичну генерацію типізованих хуків
TanStack Query обирайте, якщо:
- У вас немає Redux і не планується (використовуєте Zustand, Jotai або Context API)
- Вам потрібна більша гнучкість у налаштуванні кешування
- Ви хочете мінімальний розмір бандлу
В контексті цього курсу ми вивчаємо RTK Query, бо він ідеально інтегрується з Redux екосистемою, яку ми вже опанували у попередніх розділах.
Якщо ваш сервер використовує GraphQL замість REST API, найкращим вибором буде Apollo Client. Він має вбудовану підтримку GraphQL запитів, автоматичну нормалізацію кешу та інтеграцію з GraphQL схемою.
Проте RTK Query теж може працювати з GraphQL! Потрібно лише налаштувати кастомний baseQuery:
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const graphqlApi = createApi({
baseQuery: fetchBaseQuery({
baseUrl: 'https://api.example.com/graphql',
prepareHeaders: (headers) => {
headers.set('Content-Type', 'application/json')
return headers
},
}),
endpoints: (builder) => ({
getTrips: builder.query({
query: () => ({
body: JSON.stringify({
query: `
query GetTrips {
trips {
id
title
region
}
}
`,
}),
method: 'POST',
}),
}),
}),
})
Перші кроки: Налаштування createApi
Тепер, коли ми розуміємо проблему та її рішення, давайте створимо наш перший RTK Query API. Почнемо з найпростішої конфігурації та поступово додаватимемо функціональність.
Крок 1: Встановлення залежностей
Спочатку переконайтесь, що у вашому проєкті встановлено Redux Toolkit:
npm install @reduxjs/toolkit react-redux
yarn add @reduxjs/toolkit react-redux
pnpm add @reduxjs/toolkit react-redux
@reduxjs/toolkit — не потрібно встановлювати окремий пакет! Якщо ви вже використовуєте Redux Toolkit у своєму проєкті, RTK Query вже доступний.Крок 2: Найпростіша конфігурація
Створимо мінімальний API для роботи з публічним тестовим сервером:
// src/services/api.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
// Визначаємо структуру даних
interface Post {
id: number
title: string
body: string
}
// Створюємо API
export const api = createApi({
// Назва слайса у Redux store
reducerPath: 'api',
// Базова конфігурація запитів
baseQuery: fetchBaseQuery({
baseUrl: 'https://jsonplaceholder.typicode.com'
}),
// Описуємо ендпоінти
endpoints: (builder) => ({
// Один простий GET-запит
getPosts: builder.query<Post[], void>({
query: () => '/posts',
}),
}),
})
// RTK Query автоматично створює хук!
export const { useGetPostsQuery } = api
Що тут відбувається?
createApi()— головна функція, яка створює весь APIreducerPath— ім'я, під яким дані зберігатимуться у Redux storebaseQuery— інструмент для виконання HTTP-запитів (вбудованийfetch)endpoints— список усіх операцій з серверомbuilder.query— описує операцію читання даних (GET)useGetPostsQuery— автоматично згенерований React-хук для використання у компонентах
Крок 3: Підключення до Redux Store
Тепер потрібно інтегрувати наш API у Redux:
// src/store/index.ts
import { configureStore } from '@reduxjs/toolkit'
import { setupListeners } from '@reduxjs/toolkit/query'
import { api } from '@/services/api'
export const store = configureStore({
reducer: {
// Додаємо автоматично згенерований редюсер
[api.reducerPath]: api.reducer,
},
middleware: (getDefaultMiddleware) =>
// Додаємо middleware для керування кешем та підписками
getDefaultMiddleware().concat(api.middleware),
})
// Увімкнути refetchOnFocus та refetchOnReconnect
setupListeners(store.dispatch)
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
Пояснення ключових моментів:
api.reducer — це автоматично згенерований Redux редюсер, який зберігає:
- Завантажені дані з сервера (кеш)
- Стан завантаження для кожного запиту (
isLoading,isFetching) - Помилки, якщо запити провалились
- Метадані про підписки (які компоненти зараз використовують які дані)
Ви ніколи не пишете цей редюсер вручну — RTK Query генерує його автоматично на основі вашого опису ендпоінтів.
Middleware (проміжний обробник) виконує важливі фонові завдання:
- Керування підписками: відслідковує, які компоненти використовують які дані
- Автоматичне очищення: видаляє застарілі дані з пам'яті через певний час
- Дедуплікація запитів: якщо 5 компонентів запитують одні дані одночасно — виконує лише 1 реальний HTTP-запит
- Refetch логіка: автоматично оновлює дані при поверненні з фону або відновленні інтернету
Ця функція підключає слухачів подій для:
- Focus detection: коли користувач повертається в застосунок з фону
- Network detection: коли інтернет з'єднання відновлюється після розриву
Завдяки цьому RTK Query може автоматично оновлювати дані у потрібний момент без вашого коду!
Крок 4: Використання у компоненті
Тепер можемо використати наш API у React Native компоненті:
// app/screens/PostsScreen.tsx
import React from 'react'
import { View, Text, FlatList, ActivityIndicator, StyleSheet } from 'react-native'
import { useGetPostsQuery } from '@/services/api'
export function PostsScreen() {
// Викликаємо хук — запит виконається автоматично!
const { data, isLoading, error } = useGetPostsQuery()
// Показуємо індикатор завантаження
if (isLoading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#3b82f6" />
<Text style={styles.loadingText}>Завантаження постів...</Text>
</View>
)
}
// Обробка помилки
if (error) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>
Помилка: {error.toString()}
</Text>
</View>
)
}
// Відображаємо дані
return (
<FlatList
data={data}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item }) => (
<View style={styles.card}>
<Text style={styles.title}>{item.title}</Text>
<Text style={styles.body}>{item.body}</Text>
</View>
)}
/>
)
}
const styles = StyleSheet.create({
center: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
loadingText: {
marginTop: 12,
fontSize: 14,
color: '#64748b',
},
errorText: {
fontSize: 16,
color: '#dc2626',
textAlign: 'center',
},
card: {
backgroundColor: '#ffffff',
padding: 16,
marginHorizontal: 16,
marginTop: 12,
borderRadius: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
title: {
fontSize: 16,
fontWeight: '700',
color: '#0f172a',
marginBottom: 8,
},
body: {
fontSize: 14,
color: '#64748b',
lineHeight: 20,
},
})
Магія RTK Query в дії:
Компонент монтується
React викликає useGetPostsQuery() при рендері компонента
Автоматичний запит
RTK Query перевіряє: є дані у кеші? Якщо немає — відправляє HTTP GET запит
Оновлення стану
Поки запит виконується: isLoading = true, data = undefined
Отримання результату
Коли сервер відповідає: isLoading = false, data = [...] — компонент ререндериться
Кешування
Дані зберігаються у Redux store. Наступний виклик useGetPostsQuery() поверне їх миттєво!
Розуміння архітектури: що генерує createApi
Давайте візуалізуємо, що відбувається під капотом:
Поглиблене налаштування: Параметри createApi
Тепер розглянемо всі важливі параметри конфігурації детальніше:
// src/services/tripsApi.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import { Platform } from 'react-native'
import Constants from 'expo-constants'
/**
* Функція для визначення правильної URL адреси в залежності від середовища.
* Це критично для React Native, бо localhost працює по-різному:
* - iOS Simulator: localhost = комп'ютер розробника
* - Android Emulator: localhost = сам емулятор (потрібно 10.0.2.2)
* - Реальний пристрій: потрібна IP-адреса комп'ютера в локальній мережі
*/
const getBaseUrl = (): string => {
// Для Android емулятора використовуємо спеціальну адресу
if (Platform.OS === 'android') {
return 'http://10.0.2.2:3000'
}
// Для реальних пристроїв отримуємо IP з Expo
const hostUri = Constants.expoConfig?.hostUri
if (hostUri) {
const ip = hostUri.split(':').shift()
if (ip) return `http://${ip}:3000`
}
// Fallback для iOS Simulator
return 'http://localhost:3000'
}
interface Trip {
id: string
title: string
region: string
startDate: string
}
export const tripsApi = createApi({
// 1️⃣ REDUCER PATH: Унікальне ім'я для Redux store
reducerPath: 'tripsApi',
// 2️⃣ BASE QUERY: Налаштування HTTP-клієнта
baseQuery: fetchBaseQuery({
baseUrl: getBaseUrl(),
// Таймаут для запитів (мс)
timeout: 10000,
// Функція для додавання заголовків до кожного запиту
prepareHeaders: (headers, { getState }) => {
// Отримуємо токен авторизації зі стору
const token = (getState() as any).auth?.token
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
headers.set('Accept', 'application/json')
headers.set('Content-Type', 'application/json')
return headers
},
}),
// 3️⃣ TAG TYPES: Типи сутностей для інвалідації кешу
tagTypes: ['Trip', 'User'],
// 4️⃣ REFETCH ON FOCUS: Оновлювати дані при поверненні в застосунок
refetchOnFocus: true,
// 5️⃣ REFETCH ON RECONNECT: Оновлювати при відновленні інтернету
refetchOnReconnect: true,
// 6️⃣ KEEP UNUSED DATA FOR: Скільки секунд зберігати невикористані дані
keepUnusedDataFor: 60, // 1 хвилина
// 7️⃣ ENDPOINTS: Опис API ендпоінтів
endpoints: (builder) => ({
getTrips: builder.query<Trip[], void>({
query: () => '/trips',
}),
}),
})
export const { useGetTripsQuery } = tripsApi
Детальний розбір кожного параметра:
Це ім'я, під яким дані RTK Query зберігатимуться у Redux store.
// У store буде створено такий стан:
{
tripsApi: { // ← ваш reducerPath
queries: { /* закешовані дані */ },
mutations: { /* стани мутацій */ },
subscriptions: { /* активні підписки */ }
}
}
Правила:
- Має бути унікальним для кожного API
- Використовуйте camelCase
- Якщо у вас один API на весь проєкт — можна назвати просто
'api' - Якщо кілька API (наприклад, окремо для авторизації, платежів) — давайте описові назви
fetchBaseQuery — це вбудований HTTP-клієнт на основі fetch. Він автоматично:
- Додає
baseUrlдо всіх запитів - Обробляє JSON серіалізацію/десеріалізацію
- Кидає помилки для статусів 4xx, 5xx
- Підтримує кастомні заголовки через
prepareHeaders
prepareHeaders викликається перед кожним запитом і дозволяє:
- Додати токени авторизації
- Встановити Content-Type
- Додати кастомні заголовки (версія API, мова інтерфейсу тощо)
prepareHeaders: (headers, { getState, endpoint }) => {
// Доступ до всього Redux state
const state = getState() as RootState
// Можна додавати різні токени для різних ендпоінтів
if (endpoint === 'getSecureData') {
headers.set('X-Special-Token', state.auth.specialToken)
}
return headers
}
Теги (tags) — це ключова концепція RTK Query для автоматичної інвалідації кешу.
Як це працює:
- Query ендпоінти надають теги (
providesTags) - Mutation ендпоінти інвалідують теги (
invalidatesTags) - Коли тег інвалідується — всі запити з цим тегом автоматично перезавантажуються
tagTypes: ['Trip', 'User', 'Comment']
// Детально розглянемо це у наступних розділах
Мобільний сценарій:
- Користувач відкриває застосунок, бачить список поїздок
- Згортає застосунок (переходить в Instagram на 10 хвилин)
- Повертається назад
Поведінка:
refetchOnFocus: false— покаже старі дані (з кешу)refetchOnFocus: true— автоматично оновить дані з сервера
true, бо користувачі часто перемикаються між додатками, і дані можуть застаріти.::
Мобільний сценарій:
- Користувач їде в метро (втрачає інтернет)
- Застосунок показує закешовані дані
- Користувач виходить з метро (інтернет відновлюється)
Поведінка:
refetchOnReconnect: false— продовжує показувати старі даніrefetchOnReconnect: true— автоматично оновлює всі активні запити
Визначає скільки секунд зберігати дані після того, як жоден компонент їх не використовує.
Приклад:
keepUnusedDataFor: 60 // 60 секунд = 1 хвилина
Життєвий цикл:
- Користувач відкриває екран списку →
useGetTripsQuery()створює підписку - Дані завантажуються і кешуються
- Користувач йде на інший екран → підписка видаляється
- Таймер запускається: через 60 секунд дані будуть видалені з пам'яті
- Якщо користувач повернеться на екран протягом 60 сек — дані будуть миттєво доступні з кешу!
Рекомендації:
- Для часто використовуваних даних:
300(5 хвилин) - Для рідко використовуваних:
60(1 хвилина) - Для критичних даних (профіль):
600(10 хвилин)
::
Авторизація у мобільному застосунку
Більшість реальних застосунків потребують авторизації — користувач входить один раз, і застосунок запам'ятовує його. Давайте розберемо, як працює авторизація у мобільних додатках та як інтегрувати її з RTK Query.
Базова авторизація: Додавання токена до запитів
Найпростіший випадок — у вас є токен (token), який потрібно відправляти з кожним запитом у заголовку Authorization.
Крок 1: Зберігаємо токен у Redux
Спочатку створимо простий слайс для авторизації:
// src/store/authSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
interface AuthState {
token: string | null
user: { id: string; email: string } | null
}
const initialState: AuthState = {
token: null,
user: null,
}
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
// Зберігаємо токен після успішного логіну
setCredentials: (state, action: PayloadAction<{ token: string; user: any }>) => {
state.token = action.payload.token
state.user = action.payload.user
},
// Видаляємо токен при виході
logout: (state) => {
state.token = null
state.user = null
},
},
})
export const { setCredentials, logout } = authSlice.actions
export default authSlice.reducer
Крок 2: Налаштовуємо prepareHeaders
Тепер змусимо RTK Query автоматично додавати токен до кожного запиту:
// src/services/api.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import type { RootState } from '@/store'
export const api = createApi({
baseQuery: fetchBaseQuery({
baseUrl: 'https://api.example.com',
// Функція викликається перед кожним запитом
prepareHeaders: (headers, { getState }) => {
// Отримуємо токен зі Redux store
const token = (getState() as RootState).auth.token
// Якщо токен є — додаємо його до заголовків
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
return headers
},
}),
endpoints: (builder) => ({
// Тепер усі запити автоматично отримають заголовок Authorization
getProfile: builder.query({
query: () => '/user/profile',
}),
getTrips: builder.query({
query: () => '/trips',
}),
}),
})
Що відбувається:
Користувач логінитися
Токен зберігається у Redux через dispatch(setCredentials({ token, user }))
Компонент викликає useGetProfileQuery()
RTK Query готується виконати HTTP-запит
Спрацьовує prepareHeaders
Функція читає токен зі стору та додає до заголовків
Запит відправляється
GET /user/profile
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Проблема: Токен застарів (401 Unauthorized)
У реальних системах токени мають обмежений час життя. Типовий сценарій:
// Access Token живе 15 хвилин
// Користувач відкриває застосунок через 20 хвилин
// Запит повертає помилку:
{
status: 401,
data: { message: "Token expired" }
}
Наївне рішення — попросити користувача ввести пароль знову. Але це жахливий UX для мобільного застосунку!
Рішення: Refresh Token Pattern
Сучасні API використовують дві пари токенів:
// Живе 15-30 хвилин
// Використовується для звичайних запитів
{
"type": "access",
"token": "eyJhbGc...",
"expiresIn": 900 // 15 хвилин у секундах
}
// Живе 7-30 днів
// Використовується ТІЛЬКИ для отримання нового Access Token
{
"type": "refresh",
"token": "dGhpcyBpcyByZWZyZXNo...",
"expiresIn": 2592000 // 30 днів
}
Логіка роботи:
Реалізація: Автоматичне оновлення токенів
Тепер напишемо код, який автоматично обробляє цю логіку:
// src/services/baseQueryWithReauth.ts
import { fetchBaseQuery } from '@reduxjs/toolkit/query'
import type { BaseQueryFn, FetchArgs, FetchBaseQueryError } from '@reduxjs/toolkit/query'
import { setCredentials, logout } from '@/store/authSlice'
import type { RootState } from '@/store'
// Базовий запит з токеном
const baseQuery = fetchBaseQuery({
baseUrl: 'https://api.example.com',
prepareHeaders: (headers, { getState }) => {
const token = (getState() as RootState).auth.token
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
return headers
},
})
// Обгортка, яка додає логіку refresh
export const baseQueryWithReauth: BaseQueryFn<
string | FetchArgs,
unknown,
FetchBaseQueryError
> = async (args, api, extraOptions) => {
// Спробуємо виконати звичайний запит
let result = await baseQuery(args, api, extraOptions)
// Якщо отримали 401 — токен застарів
if (result.error && result.error.status === 401) {
console.log('🔄 Access token застарів, оновлюємо...')
// Отримуємо refresh token зі стору
const refreshToken = (api.getState() as RootState).auth.refreshToken
// Запитуємо новий access token
const refreshResult = await baseQuery(
{
url: '/auth/refresh',
method: 'POST',
body: { refreshToken },
},
api,
extraOptions,
)
if (refreshResult.data) {
// ✅ Успішно оновили токен
console.log('✅ Токен оновлено успішно')
// Зберігаємо нові токени у Redux
api.dispatch(setCredentials(refreshResult.data as any))
// Повторюємо початковий запит з новим токеном
result = await baseQuery(args, api, extraOptions)
} else {
// ❌ Не вдалося оновити токен — розлогінюємо користувача
console.log('❌ Не вдалося оновити токен, виходимо')
api.dispatch(logout())
}
}
return result
}
Використання у createApi
// src/services/api.ts
import { createApi } from '@reduxjs/toolkit/query/react'
import { baseQueryWithReauth } from './baseQueryWithReauth'
export const api = createApi({
// Використовуємо нашу розумну baseQuery замість звичайної
baseQuery: baseQueryWithReauth,
endpoints: (builder) => ({
getProfile: builder.query({
query: () => '/user/profile',
}),
getTrips: builder.query({
query: () => '/trips',
}),
}),
})
Тепер все працює автоматично! Користувач може не заходити у застосунок тиждень — при першому запиті токен оновиться прозоро.
Просунута проблема: Паралельні запити (Race Condition)
Уявіть ситуацію:
function DashboardScreen() {
// 3 запити виконуються ОДНОЧАСНО при монтуванні екрана
const { data: profile } = useGetProfileQuery()
const { data: trips } = useGetTripsQuery()
const { data: stats } = useGetStatsQuery()
// Якщо access token застарів, всі 3 запити отримають 401!
// Наївна реалізація спробує оновити токен 3 РАЗИ паралельно
}
Проблема: 3 паралельні POST-запити на /auth/refresh можуть:
- Створити навантаження на сервер
- Викликати race condition (перший токен перезаписується другим)
- Призвести до інвалідації refresh token деякими backend системами
Рішення: Mutex (взаємне блокування)
Mutex (Mutual Exclusion) — патерн, який гарантує, що тільки один запит може виконувати refresh у конкретний момент часу.
Спочатку встановимо бібліотеку:
npm install async-mutex
Тепер оновимо нашу логіку:
// src/services/baseQueryWithReauth.ts
import { fetchBaseQuery } from '@reduxjs/toolkit/query'
import type { BaseQueryFn, FetchArgs, FetchBaseQueryError } from '@reduxjs/toolkit/query'
import { Mutex } from 'async-mutex'
import { setCredentials, logout } from '@/store/authSlice'
import type { RootState } from '@/store'
// Створюємо єдиний mutex для всього застосунку
const mutex = new Mutex()
const baseQuery = fetchBaseQuery({
baseUrl: 'https://api.example.com',
prepareHeaders: (headers, { getState }) => {
const token = (getState() as RootState).auth.token
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
return headers
},
})
export const baseQueryWithReauth: BaseQueryFn<
string | FetchArgs,
unknown,
FetchBaseQueryError
> = async (args, api, extraOptions) => {
// 🔒 Чекаємо, поки mutex буде вільний
// Якщо хтось вже оновлює токен — почекаємо
await mutex.waitForUnlock()
let result = await baseQuery(args, api, extraOptions)
if (result.error && result.error.status === 401) {
// Перевіряємо: може інший запит вже захопив mutex?
if (!mutex.isLocked()) {
// 🔒 Захоплюємо mutex — тепер тільки ми можемо оновлювати токен
const release = await mutex.acquire()
try {
console.log('🔄 Оновлюю токен (mutex захоплено)')
const refreshToken = (api.getState() as RootState).auth.refreshToken
const refreshResult = await baseQuery(
{
url: '/auth/refresh',
method: 'POST',
body: { refreshToken },
},
api,
extraOptions,
)
if (refreshResult.data) {
console.log('✅ Токен оновлено, звільняю mutex')
api.dispatch(setCredentials(refreshResult.data as any))
// Повторюємо початковий запит
result = await baseQuery(args, api, extraOptions)
} else {
console.log('❌ Refresh провалився, виходимо')
api.dispatch(logout())
}
} finally {
// 🔓 ОБОВ'ЯЗКОВО звільняємо mutex
release()
}
} else {
// Інший запит вже оновлює токен
// Просто чекаємо його завершення та повторюємо запит
console.log('⏳ Чекаю завершення refresh від іншого запиту...')
await mutex.waitForUnlock()
// Токен вже оновлено — повторюємо запит
result = await baseQuery(args, api, extraOptions)
}
}
return result
}
Як це працює:
Читання даних: Query Endpoints
Тепер, коли ми розуміємо базову конфігурацію та авторизацію, давайте навчимося описувати Query Endpoints — операції читання даних з сервера. Почнемо з найпростішого прикладу та поступово додаватимемо складність.
Рівень 1: Найпростіший GET-запит без параметрів
Це базовий випадок — просто отримати список даних з сервера:
// src/services/api.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
interface Post {
id: number
title: string
body: string
}
export const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: 'https://jsonplaceholder.typicode.com' }),
endpoints: (builder) => ({
// Query без аргументів — тип <ТипВідповіді, void>
getPosts: builder.query<Post[], void>({
query: () => '/posts',
}),
}),
})
export const { useGetPostsQuery } = api
Використання у компоненті:
function PostsScreen() {
// Виклик без аргументів
const { data, isLoading, error } = useGetPostsQuery()
if (isLoading) return <ActivityIndicator />
if (error) return <Text>Помилка завантаження</Text>
return (
<FlatList
data={data}
renderItem={({ item }) => <Text>{item.title}</Text>}
/>
)
}
Рівень 2: GET-запит з одним параметром
Тепер отримаємо конкретний пост за ID:
// src/services/api.ts
export const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: 'https://jsonplaceholder.typicode.com' }),
endpoints: (builder) => ({
// Аргумент — число (ID поста)
getPostById: builder.query<Post, number>({
query: (postId) => `/posts/${postId}`,
}),
}),
})
export const { useGetPostByIdQuery } = api
Використання:
function PostDetailScreen({ route }) {
const postId = route.params.id
// Передаємо ID як аргумент
const { data: post, isLoading } = useGetPostByIdQuery(postId)
if (isLoading) return <ActivityIndicator />
return (
<View>
<Text style={styles.title}>{post?.title}</Text>
<Text>{post?.body}</Text>
</View>
)
}
Що відбувається під капотом:
Компонент рендериться з postId = 5
RTK Query створює унікальний ключ кешу: getPostById(5)
Перевіряє кеш
Чи є дані для getPostById(5) у пам'яті?
Якщо немає — робить запит
GET /posts/5
Зберігає результат
Наступний виклик useGetPostByIdQuery(5) поверне дані з кешу миттєво!
useGetPostByIdQuery(10) — це інший кеш. RTK Query робить окремий запит для кожного унікального аргументу.Рівень 3: GET-запит зі складними параметрами (фільтри)
У реальних застосунках часто потрібно передавати кілька параметрів — фільтри, сортування, пагінацію. Для цього використовуємо об'єкт:
// src/services/tripsApi.ts
interface Trip {
id: string
title: string
region: string
startDate: string
}
// Інтерфейс для параметрів фільтрації
interface GetTripsParams {
region?: string
search?: string
sortBy?: 'date' | 'title'
limit?: number
}
export const tripsApi = createApi({
baseQuery: fetchBaseQuery({ baseUrl: 'http://localhost:3000' }),
endpoints: (builder) => ({
getTrips: builder.query<Trip[], GetTripsParams | void>({
query: (params) => {
// Якщо параметрів немає — просто повертаємо URL
if (!params) return '/trips'
// Будуємо query string з параметрів
const searchParams = new URLSearchParams()
if (params.region) searchParams.set('region', params.region)
if (params.search) searchParams.set('q', params.search)
if (params.sortBy) searchParams.set('_sort', params.sortBy)
if (params.limit) searchParams.set('_limit', params.limit.toString())
return `/trips?${searchParams.toString()}`
},
}),
}),
})
export const { useGetTripsQuery } = tripsApi
Використання з різними фільтрами:
function TripsListScreen() {
const [region, setRegion] = useState<string>('all')
const [search, setSearch] = useState('')
// Передаємо об'єкт з параметрами
const { data: trips, isLoading } = useGetTripsQuery({
region: region !== 'all' ? region : undefined,
search: search || undefined,
limit: 20,
})
return (
<View>
<Picker value={region} onValueChange={setRegion}>
<Picker.Item label="Усі регіони" value="all" />
<Picker.Item label="Європа" value="europe" />
<Picker.Item label="Азія" value="asia" />
</Picker>
<TextInput
placeholder="Пошук..."
value={search}
onChangeText={setSearch}
/>
<FlatList
data={trips}
renderItem={({ item }) => <TripCard trip={item} />}
/>
</View>
)
}
Як RTK Query кешує різні комбінації параметрів:
useGetTripsQuery({ region: 'europe' }) // Кеш #1
useGetTripsQuery({ region: 'asia' }) // Кеш #2
useGetTripsQuery({ region: 'europe', limit: 10 }) // Кеш #3
useGetTripsQuery() // Кеш #4 (без параметрів)
Кожна унікальна комбінація параметрів створює окремий запис у кеші!
"П" → GET /trips?q=П
"Па" → GET /trips?q=Па
"Пар" → GET /trips?q=Пар
"Пари" → GET /trips?q=Пари
"Париж" → GET /trips?q=Париж
Рівень 4: Трансформація відповіді (transformResponse)
Іноді сервер повертає дані у незручному форматі, і їх треба обробити перед збереженням у кеш:
// Сервер повертає:
{
"status": "success",
"data": {
"items": [...],
"total": 42,
"page": 1
}
}
// Але нам потрібен лише масив items
Рішення через transformResponse:
export const api = createApi({
endpoints: (builder) => ({
getTrips: builder.query<Trip[], void>({
query: () => '/trips',
// Трансформуємо відповідь перед збереженням у кеш
transformResponse: (response: { status: string; data: { items: Trip[] } }) => {
console.log('📦 Сирі дані з сервера:', response)
// Витягуємо лише масив items
return response.data.items
},
}),
}),
})
Тепер у компоненті data буде одразу масивом Trip[], а не вкладеним об'єктом!
Інші приклади використання transformResponse:
transformResponse: (response: Trip[]) => {
// Сортуємо за датою (найновіші спочатку)
return response.sort((a, b) =>
new Date(b.startDate).getTime() - new Date(a.startDate).getTime()
)
}
transformResponse: (response: Trip[]) => {
// Видаляємо поїздки з порожніми назвами
return response.filter(trip => trip.title && trip.title.trim().length > 0)
}
transformResponse: (response: Trip[]) => {
return response.map(trip => ({
...trip,
// Додаємо обчислюване поле "чи поїздка активна"
isActive: new Date(trip.startDate) > new Date(),
// Форматована дата
displayDate: new Date(trip.startDate).toLocaleDateString('uk-UA'),
}))
}
Розуміння результату хука: Що повертає useQuery
Кожен Query-хук повертає об'єкт з багатьма корисними полями. Розберемо їх детально:
const {
data, // Дані з кешу або сервера
error, // Об'єкт помилки (якщо щось пішло не так)
isLoading, // true ТІЛЬКИ при першому завантаженні
isFetching, // true при будь-якому запиті (включно з фоновим)
isSuccess, // true коли дані успішно завантажені
isError, // true при помилці
refetch, // Функція для примусового оновлення
currentData, // Дані поточного запиту (може бути undefined)
originalArgs, // Аргументи, з якими було викликано хук
} = useGetTripsQuery(params)
Різниця між isLoading та isFetching:
// Користувач вперше відкриває екран
const { data, isLoading, isFetching } = useGetTripsQuery()
console.log(isLoading) // true ← немає жодних даних
console.log(isFetching) // true ← запит виконується
console.log(data) // undefined ← ще нічого не завантажилось
// ✅ Показуємо повноекранний skeleton
if (isLoading) return <SkeletonLoader />
// Користувач повертається з іншого екрана
// RTK Query вирішує оновити дані (refetchOnFocus: true)
const { data, isLoading, isFetching } = useGetTripsQuery()
console.log(isLoading) // false ← є старі дані з кешу
console.log(isFetching) // true ← запит виконується у фоні
console.log(data) // [...] ← показуємо старі дані поки оновлюються нові
// ✅ Показуємо дані + маленький індикатор оновлення
return (
<>
{isFetching && <TopBarRefreshIndicator />}
<FlatList data={data} />
</>
)
// Користувач тягне список вниз (Pull-to-Refresh)
const { data, isLoading, isFetching, refetch } = useGetTripsQuery()
console.log(isLoading) // false ← дані вже є
console.log(isFetching) // true ← запит запущено через refetch()
// ✅ RefreshControl показує індикатор
return (
<FlatList
data={data}
refreshControl={
<RefreshControl
refreshing={isFetching}
onRefresh={refetch}
/>
}
/>
)
Правильна обробка станів у компоненті:
function TripsListScreen() {
const { data, isLoading, isFetching, isError, error, refetch } = useGetTripsQuery()
// 1. ПОВНОЕКРАННИЙ ЛОАДЕР: перше завантаження (немає даних)
if (isLoading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" />
<Text>Завантаження поїздок...</Text>
</View>
)
}
// 2. ЕКРАН ПОМИЛКИ: не вдалося завантажити
if (isError) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>
Помилка: {error?.toString()}
</Text>
<Button title="Спробувати знову" onPress={refetch} />
</View>
)
}
// 3. РОБОЧИЙ СТАН: показуємо дані
return (
<View style={styles.container}>
{/* Тонкий індикатор фонового оновлення */}
{isFetching && !isLoading && (
<View style={styles.topBar}>
<ActivityIndicator size="small" />
<Text style={styles.topBarText}>Оновлення...</Text>
</View>
)}
<FlatList
data={data}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <TripCard trip={item} />}
{/* Pull-to-Refresh */}
refreshControl={
<RefreshControl
refreshing={isFetching && !isLoading}
onRefresh={refetch}
tintColor="#3b82f6"
/>
}
/>
</View>
)
}
Рівень 5: Lazy Queries (запити на вимогу)
Іноді потрібно запустити запит НЕ автоматично при монтуванні, а у відповідь на дію користувача (наприклад, натискання кнопки):
export const { useGetTripByIdQuery, useLazyGetTripByIdQuery } = tripsApi
Різниця між звичайним та lazy хуком:
function TripScreen({ tripId }) {
// Запит виконується ОДРАЗУ при рендері
const { data } = useGetTripByIdQuery(tripId)
return <Text>{data?.title}</Text>
}
function SearchScreen() {
const [tripId, setTripId] = useState('')
// Запит НЕ виконується автоматично
// Повертає [тригер-функцію, результат]
const [fetchTrip, { data, isLoading }] = useLazyGetTripByIdQuery()
const handleSearch = () => {
// Запит виконається тільки тут!
fetchTrip(tripId)
}
return (
<View>
<TextInput
value={tripId}
onChangeText={setTripId}
placeholder="Введіть ID поїздки"
/>
<Button title="Знайти" onPress={handleSearch} />
{isLoading && <ActivityIndicator />}
{data && <Text>{data.title}</Text>}
</View>
)
}
Типові випадки використання Lazy Queries:
- 🔍 Пошук за введенням користувача
- 📤 Завантаження додаткових даних при натисканні "Показати більше"
- 🎯 Запити, які залежать від результату попереднього запиту
- 🔐 Перевірка даних при submit форми
Рівень 6: Оптимізація рендерів через selectFromResult
За замовчуванням компонент ререндериться при будь-якій зміні у відповіді. Але іноді потрібна лише частина даних:
// ❌ ПРОБЛЕМА: Компонент ререндериться при зміні БУДЬ-ЯКОЇ поїздки у списку
function TripTitle({ tripId }) {
const { data: trips } = useGetTripsQuery()
const trip = trips?.find(t => t.id === tripId)
return <Text>{trip?.title}</Text>
}
Рішення:
// ✅ ОПТИМІЗАЦІЯ: Ререндер тільки при зміні цієї конкретної поїздки
function TripTitle({ tripId }) {
const { trip } = useGetTripsQuery(undefined, {
// selectFromResult дозволяє підписатись лише на частину даних
selectFromResult: ({ data }) => ({
trip: data?.find(t => t.id === tripId),
}),
})
return <Text>{trip?.title}</Text>
}
Тепер компонент оновиться тільки якщо змінилась поїздка з ID=tripId, а не при зміні будь-якої іншої поїздки!
selectFromResult для великих списків або коли один запит використовується багатьма компонентами з різними потребами у даних.Зміна даних: Mutation Endpoints
Тепер, коли ми навчилися читати дані через Query, час навчитися їх змінювати. Mutations (мутації) — це операції, які модифікують дані на сервері: створення, оновлення, видалення.
Ключова різниця між Query та Mutation
// ✅ Виконується АВТОМАТИЧНО при монтуванні
// ✅ Результат кешується
// ✅ Можна викликати багато разів без побічних ефектів
const { data } = useGetTripsQuery()
// ❌ НЕ виконується автоматично
// ❌ Результат НЕ кешується (немає сенсу)
// ⚠️ Кожен виклик змінює дані на сервері!
const [createTrip, { isLoading }] = useCreateTripMutation()
Чому мутації не кешуються?
Уявіть, що ви натискаєте "Створити поїздку" двічі — має створитись дві різні поїздки, а не повернутись результат першого запиту. Мутації мають побічні ефекти (side effects) — вони змінюють стан сервера.
Рівень 1: Найпростіша мутація (POST)
Почнемо з створення нової поїздки:
// src/services/tripsApi.ts
interface Trip {
id: string
title: string
region: string
startDate: string
}
// Дані для створення (без ID — його поверне сервер)
interface CreateTripDto {
title: string
region: string
startDate: string
}
export const tripsApi = createApi({
baseQuery: fetchBaseQuery({ baseUrl: 'http://localhost:3000' }),
tagTypes: ['Trip'],
endpoints: (builder) => ({
// Query для читання
getTrips: builder.query<Trip[], void>({
query: () => '/trips',
providesTags: ['Trip'],
}),
// Mutation для створення
createTrip: builder.mutation<Trip, CreateTripDto>({
query: (newTrip) => ({
url: '/trips',
method: 'POST',
body: newTrip,
}),
invalidatesTags: ['Trip'], // Оновити список після створення
}),
}),
})
export const { useGetTripsQuery, useCreateTripMutation } = tripsApi
Використання у компоненті:
// app/screens/CreateTripScreen.tsx
import { useState } from 'react'
import { View, TextInput, Button, Text, ActivityIndicator } from 'react-native'
import { useCreateTripMutation } from '@/services/tripsApi'
import { useRouter } from 'expo-router'
export function CreateTripScreen() {
const router = useRouter()
// Хук мутації повертає [функцію-тригер, стан запиту]
const [createTrip, { isLoading, error, isSuccess }] = useCreateTripMutation()
const [title, setTitle] = useState('')
const [region, setRegion] = useState('')
const handleSubmit = async () => {
try {
// Викликаємо мутацію вручну
const result = await createTrip({
title,
region,
startDate: new Date().toISOString(),
}).unwrap() // .unwrap() кидає помилку при провалі
console.log('✅ Поїздку створено:', result)
router.back() // Повертаємось на попередній екран
} catch (err) {
console.error('❌ Помилка створення:', err)
}
}
return (
<View style={{ padding: 20 }}>
<TextInput
placeholder="Назва поїздки"
value={title}
onChangeText={setTitle}
style={styles.input}
/>
<TextInput
placeholder="Регіон"
value={region}
onChangeText={setRegion}
style={styles.input}
/>
<Button
title={isLoading ? "Створення..." : "Створити поїздку"}
onPress={handleSubmit}
disabled={isLoading || !title || !region}
/>
{error && (
<Text style={styles.error}>
Помилка: {error.toString()}
</Text>
)}
</View>
)
}
Життєвий цикл мутації:
Розуміння результату хука мутації
Mutation хук повертає масив з двох елементів:
const [
trigger, // Функція для запуску мутації
result // Об'єкт зі станом
] = useCreateTripMutation()
Детальний розбір:
const [createTrip, {
data, // Відповідь сервера після успіху
error, // Об'єкт помилки
isLoading, // true під час виконання запиту
isSuccess, // true після успішного завершення
isError, // true при помилці
reset, // Функція для скидання стану (isSuccess, error)
}] = useCreateTripMutation()
Приклад повного життєвого циклу:
function CreateTripScreen() {
const [createTrip, { data, isLoading, isSuccess, isError, error, reset }] = useCreateTripMutation()
useEffect(() => {
if (isSuccess) {
// Показуємо toast повідомлення
Toast.show({
type: 'success',
text1: 'Успіх!',
text2: `Поїздку "${data?.title}" створено`,
})
// Через 2 секунди скидаємо стан та повертаємось
setTimeout(() => {
reset() // Очищає isSuccess та data
router.back()
}, 2000)
}
}, [isSuccess])
const handleSubmit = () => {
createTrip({ title: 'Париж', region: 'europe', startDate: '2026-09-01' })
}
return (
<View>
<Button title="Створити" onPress={handleSubmit} disabled={isLoading} />
{isLoading && <ActivityIndicator />}
{isSuccess && <Text style={styles.success}>✅ Успішно створено!</Text>}
{isError && <Text style={styles.error}>❌ {error.toString()}</Text>}
</View>
)
}
Рівень 2: PATCH/PUT (Оновлення існуючих даних)
Для оновлення поїздки потрібно передати ID та нові дані:
// src/services/tripsApi.ts
interface UpdateTripDto {
id: string
title?: string
region?: string
}
export const tripsApi = createApi({
// ... попередня конфігурація
endpoints: (builder) => ({
updateTrip: builder.mutation<Trip, UpdateTripDto>({
query: ({ id, ...patch }) => ({
url: `/trips/${id}`,
method: 'PATCH', // або 'PUT' для повного оновлення
body: patch,
}),
invalidatesTags: (_result, _error, { id }) => [
{ type: 'Trip', id }, // Інвалідувати конкретну поїздку
],
}),
}),
})
export const { useUpdateTripMutation } = tripsApi
Використання:
function EditTripScreen({ tripId }) {
const [updateTrip, { isLoading }] = useUpdateTripMutation()
const [title, setTitle] = useState('')
const handleSave = async () => {
try {
await updateTrip({
id: tripId,
title: title,
}).unwrap()
Toast.show({ text1: 'Зміни збережено!' })
} catch (err) {
Toast.show({ type: 'error', text1: 'Не вдалося зберегти' })
}
}
return (
<View>
<TextInput value={title} onChangeText={setTitle} />
<Button title="Зберегти" onPress={handleSave} disabled={isLoading} />
</View>
)
}
Рівень 3: DELETE (Видалення даних)
export const tripsApi = createApi({
endpoints: (builder) => ({
deleteTrip: builder.mutation<{ success: boolean }, string>({
query: (tripId) => ({
url: `/trips/${tripId}`,
method: 'DELETE',
}),
invalidatesTags: (_result, _error, tripId) => [
{ type: 'Trip', id: tripId },
{ type: 'Trip', id: 'LIST' }, // Оновити весь список
],
}),
}),
})
Використання з підтвердженням:
function TripCard({ trip }) {
const [deleteTrip, { isLoading }] = useDeleteTripMutation()
const handleDelete = () => {
Alert.alert(
'Підтвердження',
`Видалити поїздку "${trip.title}"?`,
[
{ text: 'Скасувати', style: 'cancel' },
{
text: 'Видалити',
style: 'destructive',
onPress: async () => {
try {
await deleteTrip(trip.id).unwrap()
Toast.show({ text1: 'Поїздку видалено' })
} catch (err) {
Toast.show({ type: 'error', text1: 'Помилка видалення' })
}
},
},
]
)
}
return (
<View style={styles.card}>
<Text>{trip.title}</Text>
<Button
title={isLoading ? "..." : "🗑️"}
onPress={handleDelete}
disabled={isLoading}
/>
</View>
)
}
Типові помилки та як їх уникнути
Неправильно:
function BadComponent() {
const [createTrip] = useCreateTripMutation()
// ❌ НІКОЛИ НЕ РОБІТЬ ТАК!
createTrip({ title: 'Test' }) // Виконається при кожному рендері!
return <View />
}
Правильно:
function GoodComponent() {
const [createTrip] = useCreateTripMutation()
// ✅ Мутацію викликаємо у відповідь на подію
const handlePress = () => {
createTrip({ title: 'Test' })
}
return <Button onPress={handlePress} />
}
Неправильно:
const handleSubmit = () => {
// ❌ Якщо сервер поверне 500 — користувач нічого не побачить
createTrip({ title })
}
Правильно:
const [createTrip, { isError, error }] = useCreateTripMutation()
const handleSubmit = async () => {
try {
await createTrip({ title }).unwrap()
Toast.show({ text1: 'Успіх!' })
} catch (err) {
Toast.show({ type: 'error', text1: 'Помилка створення' })
}
}
// Або просто показуємо error у UI:
{isError && <Text style={styles.error}>{error.toString()}</Text>}
Проблема:
function CreateScreen() {
const [createTrip, { isSuccess }] = useCreateTripMutation()
useEffect(() => {
if (isSuccess) {
router.back() // Повертаємось назад
}
}, [isSuccess])
// Якщо користувач повернеться на цей екран знову,
// isSuccess все ще буде true! Екран одразу закриється
}
Правильно:
function CreateScreen() {
const [createTrip, { isSuccess, reset }] = useCreateTripMutation()
useEffect(() => {
if (isSuccess) {
router.back()
}
}, [isSuccess])
// Скидаємо стан при розмонтуванні екрана
useEffect(() => {
return () => reset()
}, [])
}
Просунута техніка: Отримання даних одразу після мутації
Іноді потрібно використати результат мутації для навігації або іншої логіки:
function CreateTripScreen() {
const router = useRouter()
const [createTrip] = useCreateTripMutation()
const handleSubmit = async () => {
try {
// .unwrap() повертає data або кидає помилку
const createdTrip = await createTrip({
title: 'Париж',
region: 'europe',
startDate: '2026-09-01',
}).unwrap()
// Тепер можемо використати дані
console.log('Створено поїздку з ID:', createdTrip.id)
// Навігація на сторінку деталей нової поїздки
router.push(`/trips/${createdTrip.id}`)
} catch (err) {
console.error('Помилка:', err)
}
}
return <Button title="Створити" onPress={handleSubmit} />
}
Порівняння підходів: unwrap() vs перевірка стану
const [createTrip] = useCreateTripMutation()
const handleSubmit = async () => {
try {
const result = await createTrip(data).unwrap()
// Код виконається тільки при успіху
console.log('Успіх:', result)
router.back()
} catch (error) {
// Код виконається при помилці
console.error('Помилка:', error)
Toast.show({ type: 'error', text1: 'Не вдалося створити' })
}
}
const [createTrip, { isSuccess, isError, error }] = useCreateTripMutation()
const handleSubmit = () => {
createTrip(data)
}
useEffect(() => {
if (isSuccess) {
console.log('Успіх!')
router.back()
}
}, [isSuccess])
useEffect(() => {
if (isError) {
console.error('Помилка:', error)
Toast.show({ type: 'error', text1: 'Не вдалося створити' })
}
}, [isError])
Коли використовувати кожен підхід:
- ✅ unwrap() + try/catch — для простої лінійної логіки, коли потрібно щось зробити одразу після успіху
- ✅ isSuccess/isError — для складніших UI станів, коли потрібно показати індикатори завантаження, анімації тощо
Система тегів: Автоматична інвалідація кешу
Ви вже бачили providesTags та invalidatesTags у попередніх прикладах. Тепер розберемо цю систему детально — це одна з найпотужніших фішок RTK Query, яка робить синхронізацію даних повністю автоматичною.
Проблема, яку вирішують теги
Уявіть застосунок без системи тегів:
// Екран списку поїздок
function TripsListScreen() {
const { data: trips } = useGetTripsQuery()
// Показує 5 поїздок
}
// Екран створення нової поїздки
function CreateTripScreen() {
const [createTrip] = useCreateTripMutation()
const handleSubmit = async () => {
await createTrip({ title: 'Париж' })
router.back() // Повертаємось на список
}
}
Що побачить користувач після створення?
❌ Список все ще показує 5 старих поїздок (нову не видно!)
Чому? RTK Query не знає, що дані застаріли. Список закешований і не оновлюється автоматично.
Наївне рішення:
const handleSubmit = async () => {
await createTrip({ title: 'Париж' })
// Примусово оновлюємо список вручну
dispatch(tripsApi.util.invalidateQuery('getTrips'))
router.back()
}
Але це означає, що у кожному місці, де ви створюєте/змінюєте/видаляєте поїздки, треба пам'ятати інвалідувати правильні запити. При 10 ендпоінтах це перетворюється на кошмар!
Рішення: Декларативна система тегів
Замість ручної інвалідації, ми описуємо зв'язки між даними декларативно:
export const tripsApi = createApi({
tagTypes: ['Trip'], // Реєструємо типи тегів
endpoints: (builder) => ({
// Query НАДАЄ дані з тегом 'Trip'
getTrips: builder.query<Trip[], void>({
query: () => '/trips',
providesTags: ['Trip'],
}),
// Mutation ІНВАЛІДУЄ тег 'Trip'
createTrip: builder.mutation<Trip, CreateTripDto>({
query: (newTrip) => ({
url: '/trips',
method: 'POST',
body: newTrip,
}),
invalidatesTags: ['Trip'],
}),
}),
})
Магія:
Тепер при створенні поїздки список автоматично оновиться без жодного ручного коду!
Рівень 1: Прості теги (за типом)
Це найпростіший випадок — один тег для всього:
export const tripsApi = createApi({
tagTypes: ['Trip'],
endpoints: (builder) => ({
getTrips: builder.query<Trip[], void>({
query: () => '/trips',
providesTags: ['Trip'], // Надає тег
}),
createTrip: builder.mutation<Trip, CreateTripDto>({
query: (data) => ({ url: '/trips', method: 'POST', body: data }),
invalidatesTags: ['Trip'], // Інвалідує тег
}),
updateTrip: builder.mutation<Trip, { id: string; title: string }>({
query: ({ id, ...data }) => ({ url: `/trips/${id}`, method: 'PATCH', body: data }),
invalidatesTags: ['Trip'], // Інвалідує тег
}),
deleteTrip: builder.mutation<void, string>({
query: (id) => ({ url: `/trips/${id}`, method: 'DELETE' }),
invalidatesTags: ['Trip'], // Інвалідує тег
}),
}),
})
Що станеться:
- Будь-яка mutation (create/update/delete) інвалідує всі запити з тегом
'Trip' - У нашому випадку — це
getTrips
Проблема: Навіть якщо ви змінили тільки одну поїздку (updateTrip), перезавантажується весь список. Для великих списків це неефективно.
Рівень 2: Гранулярні теги (за ID)
Для більшої точності додаємо теги з ID:
export const tripsApi = createApi({
tagTypes: ['Trip'],
endpoints: (builder) => ({
// Список поїздок надає теги для кожної окремої поїздки
getTrips: builder.query<Trip[], void>({
query: () => '/trips',
providesTags: (result) =>
result
? [
...result.map((trip) => ({ type: 'Trip' as const, id: trip.id })),
{ type: 'Trip', id: 'LIST' },
]
: [{ type: 'Trip', id: 'LIST' }],
}),
// Одна поїздка надає тег з конкретним ID
getTripById: builder.query<Trip, string>({
query: (id) => `/trips/${id}`,
providesTags: (_result, _error, id) => [{ type: 'Trip', id }],
}),
// Оновлення інвалідує тільки конкретну поїздку
updateTrip: builder.mutation<Trip, { id: string; title: string }>({
query: ({ id, ...data }) => ({ url: `/trips/${id}`, method: 'PATCH', body: data }),
invalidatesTags: (_result, _error, { id }) => [{ type: 'Trip', id }],
}),
// Видалення інвалідує поїздку + весь список
deleteTrip: builder.mutation<void, string>({
query: (id) => ({ url: `/trips/${id}`, method: 'DELETE' }),
invalidatesTags: (_result, _error, id) => [
{ type: 'Trip', id },
{ type: 'Trip', id: 'LIST' },
],
}),
// Створення інвалідує тільки список (бо з'являється нова поїздка)
createTrip: builder.mutation<Trip, CreateTripDto>({
query: (data) => ({ url: '/trips', method: 'POST', body: data }),
invalidatesTags: [{ type: 'Trip', id: 'LIST' }],
}),
}),
})
Розберемо детально:
providesTags: (result) =>
result
? [
...result.map((trip) => ({ type: 'Trip', id: trip.id })),
{ type: 'Trip', id: 'LIST' },
]
: [{ type: 'Trip', id: 'LIST' }]
Що генерується (приклад):
[
{ type: 'Trip', id: '1' },
{ type: 'Trip', id: '2' },
{ type: 'Trip', id: '3' },
{ type: 'Trip', id: 'LIST' },
]
Це означає:
- Список залежить від поїздок з ID
'1','2','3' - Список залежить від спеціального тегу
'LIST'
invalidatesTags: (_result, _error, { id }) => [{ type: 'Trip', id }]
Сценарій: Користувач змінює назву поїздки з ID '2'
Що станеться:
- Інвалідується тільки тег
{ type: 'Trip', id: '2' } - RTK Query шукає запити з цим тегом:
getTripById('2')— якщо відкритий екран деталей → refetchgetTrips()— містить{ type: 'Trip', id: '2' }у тегах → refetch
Важливо: getTrips оновиться, бо він "підписаний" на зміни поїздки '2'!
invalidatesTags: (_result, _error, id) => [
{ type: 'Trip', id },
{ type: 'Trip', id: 'LIST' },
]
Сценарій: Користувач видаляє поїздку з ID '3'
Що станеться:
- Інвалідується
{ type: 'Trip', id: '3' }— закриє екран деталей, якщо він відкритий - Інвалідується
{ type: 'Trip', id: 'LIST' }— оновить список (бо кількість змінилась)
Чому два теги?
- Перший — для екрана деталей конкретної поїздки
- Другий — для списку (щоб видалена поїздка зникла з UI)
invalidatesTags: [{ type: 'Trip', id: 'LIST' }]
Сценарій: Користувач створює нову поїздку
Що станеться:
- Інвалідується
{ type: 'Trip', id: 'LIST' } - Усі списки (
getTrips,getFilteredTripsтощо) оновляться
Чому тільки 'LIST'?
- Нова поїздка ще не має конкретного тегу (ми не знаємо її ID до створення)
- Але ми точно знаємо, що список змінився → інвалідуємо
'LIST'
Візуалізація: Як працює гранулярна інвалідація
Рівень 3: Декілька типів тегів
У реальному застосунку є різні типи даних — поїздки, користувачі, коментарі тощо:
export const api = createApi({
// Реєструємо всі типи тегів
tagTypes: ['Trip', 'User', 'Comment'],
endpoints: (builder) => ({
// Поїздки
getTrips: builder.query<Trip[], void>({
query: () => '/trips',
providesTags: (result) =>
result
? [...result.map((trip) => ({ type: 'Trip' as const, id: trip.id })), 'Trip']
: ['Trip'],
}),
// Коментарі до поїздки
getTripComments: builder.query<Comment[], string>({
query: (tripId) => `/trips/${tripId}/comments`,
providesTags: (_result, _error, tripId) => [
{ type: 'Comment', id: `TRIP-${tripId}` },
],
}),
// Додавання коментаря
addComment: builder.mutation<Comment, { tripId: string; text: string }>({
query: ({ tripId, text }) => ({
url: `/trips/${tripId}/comments`,
method: 'POST',
body: { text },
}),
invalidatesTags: (_result, _error, { tripId }) => [
{ type: 'Comment', id: `TRIP-${tripId}` }, // Оновити коментарі цієї поїздки
],
}),
// Видалення поїздки також видаляє її коментарі
deleteTrip: builder.mutation<void, string>({
query: (id) => ({ url: `/trips/${id}`, method: 'DELETE' }),
invalidatesTags: (_result, _error, id) => [
{ type: 'Trip', id },
{ type: 'Trip', id: 'LIST' },
{ type: 'Comment', id: `TRIP-${id}` }, // Очистити коментарі
],
}),
}),
})
Стратегії тегування: Коли що використовувати
| Сценарій | Стратегія | Приклад |
|---|---|---|
| Створення нової сутності | Інвалідувати LIST | invalidatesTags: [{ type: 'Trip', id: 'LIST' }] |
| Оновлення існуючої сутності | Інвалідувати конкретний ID | invalidatesTags: [{ type: 'Trip', id: tripId }] |
| Видалення сутності | Інвалідувати ID + LIST | invalidatesTags: [{ type: 'Trip', id: tripId }, { type: 'Trip', id: 'LIST' }] |
| Зміна залежних даних | Інвалідувати всі пов'язані типи | invalidatesTags: ['Trip', 'Comment', 'User'] |
| Масове оновлення | Інвалідувати весь тип | invalidatesTags: ['Trip'] |
Просунута техніка: Умовна інвалідація
Іноді потрібно інвалідувати теги тільки за певних умов:
export const tripsApi = createApi({
endpoints: (builder) => ({
updateTripStatus: builder.mutation<Trip, { id: string; status: 'active' | 'archived' }>({
query: ({ id, status }) => ({
url: `/trips/${id}/status`,
method: 'PATCH',
body: { status },
}),
invalidatesTags: (result, error, { id, status }) => {
const tags = [{ type: 'Trip' as const, id }]
// Якщо поїздку архівували — оновити також список активних
if (status === 'archived') {
tags.push({ type: 'Trip', id: 'ACTIVE-LIST' })
}
return tags
},
}),
getActiveTrips: builder.query<Trip[], void>({
query: () => '/trips?status=active',
providesTags: [{ type: 'Trip', id: 'ACTIVE-LIST' }],
}),
}),
})
Дебаг: Як побачити теги у Redux DevTools
Відкрийте Redux DevTools та перейдіть у вкладку "State". Знайдіть свій API:
{
"tripsApi": {
"queries": {
"getTrips(undefined)": {
"status": "fulfilled",
"data": [...],
"providedTags": [
{ "type": "Trip", "id": "1" },
{ "type": "Trip", "id": "2" },
{ "type": "Trip", "id": "LIST" }
]
},
"getTripById(\"2\")": {
"status": "fulfilled",
"data": { ... },
"providedTags": [
{ "type": "Trip", "id": "2" }
]
}
},
"mutations": {}
}
}
Це допоможе зрозуміти, які теги надає кожен запит!
providesTags— "Цей запит надає дані про..."invalidatesTags— "Ця мутація змінила дані про..."
Оптимістичні оновлення (Optimistic Updates) у RTK Query
Користувач не повинен чекати відповіді сервера при натисканні простих дій (лайк, чекбокс, додавання в закладки). Інтерфейс повинен оновлюватися миттєво (0 мс затримки), а у випадку мережевого збою — непомітно повертатися до вихідного стану (Rollback).
У RTK Query це реалізується за допомогою функції onQueryStarted та утиліти updateQueryData:
// src/services/tripsOptimisticApi.ts
import { baseApi } from './baseApi'
import type { Trip } from './tripsApi'
export const tripsOptimisticApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
toggleTripFavorite: builder.mutation<{ success: boolean; isFavorite: boolean }, { tripId: string }>({
query: ({ tripId }) => ({
url: `/trips/${tripId}/favorite`,
method: 'POST',
}),
// Lifecycle-хук, що викликається ОДРАЗУ при запуску мутації
async onQueryStarted({ tripId }, { dispatch, queryFulfilled }) {
// 1. ОПТИМІСТИЧНЕ ОНОВЛЕННЯ: прямо модифікуємо draft-кеш списку поїздок
const patchResult = dispatch(
baseApi.util.updateQueryData('getAllTrips' as any, undefined, (draft: Trip[]) => {
const trip = draft.find((t) => t.id === tripId)
if (trip) {
// Миттєво перемикаємо прапорець у локальному кеші
trip.isFavorite = !trip.isFavorite
}
}),
)
try {
// 2. Очікуємо на реальну відповідь бекенду
await queryFulfilled
} catch {
// 3. ВІДКАТ (ROLLBACK): Якщо сервер повернув 500 або зникла мережа
patchResult.undo()
}
},
}),
}),
})
export const { useToggleTripFavoriteMutation } = tripsOptimisticApi
Realtime-оновлення: WebSockets через onCacheEntryAdded
Якщо дані на сервері змінюються в реальному часі (наприклад, водій на карті або статус рейсу), RTK Query підтримує підключення WebSockets безпосередньо всередині ендпоінта за допомогою onCacheEntryAdded:
// src/services/tripsRealtimeApi.ts
import { baseApi } from './baseApi'
import type { Trip } from './tripsApi'
export const tripsRealtimeApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getLiveTripStatus: builder.query<Trip, string>({
query: (tripId) => `/trips/${tripId}`,
async onCacheEntryAdded(tripId, { updateCachedData, cacheDataLoaded, cacheEntryRemoved }) {
// Створюємо WebSocket з'єднання
const ws = new WebSocket(`wss://api.nomad.dev/trips/${tripId}/live`)
try {
// Чекаємо поки початковий HTTP-запит успішно виконається
await cacheDataLoaded
// Слухаємо оновлення з сервера
ws.onmessage = (event) => {
const updatedData = JSON.parse(event.data)
// Прямо оновлюємо поточний запис у кеші
updateCachedData((draft) => {
Object.assign(draft, updatedData)
})
}
} catch {
// Обробка помилки початкового завантаження
}
// Коли екран закривається і підписка розривається -> закриваємо сокет
await cacheEntryRemoved
ws.close()
},
}),
}),
})
Пагінація та Infinite Scroll у списках
Для великих мобільних стрічок завантаження всіх даних одразу призведе до вичерпання оперативної пам'яті. RTK Query дозволяє легко реалізувати нескінченний скрол (Infinite Scroll) шляхом об'єднання сторінок за допомогою функцій serializeQueryArgs та merge:
// src/services/tripsPaginationApi.ts
import { baseApi } from './baseApi'
import type { Trip } from './tripsApi'
export interface PaginatedResponse {
items: Trip[]
nextPage: number | null
total: number
}
export const tripsPaginationApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getInfiniteTrips: builder.query<PaginatedResponse, number>({
query: (page = 1) => `/trips?_page=${page}&_limit=10`,
// 1. Один спільний ключ кешу для всіх сторінок цього ендпоінта
serializeQueryArgs: ({ endpointName }) => {
return endpointName
},
// 2. Злиття нових сторінок з існуючим кешем списку
merge: (currentCache, newItems) => {
if (newItems.nextPage === 2) {
currentCache.items = newItems.items
} else {
currentCache.items.push(...newItems.items)
}
currentCache.nextPage = newItems.nextPage
currentCache.total = newItems.total
},
// 3. Примусовий перезапит при зміні номера сторінки
forceRefetch({ currentArg, previousArg }) {
return currentArg !== previousArg
},
}),
}),
})
export const { useGetInfiniteTripsQuery } = tripsPaginationApi
Міні-проєкт: «Нотатки мандрівника» з RTK Query
Побудуємо повнофункціональний міні-застосунок для керування нотатками з використанням віддаленого REST API.
Структура файлової системи міні-проєкту
Кроки реалізації
Крок 1. Створення сервісу notesApi.ts
// src/services/notesApi.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export interface Note {
id: number
title: string
body: string
userId: number
}
export const notesApi = createApi({
reducerPath: 'notesApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://jsonplaceholder.typicode.com' }),
tagTypes: ['Note'],
endpoints: (builder) => ({
getNotes: builder.query<Note[], void>({
query: () => '/posts?_limit=15',
providesTags: (result) =>
result
? [...result.map(({ id }) => ({ type: 'Note' as const, id })), { type: 'Note', id: 'LIST' }]
: [{ type: 'Note', id: 'LIST' }],
}),
createNote: builder.mutation<Note, { title: string; body: string }>({
query: (payload) => ({
url: '/posts',
method: 'POST',
body: { ...payload, userId: 1 },
}),
invalidatesTags: [{ type: 'Note', id: 'LIST' }],
}),
deleteNote: builder.mutation<{ success: boolean }, number>({
query: (noteId) => ({
url: `/posts/${noteId}`,
method: 'DELETE',
}),
invalidatesTags: (_result, _error, id) => [
{ type: 'Note', id },
{ type: 'Note', id: 'LIST' },
],
}),
}),
})
export const { useGetNotesQuery, useCreateNoteMutation, useDeleteNoteMutation } = notesApi
Крок 2. Налаштування Store
// src/store/index.ts
import { configureStore } from '@reduxjs/toolkit'
import { setupListeners } from '@reduxjs/toolkit/query'
import { notesApi } from '@/services/notesApi'
export const store = configureStore({
reducer: {
[notesApi.reducerPath]: notesApi.reducer,
},
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(notesApi.middleware),
})
setupListeners(store.dispatch)
Крок 3. Екран керування нотатками NotesScreen.tsx
// src/screens/NotesScreen.tsx
import React, { useState } from 'react'
import { View, Text, TextInput, Button, FlatList, ActivityIndicator, RefreshControl, StyleSheet } from 'react-native'
import { useGetNotesQuery, useCreateNoteMutation, useDeleteNoteMutation } from '../services/notesApi'
export function NotesScreen() {
const [title, setTitle] = useState('')
const { data: notes, isLoading, isFetching, refetch } = useGetNotesQuery()
const [createNote, { isLoading: isCreating }] = useCreateNoteMutation()
const [deleteNote] = useDeleteNoteMutation()
const handleAdd = async () => {
if (!title.trim()) return
await createNote({ title, body: 'Текст нової нотатки' }).unwrap()
setTitle('')
}
if (isLoading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#2563eb" />
</View>
)
}
return (
<View style={styles.container}>
<Text style={styles.header}>Нотатки подорожей</Text>
<View style={styles.formRow}>
<TextInput
style={styles.input}
placeholder="Нова нотатка..."
value={title}
onChangeText={setTitle}
/>
<Button title={isCreating ? '...' : 'Додати'} onPress={handleAdd} disabled={isCreating} />
</View>
<FlatList
data={notes}
keyExtractor={(item) => String(item.id)}
refreshControl={<RefreshControl refreshing={isFetching} onRefresh={refetch} />}
renderItem={({ item }) => (
<View style={styles.noteItem}>
<View style={{ flex: 1 }}>
<Text style={styles.noteTitle}>{item.title}</Text>
</View>
<Button title="🗑️" color="#dc2626" onPress={() => deleteNote(item.id)} />
</View>
)}
/>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16, backgroundColor: '#f1f5f9' },
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
header: { fontSize: 22, fontWeight: '800', marginBottom: 16, color: '#0f172a' },
formRow: { flexDirection: 'row', gap: 8, marginBottom: 16 },
input: { flex: 1, backgroundColor: '#ffffff', padding: 12, borderRadius: 8, borderWidth: 1, borderColor: '#cbd5e1' },
noteItem: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#ffffff',
padding: 14,
borderRadius: 10,
marginBottom: 8,
},
noteTitle: { fontSize: 14, fontWeight: '600', color: '#1e293b' },
})
Наскрізний проєкт: Nomad — міграція на RTK Query
Тепер замінимо застарілий ручний tripsSlice та fetchTripsThunk у додатку Nomad на сучасний декларативний сервіс tripsApi.
1. Повний сервіс src/services/tripsApi.ts
// src/services/tripsApi.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import { Platform } from 'react-native'
import Constants from 'expo-constants'
export interface Trip {
id: string
title: string
region: string
startDate: string
endDate: string | null
description: string | null
isFavorite?: boolean
}
export interface CreateTripPayload {
title: string
region: string
startDate: string
endDate?: string | null
description?: string | null
}
const getBaseUrl = (): string => {
if (Platform.OS === 'android') return 'http://10.0.2.2:3000'
const hostUri = Constants.expoConfig?.hostUri
if (hostUri) {
const ip = hostUri.split(':').shift()
if (ip) return `http://${ip}:3000`
}
return 'http://localhost:3000'
}
export const tripsApi = createApi({
reducerPath: 'tripsApi',
baseQuery: fetchBaseQuery({ baseUrl: getBaseUrl(), timeout: 8000 }),
tagTypes: ['Trip'],
refetchOnFocus: true,
refetchOnReconnect: true,
endpoints: (builder) => ({
getTrips: builder.query<Trip[], void>({
query: () => '/trips',
providesTags: (result) =>
result
? [...result.map(({ id }) => ({ type: 'Trip' as const, id })), { type: 'Trip', id: 'LIST' }]
: [{ type: 'Trip', id: 'LIST' }],
}),
getTripById: builder.query<Trip, string>({
query: (id) => `/trips/${id}`,
providesTags: (_result, _error, id) => [{ type: 'Trip', id }],
}),
createTrip: builder.mutation<Trip, CreateTripPayload>({
query: (payload) => ({
url: '/trips',
method: 'POST',
body: payload,
}),
invalidatesTags: [{ type: 'Trip', id: 'LIST' }],
}),
}),
})
export const { useGetTripsQuery, useGetTripByIdQuery, useCreateTripMutation } = tripsApi
2. Оновлення кореневого Store
// src/store/index.ts
import { configureStore } from '@reduxjs/toolkit'
import { setupListeners } from '@reduxjs/toolkit/query'
import { tripsApi } from '@/services/tripsApi'
export const store = configureStore({
reducer: {
[tripsApi.reducerPath]: tripsApi.reducer,
},
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(tripsApi.middleware),
})
setupListeners(store.dispatch)
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
3. Оновлення головного екрана app/(tabs)/trips/index.tsx
// app/(tabs)/trips/index.tsx
import React from 'react'
import { View, FlatList, ActivityIndicator, RefreshControl, StyleSheet, Text } from 'react-native'
import { useRouter } from 'expo-router'
import { useGetTripsQuery } from '@/services/tripsApi'
import { TripCard } from '@/features/trips'
import { Screen, Button } from '@/shared/ui'
export default function TripsScreen() {
const router = useRouter()
const { data: trips, isLoading, isFetching, isError, refetch } = useGetTripsQuery()
if (isLoading) {
return (
<Screen style={styles.center}>
<ActivityIndicator size="large" color="#2563eb" />
</Screen>
)
}
return (
<Screen style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Мої Подорожі</Text>
<Button title="+ Створити" onPress={() => router.push('/create-trip')} />
</View>
<FlatList
data={trips}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <TripCard trip={item} onPress={() => router.push(`/trips/${item.id}`)} />}
refreshControl={<RefreshControl refreshing={isFetching} onRefresh={refetch} tintColor="#2563eb" />}
ListEmptyComponent={
<View style={styles.empty}>
<Text style={styles.emptyText}>Список порожній. Створіть першу поїздку!</Text>
</View>
}
/>
</Screen>
)
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16 },
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 },
title: { fontSize: 26, fontWeight: '800', color: '#0f172a' },
empty: { alignItems: 'center', marginTop: 40 },
emptyText: { color: '#64748b', fontSize: 15 },
})
Резюме розділу
🚀 Декларативний API
createApi повністю замінює ручні слайси, редюсери та createAsyncThunk для роботи з мережею, автоматично генеруючи типізовані хуки.🏷️ Гранулярні Теги
{ type: 'Trip', id: 'LIST' } та { type: 'Trip', id } забезпечує точкову інвалідацію лише змінених сутностей без зайвих мережевих запитів.📱 Мобільна оптимізація
refetchOnFocus, refetchOnReconnect та keepUnusedDataFor забезпечують актуальність даних при поверненні з фону та раціональне використання оперативної пам'яті.⚡ Optimistic UI
onQueryStarted дозволяє змінювати локальний інтерфейс за 0 мс із надійним автоматичним відкатом (rollback) при помилці бекенду.Практичні завдання
| Рівень складності | Назва завдання | Формулювання вимог |
|---|---|---|
| Базовий (Basic) | Додавання статусу поїздки | Додати mutation toggleTripStatus(id) (перемикання між planned та completed). Налаштувати теги так, щоб список автоматично відображав новий статус. |
| Середній (Intermediate) | Пошук із затримкою (Debounce) | Створити поле пошуку в заголовку списку. Використати хук useGetFilteredTripsQuery({ search }) разом із кастомним хуком дебаунсу (300 мс), щоб не спамити бекенд при кожному натисканні клавіші. |
| Просунутий (Advanced) | Оптимістичне видалення з Rollback | Реалізувати deleteTrip через onQueryStarted з миттєвим видаленням картки зі списку та демонстрацією Snackbar-повідомлення з кнопкою «Скасувати». |
Поширені запитання (FAQ)
createSlice не знає про життєвий цикл мережевих підписок, не вміє автоматично дедуплікувати паралельні виклики з різних компонентів, не має вбудованого таймера очищення пам'яті (Garbage Collection) та вимагає написання сотень рядків коду для синхронізації після кожної мутації.isLoading: true лише під час найпершого запиту, коли в кеші взагалі немає даних (ідеально для показу великого Skeleton). isFetching: true під час будь-якого мережевого запиту, навіть якщо старі дані вже відмальовані на екрані (ідеально для маленького спінера оновлення).baseQueryWithReauth над fetchBaseQuery з використанням async-mutex. Якщо запит повертає статус 401, функція блокує паралельні запити, оновлює токен, записує його в store і повторює початковий виклик.Просунуті техніки RTK Query
Тепер, коли ви опанували основи Query, Mutations та систему тегів, розглянемо просунуті техніки, які роблять UX вашого застосунку по-справжньому чудовим.
Оптимістичні оновлення (Optimistic Updates)
Проблема: Коли користувач натискає "❤️ Додати в улюблене", він чекає відповіді сервера 200-500мс перед тим як побачить зміну. Це відчувається повільно.
Рішення: Змінюємо UI миттєво (0мс), а якщо сервер поверне помилку — відкочуємо зміни назад.
Приклад: Лайк поїздки
// src/services/tripsApi.ts
export const tripsApi = createApi({
endpoints: (builder) => ({
getTrips: builder.query<Trip[], void>({
query: () => '/trips',
providesTags: ['Trip'],
}),
toggleFavorite: builder.mutation<void, string>({
query: (tripId) => ({
url: `/trips/${tripId}/favorite`,
method: 'POST',
}),
// Lifecycle хук для оптимістичних оновлень
async onQueryStarted(tripId, { dispatch, queryFulfilled }) {
// 1️⃣ ОПТИМІСТИЧНЕ ОНОВЛЕННЯ: Одразу змінюємо UI
const patchResult = dispatch(
tripsApi.util.updateQueryData('getTrips', undefined, (draft) => {
// draft — це ImmerProxy, можна мутувати безпечно
const trip = draft.find((t) => t.id === tripId)
if (trip) {
trip.isFavorite = !trip.isFavorite
}
})
)
try {
// 2️⃣ Чекаємо відповіді сервера
await queryFulfilled
// ✅ Успіх — UI вже оновлений, нічого не робимо
} catch {
// 3️⃣ ROLLBACK: Помилка — відкочуємо зміни
patchResult.undo()
// Показуємо повідомлення користувачу
Toast.show({
type: 'error',
text1: 'Не вдалося оновити',
})
}
},
}),
}),
})
export const { useGetTripsQuery, useToggleFavoriteMutation } = tripsApi
Використання у компоненті:
function TripCard({ trip }) {
const [toggleFavorite, { isLoading }] = useToggleFavoriteMutation()
const handleFavoritePress = () => {
toggleFavorite(trip.id)
// UI оновиться МИТТЄВО ⚡
// Користувач не чекає відповіді сервера
}
return (
<View style={styles.card}>
<Text>{trip.title}</Text>
<TouchableOpacity onPress={handleFavoritePress}>
<Text style={styles.icon}>
{trip.isFavorite ? '❤️' : '🤍'}
</Text>
</TouchableOpacity>
</View>
)
}
Що відбувається покроково:
Користувач натискає ❤️
Іконка МИТТЄВО змінюється з 🤍 на ❤️ (0мс затримки)
Запит відправляється на сервер
POST /trips/123/favorite
Сервер відповідає
- Якщо 200 OK: нічого не робимо, UI вже правильний
- Якщо 500 Error:
patchResult.undo()відкочує зміни, іконка повертається до 🤍
- ✅ Прості дії з високою ймовірністю успіху (лайки, перемикачі, чекбокси)
- ✅ Коли затримка відповіді сервера помітна (>200мс)
- ❌ Фінансові операції (транзакції, платежі)
- ❌ Критичні дії, де помилка недопустима
Безкінечна пагінація (Infinite Scroll)
Коли у вас сотні або тисячі записів, завантажувати їх усі одразу — погана ідея. Безкінечна пагінація завантажує дані порціями при прокручуванні.
// src/services/tripsApi.ts
interface PaginatedResponse {
items: Trip[]
nextPage: number | null
total: number
}
export const tripsApi = createApi({
endpoints: (builder) => ({
getInfiniteTrips: builder.query<PaginatedResponse, number>({
query: (page = 1) => `/trips?page=${page}&limit=10`,
// 1. Всі сторінки зберігаються під одним ключем
serializeQueryArgs: ({ endpointName }) => {
return endpointName // Ігноруємо page у ключі кешу
},
// 2. Злиття нових сторінок з існуючими
merge: (currentCache, newResponse) => {
if (newResponse.nextPage === 2) {
// Перша сторінка — замінюємо
currentCache.items = newResponse.items
} else {
// Наступні сторінки — додаємо
currentCache.items.push(...newResponse.items)
}
currentCache.nextPage = newResponse.nextPage
currentCache.total = newResponse.total
},
// 3. Завжди робити запит при зміні аргументу
forceRefetch({ currentArg, previousArg }) {
return currentArg !== previousArg
},
}),
}),
})
export const { useGetInfiniteTripsQuery } = tripsApi
Використання у компоненті:
function InfiniteTripsScreen() {
const [page, setPage] = useState(1)
const { data, isFetching } = useGetInfiniteTripsQuery(page)
const loadMore = () => {
if (data?.nextPage && !isFetching) {
setPage(data.nextPage)
}
}
return (
<FlatList
data={data?.items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <TripCard trip={item} />}
// Завантаження при досягненні кінця списку
onEndReached={loadMore}
onEndReachedThreshold={0.5} // Завантажувати за 50% до кінця
// Індикатор завантаження внизу
ListFooterComponent={
isFetching ? <ActivityIndicator style={{ padding: 20 }} /> : null
}
/>
)
}
Як це працює:
Перший рендер
useGetInfiniteTripsQuery(1) → Завантажує сторінку 1 (10 поїздок)
Користувач прокручує вниз
onEndReached спрацьовує → setPage(2)
Завантаження сторінки 2
useGetInfiniteTripsQuery(2) → merge() додає 10 нових поїздок до існуючих
Результат
Список тепер містить 20 поїздок (сторінка 1 + сторінка 2)
WebSockets: Оновлення у реальному часі
Якщо дані на сервері змінюються постійно (наприклад, координати водія на карті), використовуйте WebSocket підключення.
// src/services/liveTrackingApi.ts
export const liveTrackingApi = createApi({
endpoints: (builder) => ({
getLiveLocation: builder.query<{ lat: number; lng: number }, string>({
query: (driverId) => `/drivers/${driverId}/location`,
// Lifecycle хук для WebSocket підключення
async onCacheEntryAdded(
driverId,
{ updateCachedData, cacheDataLoaded, cacheEntryRemoved }
) {
// Створюємо WebSocket з'єднання
const ws = new WebSocket(`wss://api.example.com/drivers/${driverId}/live`)
try {
// Чекаємо поки початковий HTTP-запит завершиться
await cacheDataLoaded
// Слухаємо оновлення з WebSocket
ws.onmessage = (event) => {
const newLocation = JSON.parse(event.data)
// Оновлюємо кеш RTK Query
updateCachedData((draft) => {
draft.lat = newLocation.lat
draft.lng = newLocation.lng
})
}
} catch {
// Помилка початкового завантаження
}
// Коли компонент розмонтується — закриваємо WebSocket
await cacheEntryRemoved
ws.close()
},
}),
}),
})
Використання:
function DriverMapScreen({ driverId }) {
// Початкове завантаження через HTTP, потім оновлення через WebSocket
const { data: location } = useGetLiveLocationQuery(driverId)
return (
<MapView>
{location && (
<Marker
coordinate={{
latitude: location.lat,
longitude: location.lng,
}}
/>
)}
</MapView>
)
}
Життєвий цикл:
Компонент монтується
HTTP GET /drivers/123/location → отримує початкові координати
WebSocket підключається
new WebSocket(...) → встановлює з'єднання
Сервер надсилає оновлення
Кожні 2 секунди → нові координати через WebSocket
updateCachedData() оновлює кеш
Маркер на карті переміщується плавно
Компонент розмонтується
ws.close() → WebSocket закривається автоматично
Підсумок і наступні кроки
🎯 Що ви опанували
- Різницю між клієнтським та серверним станом
- Налаштування
createApiта інтеграцію з Redux - Query endpoints для читання даних (з параметрами, фільтрами, transformResponse)
- Mutation endpoints для зміни даних (POST, PATCH, DELETE)
- Систему тегів для автоматичної інвалідації кешу
- JWT авторизацію з Refresh Token через Mutex
- Оптимістичні оновлення для миттєвого UX
- Безкінечну пагінацію для великих списків
- WebSocket інтеграцію для реального часу
📚 Рекомендовані ресурси
- Офіційна документація RTK Query
- Redux DevTools — для дебагу стану та тегів
- RTK Query Codegen — генерація з OpenAPI
- Приклади RTK Query
🚀 Практичні завдання
- Базовий рівень: Додайте пошук поїздок з debounce (затримка 300мс)
- Середній рівень: Реалізуйте оптимістичне редагування назви поїздки
- Просунутий рівень: Створіть систему тегів для коментарів з умовною інвалідацією
Поширені запитання (FAQ)
Технічно ні — RTK Query працює поверх Redux Toolkit. Але це не означає, що вам потрібно використовувати Redux для всього стану!
Типовий підхід:
- RTK Query — для серверного стану (запити до API)
- Redux Toolkit — для глобального клієнтського стану (авторизація, налаштування)
- useState — для локального стану компонентів (форми, UI елементи)
Якщо вам зовсім не потрібен Redux — розгляньте TanStack Query або SWR.
RTK Query використовує двох-рівневу систему кешування:
- Активний кеш — поки хоч один компонент підписаний на дані
- Неактивний кеш — після того як останній компонент відписався
Параметр keepUnusedDataFor (за замовчуванням 60 секунд) визначає, скільки зберігати неактивний кеш.
Приклад:
// Користувач відкриває екран списку
useGetTripsQuery() // Активний кеш
// Користувач переходить на інший екран
// Активна підписка зникає → таймер 60 сек запускається
// Якщо користувач повернеться протягом 60 сек:
// Дані все ще у кеші → миттєвий показ
// Якщо пройде > 60 сек:
// Кеш очищено → новий запит при поверненні
Крок 1: Відкрийте Redux DevTools
Крок 2: Перейдіть у вкладку State → знайдіть ваш API (наприклад, tripsApi)
Крок 3: Перевірте queries:
{
"tripsApi": {
"queries": {
"getTrips(undefined)": {
"providedTags": [
{ "type": "Trip", "id": "1" },
{ "type": "Trip", "id": "2" },
{ "type": "Trip", "id": "LIST" }
]
}
}
}
}
Крок 4: Виконайте мутацію та перевірте, чи спрацювала інвалідація
Крок 5: Якщо інвалідація не працює — перевірте:
- Чи співпадають типи тегів (
tagTypes: ['Trip']) - Чи правильно вказані теги у
providesTagsтаinvalidatesTags - Чи активна підписка (компонент змонтований)
RTK Query автоматично обробляє багато випадків:
1. Відсутність інтернету при завантаженні:
const { data, error, isError } = useGetTripsQuery()
if (isError) {
// error містить інформацію про помилку
return <Text>Немає з'єднання з інтернетом</Text>
}
2. Автоматичне повторне підключення:
createApi({
refetchOnReconnect: true, // Автоматично оновити при відновленні мережі
})
3. Кастомна обробка таймаутів:
baseQuery: fetchBaseQuery({
baseUrl: '...',
timeout: 10000, // 10 секунд
})
4. Retry логіка (для критичних запитів):
getImportantData: builder.query({
query: () => '/important',
// Повторити запит 3 рази з затримкою
extraOptions: { maxRetries: 3 },
})
Redux Toolkit на мобільних платформах
Повний практичний посібник з Redux Toolkit у React Native — будова Store, Slice, Reducer, Action, createListenerMiddleware, персистентність з MMKV, оптимістичні оновлення, скасування thunk через AbortController, createEntityAdapter, Reselect мемоїзація та тестування логіки.
Автентифікація та Безпечне Зберігання
Практичний розбір механізмів автентифікації, управління сесіями, SecureStore та захищених маршрутів у React Native