Users search v2 — Expo RTK Query
~5 dkMobil / WebKararlı
GET /api/v2/users/search Expo entegrasyonu
Users Search V2 - Expo (RTK Query) Entegrasyon Dokumani
Migration rehberi
Yapılandırma sırası için Migration şablonu. Yeni düzenlemelerde Amaç → Önkoşullar → Endpoint → Request/Response → Hata kodları → Client adımları → İlgili sayfalar bölümlerini tercih edin.
Bu doküman, GET /api/v2/users/search endpointini Expo projesinde Redux Toolkit Query (RTK Query) ile entegre etmek için hazırlandı.
1) Endpoint Ozeti
- URL:
GET /api/v2/users/search - Auth:
Bearer <JWT>zorunlu - Query Params:
q?: stringpage?: number(default:1)limit?: number(default:5, max:100)expertise?: Expertise
2) Response Sozlesmesi (V2)
Not:
profilePhotoartık dönmez. UI tarafındaprofilePhoto96Urlkullanılmalıdır.
export type Expertise =
| 'musician'
| 'trainer'
| 'chef'
| 'photographer'
| 'designer'
| 'stylist'
| 'gamer'
| 'educator'
| 'coach'
| 'entertainer'
| 'developer'
| 'creator';
export type LiveStreamResponseDto = {
id: string;
title: string;
status: string;
channelName: string;
startedAt?: string | null;
createdAt: string;
updatedAt: string;
};
export type SearchUserV2Dto = {
_id: string;
username?: string;
name?: string | null;
surname?: string | null;
minutePriceInLiveStream?: number | null;
expertise?: Expertise | null;
profilePhoto96Url?: string | null;
activeLiveStream?: LiveStreamResponseDto | null;
};
export type PaginationDto = {
currentPage: number;
totalPages: number;
totalItems: number;
itemsPerPage: number;
hasNextPage?: boolean;
hasPrevPage?: boolean;
};
export type PaginatedResponseDto<T> = {
list: T[];
pagination: PaginationDto;
};
export type BaseResponseDto<T> = {
isSuccess: boolean;
statusCode: number;
data: T;
errors?: string[];
timestamp: string;
};3) RTK Query - Base API
src/store/api/baseApi.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import type { RootState } from '../store';
const API_URL = process.env.EXPO_PUBLIC_API_URL;
export const baseApi = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({
baseUrl: `${API_URL}/api`,
prepareHeaders: (headers, { getState }) => {
const token = (getState() as RootState).auth.accessToken;
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return headers;
},
}),
tagTypes: ['UsersSearchV2'],
endpoints: () => ({}),
});4) RTK Query - Users Search V2 Endpoint
src/store/api/usersSearchV2Api.ts
import { baseApi } from './baseApi';
import type {
BaseResponseDto,
Expertise,
PaginatedResponseDto,
SearchUserV2Dto,
} from '@/types/users-search-v2';
export type SearchUsersV2QueryParams = {
q?: string;
page?: number;
limit?: number;
expertise?: Expertise;
};
export const usersSearchV2Api = baseApi.injectEndpoints({
endpoints: (builder) => ({
searchUsersV2: builder.query<
PaginatedResponseDto<SearchUserV2Dto>,
SearchUsersV2QueryParams
>({
query: ({ q = '', page = 1, limit = 5, expertise }) => ({
url: 'v2/users/search',
params: {
q,
page,
limit,
...(expertise ? { expertise } : {}),
},
}),
transformResponse: (
response: BaseResponseDto<PaginatedResponseDto<SearchUserV2Dto>>,
) => response.data,
// Sonsuz liste (infinite scroll) için sayfalari tek cache key'de birlestir
serializeQueryArgs: ({ endpointName, queryArgs }) => {
const normalizedQ = (queryArgs.q ?? '').trim().toLowerCase();
const expertise = queryArgs.expertise ?? 'all';
const limit = queryArgs.limit ?? 5;
return `${endpointName}-${normalizedQ}-${expertise}-${limit}`;
},
merge: (currentCache, newData, { arg }) => {
const page = arg.page ?? 1;
// Ilk sayfada cache'i yenile
if (page === 1) {
currentCache.list = newData.list;
currentCache.pagination = newData.pagination;
return;
}
// Sonraki sayfalarda duplicate id engelleyerek ekle
const existingIds = new Set(currentCache.list.map((u) => u._id));
const toAppend = newData.list.filter((u) => !existingIds.has(u._id));
currentCache.list.push(...toAppend);
currentCache.pagination = newData.pagination;
},
forceRefetch({ currentArg, previousArg }) {
return (
(currentArg?.q ?? '').trim() !== (previousArg?.q ?? '').trim() ||
currentArg?.expertise !== previousArg?.expertise ||
(currentArg?.limit ?? 5) !== (previousArg?.limit ?? 5)
);
},
providesTags: (result) =>
result
? [
...result.list.map((u) => ({
type: 'UsersSearchV2' as const,
id: u._id,
})),
{ type: 'UsersSearchV2' as const, id: 'LIST' },
]
: [{ type: 'UsersSearchV2' as const, id: 'LIST' }],
}),
}),
});
export const { useSearchUsersV2Query } = usersSearchV2Api;5) Expo Screen Kullanim Ornegi
src/screens/SearchUsersScreen.tsx
import React, { useEffect, useMemo, useState } from 'react';
import { FlatList, Text, View } from 'react-native';
import { useSearchUsersV2Query } from '@/store/api/usersSearchV2Api';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
export function SearchUsersScreen() {
const [searchText, setSearchText] = useState('');
const [selectedExpertise, setSelectedExpertise] = useState<string | undefined>(undefined);
const [page, setPage] = useState(1);
const debouncedQuery = useDebouncedValue(searchText, 350);
// Query veya filtre degisince ilk sayfaya don
useEffect(() => {
setPage(1);
}, [debouncedQuery, selectedExpertise]);
const { data, isLoading, isFetching, error, refetch } = useSearchUsersV2Query({
q: debouncedQuery,
page,
limit: 10,
expertise: selectedExpertise as any,
});
const users = data?.list ?? [];
const hasNextPage = data?.pagination?.hasNextPage ?? false;
const onEndReached = () => {
if (!isFetching && hasNextPage) {
setPage((prev) => prev + 1);
}
};
const emptyText = useMemo(() => {
if (isLoading || isFetching) return 'Yukleniyor...';
if (!users.length) return 'Sonuc bulunamadi';
return '';
}, [isLoading, isFetching, users.length]);
return (
<View style={{ flex: 1 }}>
{/* Search input ve filter UI burada */}
{!!error && <Text>Arama sirasinda hata olustu.</Text>}
<FlatList
data={users}
keyExtractor={(item) => item._id}
onEndReachedThreshold={0.4}
onEndReached={onEndReached}
onRefresh={refetch}
refreshing={isFetching && page === 1}
ListEmptyComponent={<Text>{emptyText}</Text>}
renderItem={({ item }) => (
<View style={{ padding: 12 }}>
<Text>{item.username ?? '-'}</Text>
<Text>{item.profilePhoto96Url ?? 'no-photo'}</Text>
<Text>
{item.activeLiveStream ? 'Yayinda' : 'Yayinda değil'}
</Text>
</View>
)}
/>
</View>
);
}6) Error Handling (RTK Query)
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query';
import type { SerializedError } from '@reduxjs/toolkit';
type ApiErrorBody = {
isSuccess?: boolean;
statusCode?: number;
errors?: string[];
timestamp?: string;
};
export function getApiErrorMessage(
error: FetchBaseQueryError | SerializedError | undefined,
): string {
if (!error) return 'Bilinmeyen hata';
if ('status' in error) {
const data = error.data as ApiErrorBody | undefined;
if (data?.errors?.length) return data.errors[0];
if (typeof error.status === 'number') return `HTTP ${error.status}`;
return 'Istek hatasi';
}
return error.message ?? 'Bilinmeyen hata';
}7) Entegrasyon Kontrol Listesi
baseUrldeğeri/apiprefixi icermeli.v2endpoint çağrısı:v2/users/search.- Auth header:
Authorization: Bearer <token>. - UI
profilePhotodeğilprofilePhoto96Urlkullanmali. - Infinite scroll kullaniyorsaniz
serializeQueryArgs + mergeaktif olmali. - Filtre degisiminde
page=1resetlenmeli.
8) Ornek Raw Response
{
"isSuccess": true,
"statusCode": 200,
"data": {
"list": [
{
"_id": "698b3452cddbd8c812e1cd9f",
"username": "fgfg",
"name": "Gf",
"surname": "Fgf",
"minutePriceInLiveStream": 10,
"expertise": "trainer",
"profilePhoto96Url": "https://s.allminelive.com/profiles/.../96.webp",
"activeLiveStream": null
}
],
"pagination": {
"currentPage": 1,
"totalPages": 1,
"totalItems": 1,
"itemsPerPage": 5,
"hasNextPage": false,
"hasPrevPage": false
}
},
"errors": [],
"timestamp": "2026-02-20T08:38:51.978Z"
}