Module 07 — Networking & API

PHẢI CÓ ⏱ 8-10 giờ 📋 Prerequisites: Module 01 (JavaScript Core), Module 04 (State Management), Module 06 (TypeScript)

🎯 MỤC TIÊU HỌC TẬP

📖 HƯỚNG DẪN HỌC

Step 1: Đọc Axios documentation (1h)

Truy cập axios-http.com/docs. Tập trung vào: creating instances, interceptors, request config, response schema, error handling. Không cần đọc hết — focus vào phần Instance, Interceptors, và Cancellation.

Step 2: Build Axios instance với interceptors (2h)

Tạo một file api/client.ts trong project React Native. Config base URL, timeout, default headers. Thêm request interceptor để attach token, response interceptor để handle errors. Test với một API thực (dùng JSONPlaceholder hoặc ReqRes).

Step 3: Implement token refresh flow (2h)

Đây là phần quan trọng nhất. Implement logic: khi nhận 401, tự động refresh token và retry request. Phải handle concurrent requests bị 401 cùng lúc (queue mechanism). Đọc kỹ phần lý thuyết bên dưới trước khi code.

Step 4: Đọc TanStack Query docs (1.5h)

Truy cập TanStack Query docs. Đọc theo thứ tự: Overview → Quick Start → useQuery → useMutation → Query Invalidation → Infinite Queries. Chạy thử từng example.

Step 5: Build data-fetching layer hoàn chỉnh (2-3h)

Kết hợp Axios instance với React Query. Tạo custom hooks cho CRUD operations. Implement infinite scroll cho một list screen. Test cache invalidation khi create/update/delete.

⚡ ÔN NHANH

🧭 Vòng lặp học module này

📚 LÝ THUYẾT CHI TIẾT

1. Fetch API — Gotchas quan trọng

⚠️ Fetch KHÔNG throw error cho HTTP 4xx/5xx

Đây là gotcha lớn nhất. Fetch chỉ reject Promise khi có network error (mất mạng, DNS fail). Với response 404 hay 500, Fetch vẫn resolve bình thường — bạn phải tự check response.ok.

Ví dụ sai — code này KHÔNG catch được lỗi 404:

try {
  const response = await fetch('https://api.example.com/users/999');
  const data = await response.json();
  console.log(data);
} catch (error) {
  console.log('Lỗi này KHÔNG BAO GIỜ chạy cho 404');
}

Ví dụ đúng — luôn check response.ok:

const response = await fetch('https://api.example.com/users/999');

if (!response.ok) {
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}

const data = await response.json();

Các gotchas khác của Fetch API

Abort request với timeout sử dụng AbortController:

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);

try {
  const response = await fetch('https://api.example.com/data', {
    signal: controller.signal,
  });
  clearTimeout(timeoutId);
  const data = await response.json();
  return data;
} catch (error) {
  if (error.name === 'AbortError') {
    console.log('Request timed out');
  }
  throw error;
}

2. Axios — Instance, Interceptors, Cancel Tokens

2.1 Tạo Axios Instance

Thay vì dùng axios.get() trực tiếp, luôn tạo instance riêng. Điều này cho phép config base URL, headers, timeout một lần duy nhất và reuse khắp app.

import axios from 'axios';
import type { AxiosInstance, AxiosRequestConfig } from 'axios';

const API_BASE_URL = 'https://api.example.com/v1';

const apiClient: AxiosInstance = axios.create({
  baseURL: API_BASE_URL,
  timeout: 15000,
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  },
});

2.2 Request Interceptor

Request interceptor chạy TRƯỚC mỗi request được gửi đi. Dùng để attach token, add tracking headers, log requests.

