Version2

RTK Query на мобільних платформах

Практичний навчальний посібник з RTK Query у React Native — від простих прикладів до складних сценаріїв. Дізнайтеся як автоматизувати роботу з серверними даними, налаштувати кеш, інвалідацію та оптимістичні оновлення у мобільному застосунку.

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 знову, хоча потрібна лише одна нова.

Проблема 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()
}, [])

Проблема: Цей код потрібно дублювати у кожному компоненті, який працює з серверними даними!

Підсумок проблем традиційного підходу

Для роботи з серверними даними через традиційний Redux + createAsyncThunk вам потрібно:
  • Створювати окремий 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 (при відновленні інтернету). Вмикається одним рядком конфігурації.

🎯 Дедуплікація запитів

Якщо 5 компонентів одночасно викликають useGetTripsQuery(), RTK Query відправить лише один реальний HTTP-запит. Всі компоненти отримають той самий результат.
Головний висновок: RTK Query розуміє, що серверний стан (Server State) принципово відрізняється від клієнтського (Client State).
  • Клієнтський стан — це дані, які живуть лише у вашому застосунку (налаштування теми, поточна вкладка, текст у полі вводу). Вони завжди актуальні, бо належать вам.
  • Серверний стан — це дані, першоджерело яких знаходиться на віддаленому сервері. Вони можуть застаріти, можуть змінитися іншими користувачами, вимагають мережевих запитів та спеціальної логіки кешування.
RTK Query — це спеціалізований інструмент для автоматизації всього життєвого циклу серверного стану у React Native застосунках.

Розуміння різниці: Клієнтський стан 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
        },
    },
})
Стан UI елементів
// 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

Візуальне порівняння: життєвий цикл даних

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam defaultFontName "Helvetica"

package "КЛІЄНТСЬКИЙ СТАН (локальний)" {
    [Користувач] as User1 #DBEAFE
    [useState / Redux Slice] as State #DCFCE7
    
    User1 --> State : Змінює тему
    State --> User1 : Миттєво застосовує
    
    note right of State #FEF3C7
        ✅ Завжди актуально
        ✅ Синхронно
        ✅ Повний контроль
    end note
}

package "СЕРВЕРНИЙ СТАН (віддалений)" {
    [Користувач (мобілка)] as User2 #DBEAFE
    [RTK Query Cache] as Cache #FED7AA
    [API Server / База даних] as Server #FECACA
    [Інший користувач (веб)] as OtherUser #E0E7FF
    
    User2 --> Cache : 1. Читає дані
    Cache --> Server : 2. Якщо немає/застаріли\nGET /trips
    Server --> Cache : 3. Повертає свіжі дані
    Cache --> User2 : 4. Відображає
    
    OtherUser --> Server : Змінює дані\nпоки застосунок\nзгорнутий
    
    note right of Cache #FEF3C7
        ⚠️ Може застаріти
        ⏱️ Асинхронно
        🔄 Потребує синхронізації
    end note
}

@enduml

Практичний сценарій: Чому важлива ця різниця?

Уявіть застосунок для планування подорожей. Давайте подивимось, як обидва типи стану працюють разом:

// 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 (або інший інструмент серверного стану)
Не намагайтеся зберігати серверні дані у звичайному Redux слайсі — ви витратите тижні на написання логіки, яку RTK Query робить автоматично!

Порівняння бібліотек для серверного стану

Тепер, коли ми розуміємо природу серверного стану, давайте порівняємо інструменти для роботи з ним:

КритерійRTK QueryTanStack QuerySWRApollo Client
ЕкосистемаЧастина Redux ToolkitНезалежна бібліотекаВід команди VercelДля GraphQL
Інтеграція з Redux✅ Нативна (єдині DevTools)⚠️ Окреме підключення❌ Окреме дерево стану❌ Окреме дерево
Розмір бандлу~9KB (якщо Redux вже є)~13KB~5KB~33KB
Інвалідація кешуТеги (декларативно)Ключі (імперативно)Ключі (імперативно)__typename (авто)
Генерація хуків✅ Автоматична❌ Ручна❌ Ручна⚠️ З GraphQL схеми
TypeScript✅ Відмінна підтримка✅ Відмінна підтримка✅ Добра підтримка✅ З кодогенерацією
Коли обиратиУ вас вже є ReduxRedux не потрібенПростий REST APIТільки GraphQL

