Урок 11

Практика: типизация API в React

Собрали подходы к типизации API-запросов в React-приложениях.

Типы данных

type User = {
    id: number;
    name: string;
    email: string;
    createdAt: string;
};

type UserInput = Omit<User, 'id' | 'createdAt'>;
type UserUpdate = Partial<UserInput>;

API-клиент

const API_URL = import.meta.env.VITE_API_URL;

type ApiError = {
    message: string;
    errors?: Record<string, string[]>;
};

async function request<T>(path: string, init?: RequestInit): Promise<T> {
    const res = await fetch(`${API_URL}${path}`, {
        headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
        ...init,
    });

    if (!res.ok) {
        const error: ApiError = await res.json();
        throw new Error(error.message);
    }

    return res.json() as Promise<T>;
}

CRUD

export const usersApi = {
    list: (page = 1) => request<Paginated<User>>(`/api/users?page=${page}`),
    get: (id: number) => request<User>(`/api/users/${id}`),
    create: (data: UserInput) =>
        request<User>('/api/users', {
            method: 'POST',
            body: JSON.stringify(data),
        }),
    update: (id: number, data: UserUpdate) =>
        request<User>(`/api/users/${id}`, {
            method: 'PATCH',
            body: JSON.stringify(data),
        }),
    remove: (id: number) =>
        request<void>(`/api/users/${id}`, { method: 'DELETE' }),
};

Paginated

type Paginated<T> = {
    data: T[];
    meta: {
        current_page: number;
        last_page: number;
        per_page: number;
        total: number;
    };
};

Хук useQuery

function useUsers(page: number) {
    const [data, setData] = useState<Paginated<User> | null>(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState<Error | null>(null);

    useEffect(() => {
        let cancelled = false;

        usersApi.list(page)
            .then((res) => { if (!cancelled) setData(res); })
            .catch((e) => { if (!cancelled) setError(e as Error); })
            .finally(() => { if (!cancelled) setLoading(false); });

        return () => { cancelled = true; };
    }, [page]);

    return { data, loading, error };
}

TanStack Query

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

function useUsers(page: number) {
    return useQuery({
        queryKey: ['users', page],
        queryFn: () => usersApi.list(page),
    });
}

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

    return useMutation({
        mutationFn: (data: UserInput) => usersApi.create(data),
        onSuccess: () => {
            queryClient.invalidateQueries({ queryKey: ['users'] });
        },
    });
}

TypeScript выводит data как Paginated<User> | undefined, error как Error | null.

Типизированные ключи

export const userKeys = {
    all: ['users'] as const,
    lists: () => [...userKeys.all, 'list'] as const,
    list: (page: number) => [...userKeys.lists(), { page }] as const,
    detail: (id: number) => [...userKeys.all, 'detail', id] as const,
};

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

queryClient.invalidateQueries({ queryKey: userKeys.lists() });

Обработка ошибок

function UserForm() {
    const { mutate, error } = useCreateUser();

    function onSubmit(data: UserInput) {
        mutate(data, {
            onError: (err: Error) => console.error(err.message),
        });
    }
}

Response union

type ApiResponse<T> =
    | { ok: true; data: T }
    | { ok: false; error: ApiError };

async function safeRequest<T>(path: string): Promise<ApiResponse<T>> {
    const res = await fetch(path);
    if (!res.ok) {
        return { ok: false, error: await res.json() };
    }
    return { ok: true, data: await res.json() };
}

const res = await safeRequest<User>('/api/users/1');
if (res.ok) {
    console.log(res.data.name);
} else {
    console.log(res.error.message);
}

TypeScript сужает по ok.

Zod для валидации

const UserSchema = z.object({
    id: z.number(),
    name: z.string(),
    email: z.string().email(),
    createdAt: z.string(),
});

async function getUser(id: number): Promise<User> {
    const res = await fetch(`/api/users/${id}`);
    return UserSchema.parse(await res.json());
}

Runtime-валидация + типы из схемы.

Итоги

  • Типы данных с Omit, Partial
  • API-клиент с дженериком request<T>
  • Paginated<T> для списков
  • TanStack Query выводит типы
  • Типизированные ключи для инвалидации
  • Zod для runtime-валидации