apiClient.interceptors.request.use(
  async (config) => {
    const token = await getAccessToken();
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    config.headers['X-Request-ID'] = generateUUID();
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

2.3 Response Interceptor

Response interceptor xử lý response TRƯỚC khi trả về cho caller. Dùng để transform data, handle errors globally, refresh token.

apiClient.interceptors.response.use(
  (response) => {
    return response.data;
  },
  async (error) => {
    if (error.response) {
      switch (error.response.status) {
        case 401:
          break;
        case 403:
          break;
        case 429:
          break;
        case 500:
          break;
      }
    }
    return Promise.reject(error);
  }
);

2.4 Cancel Requests

Trong React Native, cancel request khi user rời screen để tránh memory leak và state update trên unmounted component. Từ Axios v0.22+, dùng AbortController thay cho CancelToken (đã deprecated).

const controller = new AbortController();

apiClient.get('/users', {
  signal: controller.signal,
});

controller.abort();

Trong React component, cleanup khi unmount:

useEffect(() => {
  const controller = new AbortController();

  const fetchData = async () => {
    try {
      const data = await apiClient.get('/users', {
        signal: controller.signal,
      });
      setUsers(data);
    } catch (error) {
      if (!axios.isCancel(error)) {
        setError(error);
      }
    }
  };

  fetchData();

  return () => controller.abort();
}, []);

3. Token Refresh Flow — Chi tiết step by step

Tại sao cần Token Refresh?

Access token có thời hạn ngắn (thường 15-30 phút) vì lý do bảo mật. Khi hết hạn, thay vì bắt user đăng nhập lại, ta dùng refresh token (thời hạn dài hơn, 7-30 ngày) để xin access token mới một cách im lặng (silent refresh).

3.1 Flow cơ bản

  1. Client gửi request với access token trong header Authorization
  2. Server trả về 401 Unauthorized (token hết hạn)
  3. Client gửi refresh token lên endpoint /auth/refresh
  4. Server trả về access token mới + refresh token mới
  5. Client lưu tokens mới vào storage
  6. Client retry request ban đầu với access token mới

3.2 Vấn đề: Concurrent 401s

🚨 Race Condition với Multiple 401s

Khi access token hết hạn, có thể 3-5 requests đang chạy đồng thời và tất cả đều nhận 401. Nếu mỗi request đều gọi refresh token riêng, sẽ gây ra:

3.3 Giải pháp: Queue Mechanism

Dùng một biến flag isRefreshing và một queue chứa các requests đang chờ. Chỉ request đầu tiên nhận 401 mới thực sự gọi refresh. Các requests sau sẽ chờ trong queue và được retry sau khi refresh thành công.

Implementation đầy đủ:

import axios from 'axios';
import type {
  AxiosInstance,
  AxiosError,
  InternalAxiosRequestConfig,
} from 'axios';

const apiClient: AxiosInstance = axios.create({
  baseURL: 'https://api.example.com/v1',
  timeout: 15000,
});

let isRefreshing = false;
let failedQueue: Array<{
  resolve: (token: string) => void;
  reject: (error: AxiosError) => void;
}> = [];

const processQueue = (
  error: AxiosError | null,
  token: string | null
): void => {
  failedQueue.forEach(({ resolve, reject }) => {
    if (error) {
      reject(error);
    } else {
      resolve(token!);
    }
  });
  failedQueue = [];
};

apiClient.interceptors.request.use(async (config) => {
  const token = await TokenStorage.getAccessToken();
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

apiClient.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const originalRequest = error.config as InternalAxiosRequestConfig & {
      _retry?: boolean;
    };

    if (error.response?.status !== 401 || originalRequest._retry) {
      return Promise.reject(error);
    }

    if (isRefreshing) {
      return new Promise<string>((resolve, reject) => {
        failedQueue.push({ resolve, reject });
      }).then((token) => {
        originalRequest.headers.Authorization = `Bearer ${token}`;
        return apiClient(originalRequest);
      });
    }

    originalRequest._retry = true;
    isRefreshing = true;

    try {
      const refreshToken = await TokenStorage.getRefreshToken();

      if (!refreshToken) {
        throw new Error('No refresh token available');
      }

      const { data } = await axios.post(
        'https://api.example.com/v1/auth/refresh',
        { refreshToken }
      );

      const { accessToken, refreshToken: newRefreshToken } = data;

      await TokenStorage.setTokens(accessToken, newRefreshToken);

      processQueue(null, accessToken);

      originalRequest.headers.Authorization = `Bearer ${accessToken}`;
      return apiClient(originalRequest);
    } catch (refreshError) {
      processQueue(refreshError as AxiosError, null);

      await TokenStorage.clearTokens();
      navigateToLogin();

      return Promise.reject(refreshError);
    } finally {
      isRefreshing = false;
    }
  }
);

Giải thích flow xử lý concurrent 401s

  1. Request A nhận 401 → isRefreshing = false → set thành true, bắt đầu refresh
  2. Request B nhận 401 → isRefreshing = true → được push vào failedQueue, trả về Promise đang pending
  3. Request C nhận 401 → tương tự B, push vào queue
  4. Refresh thành công → processQueue resolve tất cả Promise trong queue với token mới
  5. B và C tự động retry với token mới
  6. Nếu refresh fail → processQueue reject tất cả → redirect về Login

3.4 TokenStorage helper

Sử dụng react-native-keychain hoặc expo-secure-store cho production. KHÔNG dùng AsyncStorage cho tokens vì không được encrypt.

import * as SecureStore from 'expo-secure-store';

const TokenStorage = {
  async getAccessToken(): Promise<string | null> {
    return SecureStore.getItemAsync('access_token');
  },

  async getRefreshToken(): Promise<string | null> {
    return SecureStore.getItemAsync('refresh_token');
  },

  async setTokens(
    accessToken: string,
    refreshToken: string
  ): Promise<void> {
    await Promise.all([
      SecureStore.setItemAsync('access_token', accessToken),
      SecureStore.setItemAsync('refresh_token', refreshToken),
    ]);
  },

  async clearTokens(): Promise<void> {
    await Promise.all([
      SecureStore.deleteItemAsync('access_token'),
      SecureStore.deleteItemAsync('refresh_token'),
    ]);
  },
};

4. React Query (TanStack Query)

4.1 Setup

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000,
      gcTime: 10 * 60 * 1000,
      retry: 3,
      retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
      refetchOnWindowFocus: false,
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Navigation />
    </QueryClientProvider>
  );
}