Перші кроки: Налаштування createApi

Тепер, коли ми розуміємо проблему та її рішення, давайте створимо наш перший RTK Query API. Почнемо з найпростішої конфігурації та поступово додаватимемо функціональність.

Крок 1: Встановлення залежностей

Спочатку переконайтесь, що у вашому проєкті встановлено Redux Toolkit:

npm install @reduxjs/toolkit react-redux
Важливо: RTK Query входить до складу @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() — головна функція, яка створює весь API
  • reducerPath — ім'я, під яким дані зберігатимуться у Redux store
  • baseQuery — інструмент для виконання 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

Пояснення ключових моментів:

Крок 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

Давайте візуалізуємо, що відбувається під капотом:

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam defaultFontName "Helvetica"

package "Ваш код (пишете ви)" {
    [createApi(\{...\})] as CreateApi #DBEAFE
}

package "Автоматично генерується RTK Query" {
    [api.reducer] as Reducer #DCFCE7
    [api.middleware] as Middleware #FEF3C7
    [useGetPostsQuery()] as Hook #FED7AA
    [endpoints.getPosts] as Endpoint #E0E7FF
}

package "Redux Store (керує станом)" {
    [State Tree] as State #F3F4F6
}

package "React Components (UI)" {
    [PostsScreen] as Component #FECACA
}

CreateApi --> Reducer : генерує
CreateApi --> Middleware : генерує
CreateApi --> Hook : генерує
CreateApi --> Endpoint : генерує

Reducer --> State : зберігає кеш
Middleware --> State : керує підписками

Component --> Hook : викликає
Hook --> Endpoint : запускає запит
Endpoint --> Middleware : обробляє життєвий цикл
Middleware --> Reducer : оновлює дані

@enduml

Поглиблене налаштування: Параметри 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

Детальний розбір кожного параметра:

::

Мобільний сценарій:

  1. Користувач їде в метро (втрачає інтернет)
  2. Застосунок показує закешовані дані
  3. Користувач виходить з метро (інтернет відновлюється)

Поведінка:

  • refetchOnReconnect: false — продовжує показувати старі дані
  • refetchOnReconnect: true — автоматично оновлює всі активні запити

Визначає скільки секунд зберігати дані після того, як жоден компонент їх не використовує.

Приклад:

keepUnusedDataFor: 60  // 60 секунд = 1 хвилина

Життєвий цикл:

  1. Користувач відкриває екран списку → useGetTripsQuery() створює підписку
  2. Дані завантажуються і кешуються
  3. Користувач йде на інший екран → підписка видаляється
  4. Таймер запускається: через 60 секунд дані будуть видалені з пам'яті
  5. Якщо користувач повернеться на екран протягом 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 хвилин у секундах
}

Логіка роботи:

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam defaultFontName "Helvetica"

actor "Користувач" as User #DBEAFE
participant "Mobile App" as App #DCFCE7
participant "RTK Query" as RTK #FED7AA
participant "Backend API" as API #FECACA

== Початковий логін ==
User -> App : Вводить email + пароль
App -> API : POST /auth/login
API --> App : { accessToken, refreshToken }
App -> App : Зберігає обидва токени у Redux

== Звичайний запит (токен валідний) ==
User -> App : Відкриває список поїздок
App -> RTK : useGetTripsQuery()
RTK -> API : GET /trips\nAuthorization: Bearer <accessToken>
API --> RTK : 200 OK + дані
RTK --> App : Показує список

== Запит з застарілим токеном ==
User -> App : Відкриває профіль (через 20 хвилин)
App -> RTK : useGetProfileQuery()
RTK -> API : GET /profile\nAuthorization: Bearer <старий accessToken>
API --> RTK : ❌ 401 Unauthorized

