Практика: интеграция с фронтом
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 для прогресса загрузки