staleTime vs gcTime (cacheTime cũ)

4.2 useQuery

useQuery dùng cho GET requests — đọc data. Nó tự động handle loading, error, caching, refetching.

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

interface User {
  id: number;
  name: string;
  email: string;
}

const useUser = (userId: number) => {
  return useQuery<User>({
    queryKey: ['users', userId],
    queryFn: () => apiClient.get(`/users/${userId}`),
    enabled: userId > 0,
    staleTime: 30 * 1000,
  });
};

function UserProfile({ userId }: { userId: number }) {
  const { data, isLoading, isError, error, refetch } = useUser(userId);

  if (isLoading) return <ActivityIndicator />;
  if (isError) return <ErrorView error={error} onRetry={refetch} />;

  return <Text>{data.name}</Text>;
}

4.3 Query Keys — Thiết kế đúng

Query key là identity của cache entry. React Query dùng nó để determine khi nào refetch và cache data ở đâu. Thiết kế key tốt rất quan trọng cho cache invalidation.

const queryKeys = {
  users: {
    all: ['users'] as const,
    lists: () => [...queryKeys.users.all, 'list'] as const,
    list: (filters: UserFilters) =>
      [...queryKeys.users.lists(), filters] as const,
    details: () => [...queryKeys.users.all, 'detail'] as const,
    detail: (id: number) =>
      [...queryKeys.users.details(), id] as const,
  },

  posts: {
    all: ['posts'] as const,
    byUser: (userId: number) =>
      [...queryKeys.posts.all, 'user', userId] as const,
  },
};

4.4 useMutation

useMutation dùng cho POST/PUT/PATCH/DELETE — thay đổi data. Sau khi mutation thành công, dùng invalidateQueries hoặc setQueryData để update cache.

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

interface CreateUserInput {
  name: string;
  email: string;
}

const useCreateUser = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (input: CreateUserInput) =>
      apiClient.post('/users', input),

    onSuccess: (newUser) => {
      queryClient.invalidateQueries({
        queryKey: queryKeys.users.lists(),
      });

      queryClient.setQueryData(
        queryKeys.users.detail(newUser.id),
        newUser
      );
    },

    onError: (error) => {
      showToast('Tạo user thất bại');
    },
  });
};

invalidateQueries vs setQueryData

4.5 Optimistic Updates

Update UI ngay lập tức trước khi server confirm, rollback nếu fail. Tạo UX mượt mà hơn.

const useUpdateUser = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ id, ...data }: UpdateUserInput) =>
      apiClient.patch(`/users/${id}`, data),

    onMutate: async (updatedUser) => {
      await queryClient.cancelQueries({
        queryKey: queryKeys.users.detail(updatedUser.id),
      });

      const previousUser = queryClient.getQueryData<User>(
        queryKeys.users.detail(updatedUser.id)
      );

      queryClient.setQueryData(
        queryKeys.users.detail(updatedUser.id),
        (old: User | undefined) => (old ? { ...old, ...updatedUser } : old)
      );

      return { previousUser };
    },

    onError: (_error, updatedUser, context) => {
      if (context?.previousUser) {
        queryClient.setQueryData(
          queryKeys.users.detail(updatedUser.id),
          context.previousUser
        );
      }
    },

    onSettled: (_data, _error, updatedUser) => {
      queryClient.invalidateQueries({
        queryKey: queryKeys.users.detail(updatedUser.id),
      });
    },
  });
};