RTK -> RTK : Перехоплюємо помилку 401
RTK -> API : POST /auth/refresh\n{ refreshToken }
API --> RTK : ✅ { newAccessToken, newRefreshToken }
RTK -> RTK : Оновлюємо токени у Redux

RTK -> API : Повторний запит GET /profile\nAuthorization: Bearer <НОВИЙ accessToken>
API --> RTK : 200 OK + дані профілю
RTK --> App : Показує профіль
note over User, App #FEF3C7
    Користувач навіть не помітив,
    що токен оновлювався!
end note

@enduml

Реалізація: Автоматичне оновлення токенів

Тепер напишемо код, який автоматично обробляє цю логіку:

// 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',
        }),
    }),
})

Тепер все працює автоматично! Користувач може не заходити у застосунок тиждень — при першому запиті токен оновиться прозоро.

Важливе зауваження: Refresh token теж може застаріти (наприклад, через 30 днів). У цьому випадку користувачу дійсно доведеться ввести пароль знову. Це нормально і безпечно!

Просунута проблема: Паралельні запити (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
}

Як це працює:

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam defaultFontName "Helvetica"

participant "Request 1\n(getProfile)" as R1 #DBEAFE
participant "Request 2\n(getTrips)" as R2 #DCFCE7
participant "Request 3\n(getStats)" as R3 #FED7AA
participant "Mutex" as M #FEF3C7
participant "API Server" as API #FECACA

== Всі запити отримують 401 одночасно ==
R1 -> M : Перевіряю mutex.isLocked()
M --> R1 : false (вільний)
R1 -> M : mutex.acquire() 🔒
M --> R1 : Захоплено!

R2 -> M : Перевіряю mutex.isLocked()
M --> R2 : true (зайнятий)
R2 -> R2 : Чекаю mutex.waitForUnlock()

R3 -> M : Перевіряю mutex.isLocked()
M --> R3 : true (зайнятий)
R3 -> R3 : Чекаю mutex.waitForUnlock()

== Request 1 оновлює токен ==
R1 -> API : POST /auth/refresh
API --> R1 : { newAccessToken }
R1 -> R1 : Зберігаю у Redux
R1 -> M : release() 🔓

== Інші запити продовжують роботу ==
M --> R2 : Mutex звільнено!
M --> R3 : Mutex звільнено!

R2 -> API : GET /trips (з НОВИМ токеном)
R3 -> API : GET /stats (з НОВИМ токеном)
API --> R2 : 200 OK
API --> R3 : 200 OK

@enduml
Підсумок: Mutex гарантує, що навіть якщо 100 запитів одночасно отримають 401, тільки перший виконає refresh. Всі інші почекають його завершення та автоматично використають оновлений токен.

Читання даних: 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=Париж
5 запитів за секунду — це надто багато! Рішення — дебаунс (debounce), про який поговоримо нижче.

Рівень 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()
    )
}

Розуміння результату хука: Що повертає 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 />

Правильна обробка станів у компоненті:

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>
}

Типові випадки використання 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()

Чому мутації не кешуються?

Уявіть, що ви натискаєте "Створити поїздку" двічі — має створитись дві різні поїздки, а не повернутись результат першого запиту. Мутації мають побічні ефекти (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>
    )
}

Життєвий цикл мутації:

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam defaultFontName "Helvetica"

participant "Користувач" as User #DBEAFE
participant "CreateTripScreen" as Screen #DCFCE7
participant "createTrip (mutation)" as Mutation #FED7AA
participant "RTK Query Cache" as Cache #FEF3C7
participant "Backend API" as API #FECACA
participant "getTrips (query)" as Query #E0E7FF

User -> Screen : Заповнює форму\nта натискає "Створити"
Screen -> Mutation : createTrip({ title, region })
Mutation -> Mutation : isLoading = true

Mutation -> API : POST /trips\n{ title, region, startDate }
API --> Mutation : 201 Created\n{ id: "123", title: "Париж", ... }

Mutation -> Mutation : isSuccess = true\nisLoading = false

