Урок 10

Практика: интеграция с фронтом

API-клиент

// lib/api-client.ts
const BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? '';

export class ApiError extends Error {
    constructor(public status: number, message: string, public details?: unknown) {
        super(message);
        this.name = 'ApiError';
    }
}

async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
    const response = await fetch(`${BASE_URL}${path}`, {
        ...options,
        headers: {
            'Content-Type': 'application/json',
            ...options.headers,
        },
        credentials: 'include',
    });

    if (!response.ok) {
        const error = await response.json().catch(() => ({ error: 'Unknown error' }));
        throw new ApiError(response.status, error.error ?? 'Request failed', error.details);
    }

    if (response.status === 204) return null as T;

    return response.json();
}

export const api = {
    get: <T>(path: string) => request<T>(path),
    post: <T>(path: string, body: unknown) =>
        request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
    patch: <T>(path: string, body: unknown) =>
        request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
    delete: (path: string) => request<void>(path, { method: 'DELETE' }),
};

Типы

// lib/types.ts
export interface Post {
    id: number;
    title: string;
    slug: string;
    body: string;
    published: boolean;
    authorId: number;
    author?: { id: number; name: string };
    createdAt: string;
    updatedAt: string;
}

export interface Paginated<T> {
    data: T[];
    meta: {
        page: number;
        limit: number;
        total: number;
        totalPages: number;
    };
}

Хук для списка

'use client';

import { useState, useEffect } from 'react';
import { api, ApiError } from '@/lib/api-client';
import type { Post, Paginated } from '@/lib/types';

export function usePosts(params: { page?: number; search?: string }) {
    const [data, setData] = useState<Paginated<Post> | null>(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState<ApiError | null>(null);

    const query = new URLSearchParams();
    if (params.page) query.set('page', String(params.page));
    if (params.search) query.set('search', params.search);

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

        setLoading(true);
        api.get<Paginated<Post>>(`/api/posts?${query}`)
            .then((res) => { if (!cancelled) setData(res); })
            .catch((err) => { if (!cancelled) setError(err); })
            .finally(() => { if (!cancelled) setLoading(false); });

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

    return { data, loading, error };
}

TanStack Query

Лучше — через библиотеку:

npm install @tanstack/react-query
'use client';

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

const queryClient = new QueryClient();

export function Providers({ children }: { children: React.ReactNode }) {
    return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

function PostsList() {
    const { data, isLoading, error } = useQuery({
        queryKey: ['posts'],
        queryFn: () => api.get<Paginated<Post>>('/api/posts'),
    });

    if (isLoading) return <Skeleton />;
    if (error) return <Error message={error.message} />;

    return <ul>{data?.data.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}

Мутация

'use client';

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

export function CreatePost() {
    const queryClient = useQueryClient();

    const mutation = useMutation({
        mutationFn: (data: PostInput) => api.post<Post>('/api/posts', data),
        onSuccess: () => {
            queryClient.invalidateQueries({ queryKey: ['posts'] });
        },
    });

    return (
        <button
            onClick={() => mutation.mutate({ title: 'Новый', body: 'Текст' })}
            disabled={mutation.isPending}
        >
            {mutation.isPending ? 'Создание...' : 'Создать'}
        </button>
    );
}

Оптимистичное обновление

useMutation({
    mutationFn: (data: PostInput) => api.post<Post>('/api/posts', data),
    onMutate: async (newPost) => {
        await queryClient.cancelQueries({ queryKey: ['posts'] });

        const previous = queryClient.getQueryData<Paginated<Post>>(['posts']);

        queryClient.setQueryData<Paginated<Post>>(['posts'], (old) => ({
            ...old!,
            data: [
                { ...newPost, id: Date.now(), slug: '', published: false, authorId: 0, createdAt: '', updatedAt: '' },
                ...old!.data,
            ],
        }));

        return { previous };
    },
    onError: (_, __, context) => {
        queryClient.setQueryData(['posts'], context?.previous);
    },
    onSettled: () => {
        queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
});

Server Component + API

Если API внешний:

// app/posts/page.tsx
export default async function Posts() {
    const res = await fetch(`${process.env.API_URL}/api/posts`, {
        next: { revalidate: 60, tags: ['posts'] },
    });
    const data = await res.json();

    return <PostList posts={data.data} />;
}

Но обычно в Next.js прямой доступ к БД быстрее.

Загрузка файлов

'use client';

async function uploadFile(file: File, onProgress?: (p: number) => void) {
    return new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();

        xhr.upload.onprogress = (e) => {
            if (e.lengthComputable && onProgress) {
                onProgress((e.loaded / e.total) * 100);
            }
        };

        xhr.onload = () => resolve(JSON.parse(xhr.response));
        xhr.onerror = () => reject(new Error('Upload failed'));

        const formData = new FormData();
        formData.append('file', file);

        xhr.open('POST', '/api/upload');
        xhr.send(formData);
    });
}

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

function ErrorMessage({ error }: { error: ApiError }) {
    if (error.status === 401) return <p>Войдите в аккаунт</p>;
    if (error.status === 403) return <p>Нет доступа</p>;
    if (error.status === 404) return <p>Не найдено</p>;
    if (error.status === 422) return <ValidationErrors errors={error.details} />;
    return <p>{error.message}</p>;
}

Итоги

  • API-клиент с классом ошибок
  • Типы из бэкенда
  • TanStack Query для кеша
  • Мутации и инвалидация
  • Оптимистичные обновления
  • XHR для прогресса загрузки