5. Infinite Scroll — useInfiniteQuery + FlatList

Pattern phổ biến trong mobile: load thêm data khi user scroll tới cuối list.

import { useInfiniteQuery } from '@tanstack/react-query';
import { FlatList, ActivityIndicator } from 'react-native';

interface PaginatedResponse<T> {
  data: T[];
  nextCursor: string | null;
  hasMore: boolean;
}

const useInfiniteUsers = () => {
  return useInfiniteQuery<PaginatedResponse<User>>({
    queryKey: ['users', 'infinite'],
    queryFn: ({ pageParam }) =>
      apiClient.get('/users', {
        params: { cursor: pageParam, limit: 20 },
      }),
    initialPageParam: undefined as string | undefined,
    getNextPageParam: (lastPage) =>
      lastPage.hasMore ? lastPage.nextCursor : undefined,
  });
};

function UserList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
    isError,
    refetch,
  } = useInfiniteUsers();

  const allUsers = data?.pages.flatMap((page) => page.data) ?? [];

  const handleEndReached = () => {
    if (hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  };

  if (isLoading) return <ActivityIndicator size="large" />;

  return (
    <FlatList
      data={allUsers}
      keyExtractor={(item) => item.id.toString()}
      renderItem={({ item }) => <UserCard user={item} />}
      onEndReached={handleEndReached}
      onEndReachedThreshold={0.5}
      refreshing={isLoading}
      onRefresh={refetch}
      ListFooterComponent={
        isFetchingNextPage ? <ActivityIndicator /> : null
      }
    />
  );
}

⚠️ Lưu ý với onEndReached

6. Error Handling Patterns

6.1 Typed Error Response

interface ApiError {
  status: number;
  code: string;
  message: string;
  details?: Record<string, string[]>;
}

class ApiException extends Error {
  status: number;
  code: string;
  details?: Record<string, string[]>;

  constructor(error: ApiError) {
    super(error.message);
    this.name = 'ApiException';
    this.status = error.status;
    this.code = error.code;
    this.details = error.details;
  }

  get isUnauthorized(): boolean {
    return this.status === 401;
  }

  get isValidationError(): boolean {
    return this.status === 422;
  }

  get isServerError(): boolean {
    return this.status >= 500;
  }

  get isNetworkError(): boolean {
    return this.status === 0;
  }
}

6.2 Response Interceptor với Error Transform

apiClient.interceptors.response.use(
  (response) => response.data,
  (error: AxiosError<ApiError>) => {
    if (!error.response) {
      return Promise.reject(
        new ApiException({
          status: 0,
          code: 'NETWORK_ERROR',
          message: 'Không có kết nối mạng',
        })
      );
    }

    return Promise.reject(
      new ApiException({
        status: error.response.status,
        code: error.response.data?.code ?? 'UNKNOWN',
        message:
          error.response.data?.message ?? 'Đã có lỗi xảy ra',
        details: error.response.data?.details,
      })
    );
  }
);

6.3 Retry Strategy với Exponential Backoff

const retryableStatusCodes = new Set([408, 429, 500, 502, 503, 504]);

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: (failureCount, error) => {
        if (failureCount >= 3) return false;
        if (error instanceof ApiException) {
          return retryableStatusCodes.has(error.status);
        }
        return false;
      },
      retryDelay: (attemptIndex) =>
        Math.min(1000 * 2 ** attemptIndex, 30000),
    },
  },
});

7. REST — HTTP Methods, Status Codes, Pagination

7.1 HTTP Methods

MethodMục đíchIdempotent?Request Body?Ví dụ
GETĐọc resourceGET /users/123
POSTTạo resourcePOST /users
PUTReplace toàn bộ resourcePUT /users/123
PATCHUpdate một phần resource❌*PATCH /users/123
DELETEXóa resourceDELETE /users/123

*PATCH có thể idempotent hoặc không, tùy implementation. Ví dụ {"name": "John"} là idempotent, nhưng {"$inc": {"views": 1}} thì không.

7.2 HTTP Status Codes