Mutation -> Cache : invalidatesTags: ['Trip']
note right of Cache
    RTK Query бачить, що тег 'Trip'
    інвалідовано. Шукає всі активні
    запити з цим тегом.
end note

Cache -> Query : getTrips має тег 'Trip'\n→ потрібно оновити!
Query -> API : GET /trips (автоматичний refetch)
API --> Query : 200 OK [список з новою поїздкою]

Query --> Screen : Оновлює UI на екрані списку
note over User, Screen #DCFCE7
    Користувач повертається назад
    і одразу бачить нову поїздку!
end note

@enduml

Розуміння результату хука мутації

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 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: 'Не вдалося створити' })
    }
}

Коли використовувати кожен підхід:

  • 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'],
        }),
    }),
})

Магія:

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam defaultFontName "Helvetica"

participant "TripsListScreen" as List #DBEAFE
participant "getTrips query" as Query #DCFCE7
participant "RTK Query Cache" as Cache #FEF3C7
participant "createTrip mutation" as Mutation #FED7AA

List -> Query : useGetTripsQuery()
Query -> Cache : Зберігає дані з тегом 'Trip'
note right of Cache
    Cache = {
        getTrips: {
            data: [...],
            tags: ['Trip']
        }
    }
end note

Mutation -> Mutation : Користувач створює поїздку
Mutation -> Cache : invalidatesTags: ['Trip']

Cache -> Cache : Шукає всі запити з тегом 'Trip'
Cache -> Query : Знайдено getTrips з тегом 'Trip'\n→ Позначити як STALE

Query -> Query : Автоматичний refetch
Query --> List : Оновлює UI зі свіжими даними

@enduml

Тепер при створенні поїздки список автоматично оновиться без жодного ручного коду!

Рівень 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' }],
        }),
    }),
})

Розберемо детально:

Візуалізація: Як працює гранулярна інвалідація

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam defaultFontName "Helvetica"

package "Cache після getTrips()" {
    card "getTrips()" as List #DCFCE7 {
        [data: Trip[]]
        [tags:]
        [{ type: 'Trip', id: '1' }]
        [{ type: 'Trip', id: '2' }]
        [{ type: 'Trip', id: '3' }]
        [{ type: 'Trip', id: 'LIST' }]
    }
}

package "Cache після getTripById('2')" {
    card "getTripById('2')" as Detail #DBEAFE {
        [data: Trip]
        [tags:]
        [{ type: 'Trip', id: '2' }]
    }
}

package "Mutation: updateTrip({ id: '2', title: 'Нова назва' })" as Update #FED7AA {
    [invalidatesTags:]
    [{ type: 'Trip', id: '2' }]
}

Update --> List : Перевіряє теги\nЗнайдено { type: 'Trip', id: '2' }\n→ REFETCH
Update --> Detail : Перевіряє теги\nЗнайдено { type: 'Trip', id: '2' }\n→ REFETCH

note right of Update #FEF3C7
    Тільки запити з тегом
    { type: 'Trip', id: '2' }
    будуть оновлені.
    
    Інші поїздки ('1', '3')
    не будуть перезавантажені!
end note

@enduml

Рівень 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}` },  // Очистити коментарі
            ],
        }),
    }),
})

Стратегії тегування: Коли що використовувати

СценарійСтратегіяПриклад
Створення нової сутностіІнвалідувати LISTinvalidatesTags: [{ type: 'Trip', id: 'LIST' }]
Оновлення існуючої сутностіІнвалідувати конкретний IDinvalidatesTags: [{ type: 'Trip', id: tripId }]
Видалення сутностіІнвалідувати ID + LISTinvalidatesTags: [{ 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)


Просунуті техніки 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 інтеграцію для реального часу

📚 Рекомендовані ресурси

🚀 Практичні завдання

  1. Базовий рівень: Додайте пошук поїздок з debounce (затримка 300мс)
  2. Середній рівень: Реалізуйте оптимістичне редагування назви поїздки
  3. Просунутий рівень: Створіть систему тегів для коментарів з умовною інвалідацією

Поширені запитання (FAQ)

Copyright © 2026