CodeTênKhi nào dùng
200OKGET/PUT/PATCH thành công
201CreatedPOST tạo resource thành công
204No ContentDELETE thành công, không có body
400Bad RequestRequest sai format, thiếu field
401UnauthorizedChưa authenticate (token missing/invalid/expired)
403ForbiddenĐã authenticate nhưng không có quyền
404Not FoundResource không tồn tại
409ConflictDuplicate resource (email đã tồn tại)
422Unprocessable EntityValidation errors
429Too Many RequestsRate limited
500Internal Server ErrorServer gặp lỗi không xác định
502Bad GatewayServer trung gian nhận response lỗi
503Service UnavailableServer tạm thời không available

7.3 Pagination — Cursor vs Offset

Tiêu chíOffset-basedCursor-based
Cách dùng?page=2&limit=20?cursor=abc123&limit=20
Jump to page✅ Được❌ Không được
Performance lớn❌ Chậm (OFFSET SQL)✅ Nhanh (WHERE id > cursor)
Data thay đổi❌ Bị trùng/thiếu items✅ Ổn định
Phù hợp choAdmin panels, search resultsSocial feeds, infinite scroll

Tại sao Cursor pagination tốt hơn cho mobile?

Mobile app chủ yếu dùng infinite scroll, không cần jump to page. Cursor-based pagination không bị ảnh hưởng khi có data mới được insert (offset sẽ bị lệch, gây duplicate items). Với dataset lớn, cursor cũng nhanh hơn nhiều vì database dùng index thay vì scan + skip.

📖 ĐỌC SÂU KHI DỰ ÁN CẦN

GraphQL, WebSocket và rate limiting quan trọng nhưng không phải app nào cũng dùng. Nếu mục tiêu là build CRUD/mobile commerce thông thường, hãy học chắc Axios, token refresh, React Query, pagination và error handling trước.

8. GraphQL Basics

8.1 Query và Mutation

GraphQL cho phép client request chính xác data cần thiết, giảm over-fetching và under-fetching.

query GetUser($id: ID!) {
  user(id: $id) {
    id
    name
    email
    posts(first: 5) {
      title
      createdAt
    }
  }
}

mutation CreatePost($input: CreatePostInput!) {
  createPost(input: $input) {
    id
    title
    author {
      name
    }
  }
}

8.2 Apollo Client vs urql

Tiêu chíApollo Clienturql
Bundle size~40KB~12KB
CacheNormalized (mạnh, phức tạp)Document cache (đơn giản)
DevToolsRất tốtTốt
Learning curveCaoThấp
EcosystemLớn nhấtĐang phát triển
Khi nào dùngApp lớn, cần normalized cacheApp nhỏ-trung, muốn lightweight

8.3 Apollo Client Setup trong React Native

import {
  ApolloClient,
  InMemoryCache,
  ApolloProvider,
  createHttpLink,
} from '@apollo/client';
import { setContext } from '@apollo/client/link/context';

const httpLink = createHttpLink({
  uri: 'https://api.example.com/graphql',
});

const authLink = setContext(async (_, { headers }) => {
  const token = await TokenStorage.getAccessToken();
  return {
    headers: {
      ...headers,
      authorization: token ? `Bearer ${token}` : '',
    },
  };
});

const client = new ApolloClient({
  link: authLink.concat(httpLink),
  cache: new InMemoryCache(),
});

function App() {
  return (
    <ApolloProvider client={client}>
      <Navigation />
    </ApolloProvider>
  );
}

9. WebSocket vs SSE vs Long Polling

Tiêu chíWebSocketSSE (Server-Sent Events)Long Polling
DirectionBidirectional (full-duplex)Server → Client onlySimulated bidirectional
Protocolws:// hoặc wss://HTTPHTTP
ConnectionPersistentPersistentRepeated requests
Binary data❌ (text only)
Auto reconnect❌ (tự implement)✅ (built-in)✅ (tự implement)
OverheadThấp sau handshakeThấpCao (mỗi lần new request)
Use casesChat, gaming, real-time collabNotifications, feeds, stock pricesLegacy systems, fallback
RN supportTốt (built-in WebSocket API)Hạn chế (cần polyfill)Tốt (dùng fetch/axios)

WebSocket cơ bản trong React Native:

const useWebSocket = (url: string) => {
  const wsRef = useRef<WebSocket | null>(null);
  const [messages, setMessages] = useState<string[]>([]);
  const [isConnected, setIsConnected] = useState(false);
  const reconnectAttempts = useRef(0);
  const maxReconnectAttempts = 5;

  const connect = useCallback(() => {
    const ws = new WebSocket(url);

    ws.onopen = () => {
      setIsConnected(true);
      reconnectAttempts.current = 0;
    };

    ws.onmessage = (event) => {
      setMessages((prev) => [...prev, event.data]);
    };

    ws.onclose = () => {
      setIsConnected(false);
      if (reconnectAttempts.current < maxReconnectAttempts) {
        const delay = Math.min(
          1000 * 2 ** reconnectAttempts.current,
          30000
        );
        reconnectAttempts.current += 1;
        setTimeout(connect, delay);
      }
    };

    ws.onerror = () => {
      ws.close();
    };

    wsRef.current = ws;
  }, [url]);

  const send = useCallback((data: string) => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(data);
    }
  }, []);

  useEffect(() => {
    connect();
    return () => wsRef.current?.close();
  }, [connect]);

  return { messages, send, isConnected };
};

10. Rate Limiting (429)

⚠️ Xử lý 429 Too Many Requests

Khi server trả 429, response thường có header Retry-After cho biết bao lâu phải chờ (tính bằng giây). Client phải respect giá trị này.

apiClient.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    if (error.response?.status === 429) {
      const retryAfter = error.response.headers['retry-after'];
      const delayMs = retryAfter
        ? parseInt(retryAfter, 10) * 1000
        : 5000;

      await new Promise((resolve) => setTimeout(resolve, delayMs));

      return apiClient(error.config!);
    }
    return Promise.reject(error);
  }
);

Best Practices cho Rate Limiting

❓ CÂU HỎI PHỎNG VẤN + ĐÁP ÁN

🛠️ Debug playbook: API bug

Triệu chứngKiểm traFix thường gặp
Nhiều refresh token request cùng lúcLog 401 time, isRefreshing flag, failedQueue lengthMột refresh request duy nhất, request còn lại chờ queue
Loading không bao giờ tắtTimeout, abort, finally, query error stateConfig timeout, handle error/finally, cancel khi unmount
List duplicate item khi load moreonEndReached fire nhiều lần, cursor, hasNextPageGuard isFetchingNextPage, cursor pagination, stable key
Retry spam backendStatus code nào đang retry, retryDelay, jitterChỉ retry network/5xx/429 phù hợp, không retry 400/401/422

Khung trả lời phỏng vấn: API layer chuyên nghiệp gồm auth header, token refresh, typed error, timeout/cancel, retry có điều kiện, cache/invalidation và UI error matrix. Nói rõ lỗi nào retry được, lỗi nào phải để user xử lý.

1. Fetch API và Axios khác nhau như thế nào? Khi nào nên dùng cái nào?

Fetch API là web standard, built-in trong React Native, không cần install thêm. Tuy nhiên, Fetch không throw error cho HTTP 4xx/5xx (chỉ reject khi network error), không có interceptors, không tự transform JSON, và không có request timeout built-in (phải dùng AbortController).

Axios là third-party library. Ưu điểm: tự throw error cho non-2xx status, có interceptors (request/response), tự transform JSON, hỗ trợ timeout config, cancel requests, progress tracking cho upload.

Khi nào dùng: Fetch phù hợp cho small projects không cần interceptors. Axios phù hợp cho production apps cần token management, error handling tập trung, và response transformation. Trong thực tế, đa số production React Native apps dùng Axios kết hợp với React Query.

2. Giải thích token refresh flow. Làm sao handle khi nhiều requests đồng thời nhận 401?

Flow cơ bản: Access token hết hạn → server trả 401 → client dùng refresh token xin access token mới → retry request ban đầu.

Vấn đề concurrent 401s: Nếu 5 requests cùng nhận 401, không thể gọi refresh 5 lần vì: (1) lãng phí, (2) nếu server dùng refresh token rotation, lần refresh thứ 2 sẽ fail vì token cũ đã bị invalidate.

Giải pháp — Queue mechanism:

  • Dùng biến isRefreshing flag
  • Request đầu tiên nhận 401 → set isRefreshing = true, gọi refresh
  • Các requests sau nhận 401 → thấy isRefreshing = true → push vào failedQueue (mảng chứa Promise resolve/reject)
  • Refresh thành công → processQueue resolve tất cả Promises trong queue với token mới → mỗi request tự retry
  • Refresh fail → processQueue reject tất cả → redirect về login
3. staleTime và gcTime (cacheTime) trong React Query khác nhau thế nào?

staleTime (default: 0): Thời gian data được coi là "fresh". Khi data còn fresh, React Query sẽ KHÔNG refetch khi component mount lại hoặc window focus. Ví dụ staleTime: 5 * 60 * 1000 — data fresh trong 5 phút.

gcTime (default: 5 phút): Thời gian data được giữ trong cache SAU KHI không còn component nào subscribe. Khi hết gcTime, data bị xóa khỏi cache (garbage collected). Ví dụ user rời screen A, data của screen A vẫn trong cache 5 phút, quay lại sẽ hiển thị ngay.

Mối quan hệ: staleTime kiểm soát khi nào refetch, gcTime kiểm soát khi nào xóa cache. Thường gcTime >= staleTime.

4. So sánh cursor-based pagination và offset-based pagination. Cái nào phù hợp cho mobile?

Offset-based (?page=2&limit=20): Dễ implement, cho phép nhảy đến page bất kỳ. Nhưng có vấn đề: khi data thay đổi (insert/delete), offset bị lệch → user thấy item trùng hoặc bỏ sót. Performance kém với dataset lớn vì database phải OFFSET (scan + skip).

Cursor-based (?cursor=abc&limit=20): Data ổn định dù có insert/delete. Performance tốt vì database dùng index (WHERE id > cursor). Nhưng không thể nhảy đến page bất kỳ.

Cho mobile: Cursor-based phù hợp hơn vì mobile chủ yếu dùng infinite scroll (không cần page numbers). Social feeds, chat histories, notification lists đều nên dùng cursor.

5. Optimistic update là gì? Khi nào nên và không nên dùng?

Optimistic update: Update UI ngay lập tức trước khi server confirm, rollback nếu server trả error. Tạo UX mượt mà, user không phải chờ loading.

Nên dùng: Các action có xác suất thành công cao và kết quả dự đoán được — like/unlike, toggle bookmark, update profile name, reorder list.

Không nên dùng: Các action có side effects phức tạp hoặc xác suất fail cao — payment, gửi email, delete account, actions cần server validation phức tạp.

Implementation: Trong React Query, dùng onMutate để snapshot data cũ + update cache, onError để rollback từ snapshot, onSettled để invalidate queries (refetch data thật từ server).

6. WebSocket, SSE, và Long Polling khác nhau thế nào? Khi nào dùng cái nào?

WebSocket: Full-duplex, bidirectional qua persistent connection. Client và server đều có thể gửi data bất kỳ lúc nào. Dùng cho: chat, gaming, real-time collaboration. Cần tự implement reconnection logic.

SSE (Server-Sent Events): Unidirectional (server → client), dùng HTTP standard. Có auto-reconnect built-in. Dùng cho: notifications, live feeds, stock prices. Hạn chế trong React Native (cần polyfill).

Long Polling: Client gửi request, server giữ connection open đến khi có data mới hoặc timeout. Client nhận response → gửi request mới ngay. Overhead cao nhất vì mỗi lần tạo HTTP connection mới. Dùng cho: legacy systems, fallback khi WebSocket/SSE không khả dụng.

Cho React Native: WebSocket là lựa chọn phổ biến nhất vì React Native có built-in WebSocket API và hỗ trợ tốt. Hoặc dùng Socket.IO cho tiện — nó tự fallback giữa WebSocket và polling.

7. Làm sao handle error một cách tập trung trong React Native app?

Sử dụng layered error handling strategy:

Layer 1 — Axios Interceptor: Transform raw errors thành typed ApiException class. Tất cả API errors đều có structure thống nhất: status, code, message, details.

Layer 2 — React Query Global Error Handler: Config onError callback trong QueryClient để xử lý errors chung (ví dụ: show toast cho network error, redirect login cho 401).

Layer 3 — Component Level: Handle errors cụ thể cho từng screen. Ví dụ: validation error (422) hiển thị inline error messages, not found (404) hiển thị empty state.

Layer 4 — Error Boundary: Bắt unexpected errors, hiển thị fallback UI, log error lên crash reporting service (Sentry, Crashlytics).

8. invalidateQueries và setQueryData khác nhau thế nào? Khi nào dùng cái nào?

invalidateQueries: Mark queries matching key pattern là stale. Nếu có component đang subscribe → refetch ngay. Nếu không → refetch khi component mount lại. Dùng khi bạn KHÔNG biết chính xác data mới look like gì (ví dụ: create user xong, invalidate user list — không biết server sort/filter thế nào).

setQueryData: Update cache trực tiếp, KHÔNG trigger refetch. Dùng khi biết chính xác data mới (ví dụ: API trả về updated user object → set vào cache). Nhanh hơn vì không cần network request.

Kết hợp: Thường dùng cả hai — setQueryData cho detail queries (response API trả về đầy đủ), invalidateQueries cho list queries (cần server re-sort, re-paginate).

9. PUT và PATCH khác nhau thế nào? Khi nào dùng cái nào?

PUT replace toàn bộ resource. Client phải gửi full object. Nếu thiếu field, field đó sẽ bị xóa hoặc set default. PUT luôn idempotent — gọi 10 lần kết quả giống gọi 1 lần.

PATCH update một phần resource. Client chỉ gửi fields muốn thay đổi. Fields không gửi không bị ảnh hưởng. PATCH có thể idempotent hoặc không, tùy implementation.

Ví dụ: User có fields {name, email, avatar}.

  • PUT /users/1 body {name: "John"} → email và avatar bị mất
  • PATCH /users/1 body {name: "John"} → chỉ name thay đổi, email và avatar giữ nguyên

Thực tế: Đa số REST APIs dùng PATCH cho updates vì hiếm khi muốn replace toàn bộ resource. Mobile apps gần như luôn dùng PATCH.

10. Làm sao implement retry logic đúng cách? Exponential backoff là gì?

Exponential backoff: Tăng thời gian chờ giữa các lần retry theo cấp số nhân. Retry 1: 1s, Retry 2: 2s, Retry 3: 4s, Retry 4: 8s... Có cap tối đa (thường 30s). Tránh làm quá tải server khi server đang gặp vấn đề.

Jitter: Thêm random vào delay để tránh thundering herd — nhiều clients retry cùng lúc. Ví dụ: delay = baseDelay * 2^attempt + random(0, 1000).

Retry chỉ cho idempotent operations hoặc specific error codes:

  • ✅ Retry: 408 (Timeout), 429 (Rate Limited), 500, 502, 503, 504
  • ❌ Không retry: 400, 401, 403, 404, 409, 422

Trong React Query: Config retry (số lần) và retryDelay (hàm tính delay). Có thể dùng function cho retry để chỉ retry với certain error codes.

🏋️ BÀI TẬP

Bài tập: Build API Layer hoàn chỉnh cho User Management App

Yêu cầu

Xây dựng một data-fetching layer hoàn chỉnh cho app quản lý users. Sử dụng Axios + React Query. API backend: reqres.in (free fake API).

Part 1: Axios Setup (api/client.ts)

Part 2: Type Definitions (types/api.ts)

Part 3: React Query Hooks (hooks/useUsers.ts)

Part 4: Screens

Acceptance Criteria

🧩 MINI EXERCISE THỰC TẾ REACT NATIVE

Exercise 1: Debug concurrent 401

Giả lập 5 request cùng nhận 401. Chứng minh chỉ có 1 request refresh token chạy, 5 request ban đầu được retry đúng token mới.

Exercise 2: Error UX matrix

Tạo màn hình list users với 4 trạng thái lỗi: mất mạng, 401, 422 validation, 500. Mỗi lỗi phải hiển thị UI/action khác nhau.

Exercise 3: Infinite list không duplicate

Dùng cursor pagination + FlatList. Test trường hợp user scroll nhanh, onEndReached fire nhiều lần, và API trả page rỗng.

⚠️ LỖI THƯỜNG GẶP

🏢 CASE ĐI LÀM THẬT

Case: Sau khi access token hết hạn, Home, Notification và Profile cùng gọi API. Cả ba request nhận 401, cùng refresh token, request cuối ghi token mới nhưng request đầu retry bằng token đã bị revoke.

Cách xử lý: Dùng isRefreshing + queue. Refresh fail thì reject toàn bộ queue và điều hướng về Login một lần duy nhất. Thêm test giả lập concurrent 401 để tránh regression.

🧠 GHI NHỚ NHANH

API layer tốt không chỉ “gọi được API”. Nó phải xử lý timeout, cancel, token refresh, typed error, retry có điều kiện, cache và UI state nhất quán.

❔ CÂU HỎI TỰ KIỂM TRA

1. Khi refresh token fail, bạn clear state và navigate thế nào để không gọi logout nhiều lần?
2. Lỗi nào nên retry, lỗi nào không nên retry?
3. Khi update item trong list, khi nào dùng setQueryData, khi nào dùng invalidateQueries?
4. Cursor pagination cần backend trả về những field nào để client xử lý đúng?

✅ CHECKLIST TỰ ĐÁNH GIÁ

Kiến thức

Thực hành

📎 TÀI NGUYÊN

Documentation

Articles

Videos

Tools