Data APIs: Loaders та Actions
Data APIs: Loaders та Actions
Це розділ, який відділяє новачків від професіоналів. До версії 6.4 React Router займався лише UI. Тепер він бере на себе керування даними.
Ми переходимо від пасивного "відображення сторінок" до активного керування життєвим циклом даних.
1. Проблема: Fetch-on-Render (Водоспади)
Традиційний спосіб завантаження даних в React має суттєві архітектурні вади.
// ❌ Старий спосіб (Fetch-on-Render)
function ProductPage() {
const [product, setProduct] = useState(null);
const { id } = useParams();
useEffect(() => {
// Починаємо завантаження ТІЛЬКИ ПІСЛЯ того, як компонент відрендерився
fetch(`/api/products/${id}`)
.then(res => res.json())
.then(setProduct);
}, [id]);
if (!product) return <Spinner />;
return <div>{product.name}</div>;
}
Чому це погано?
- Затримка (Network Chasm): Браузер завантажує JS -> React рендерить пустий компонент -> Спрацьовує ефект -> Йде запит. Ми втрачаємо дорогоцінні мілісекунди.
- Водоспади (Waterfalls): Якщо у батька свій
fetch, а у дитини свій — дитина не почне завантаження, поки батько не отримає дані і не відрендерить її.
2. Рішення: Loaders (Завантажувачі)
React Router v6.4 пропонує патерн "Fetch-then-Render". Ми кажемо роутеру: "Перш ніж показати цей маршрут, піди і візьми ось ці дані. Не малюй компонент, поки дані не будуть готові".
Анатомія loader
Loader — це звичайна асинхронна функція. Вона НЕ є хуком, тому тут не можна використовувати useParams чи useContext. Натомість, роутер передає контекст через аргументи.
// Анатомія функції loader
export async function loader({ params, request }) {
// 1. params: Об'єкт з динамічними сегментами URL (наприклад, :productId)
const id = params.productId;
// 2. request: Стандартний Web Request об'єкт.
// Дозволяє читати заголовки, cookies, URL запиту.
const url = new URL(request.url);
const searchTerm = url.searchParams.get("q");
// 3. Виконання запиту
// Важливо: ми повертаємо Promise. Роутер сам чекає його виконання (await).
// Response об'єкт від fetch можна повертати напряму!
return fetch(`/api/products/${id}?q=${searchTerm}`);
}
Реалізація в коді
import { useLoaderData } from "react-router-dom";
// 1. Компонент тепер "тупий" і чистий. Він просто показує дані.
export default function ProductDetailsPage() {
const product = useLoaderData(); // Хук для доступу до даних з loader-а
return (
<div>
<h1>{product.name}</h1>
<p>{product.price}$</p>
</div>
);
}
// 2. Експортуємо лоадер для використання в роутері
export const productLoader = async ({ params }) => {
const res = await fetch(`/api/products/${params.productId}`);
if (!res.ok) {
// Викидаємо помилку, яку спіймає <ErrorPage> (errorElement)
throw new Response("Not Found", { status: 404 });
}
return res.json();
};
Підключення в main.jsx залишається простим:
{
path: "products/:productId",
element: <ProductDetailsPage />,
loader: productLoader, // <--- Зв'язок
errorElement: <ErrorPage />,
}
3. Мутації даних: Actions та компонент <Form>
Якщо loader — це читання (GET), то action — це запис (POST, PUT, DELETE).
React Router переосмислює роботу з формами, повертаючись до витоків вебу, але з потужністю SPA. Замість того, щоб вручну писати onSubmit, e.preventDefault(), та fetch, ми використовуємо декларативний компонент <Form>.
Анатомія компонента <Form>
Компонент <Form> — це розумна обгортка над стандартним HTML <form>.
<Form
method="post" // Метод: post, put, patch, delete (get за замовчуванням)
action="/search" // Куди відправити дані (шлях до маршруту з відповідним action)
replace // Замінити поточний запис в історії браузера замість додавання нового
>
<input name="query" />
<button type="submit">Шукати</button>
</Form>
Як це працює:
- Коли користувач натискає "Submit", React Router перехоплює подію.
- Він збирає всі дані з полів введення (використовуючи атрибут
name). - Він шукає
actionфункцію, прив'язану до маршруту вactionпропсі форми (або поточного маршруту). - Він викликає цей
action, передаючи туди об'єктrequest.
Анатомія функції action
Action викликається автоматично при сабміті форми.
export async function action({ request, params }) {
// 1. Отримання даних через стандартний FormData API
const formData = await request.formData();
// Читаємо значення за атрибутом 'name' інпутів
const title = formData.get("title");
// 2. Виконання логіки (запит на сервер)
const res = await fetch('/api/items', {
method: 'POST',
body: JSON.stringify({ title }),
});
// 3. Результат
// Можна повернути помилки, об'єкт або зробити редірект
if (!res.ok) return { error: "Не вдалося зберегти" };
return redirect("/dashboard");
}
4. Стан завантаження: useNavigation
Коли ми використовуємо <Form>, браузер не перезавантажується, тому користувач може не зрозуміти, що запит пішов. Для цього нам потрібен хук useNavigation.
import { Form, useNavigation } from "react-router-dom";
function CreatePost() {
const navigation = useNavigation();
// navigation.state може бути:
// "idle" — нічого не відбувається
// "submitting" — зараз виконується action
// "loading" — action завершився, і зараз перезавантажуються лоадери
const isSubmitting = navigation.state === "submitting";
return (
<Form method="post">
<input name="title" disabled={isSubmitting} />
<button type="submit">
{isSubmitting ? "Збереження..." : "Створити"}
</button>
</Form>
);
}
5. Інтеграція з React Hook Form
Нативні <Form> від React Router ідеальні для простих мутацій (видалення, прості форми), але для складних форм з валідацією "на льоту" ми використовуємо React Hook Form (RHF).
Як їх подружити?
- RHF: Відповідає за UI, валідацію полів, відображення помилок під час введення.
- React Router Action: Відповідає за відправку даних на сервер і перехід.
- Міст: Хук
useSubmitвід React Router.
Патерн "Smart Bridge"
Ми використовуємо RHF для збору даних, а потім передаємо їх в React Router через submit().
import { useForm } from "react-hook-form";
import { useSubmit, useActionData, redirect } from "react-router-dom";
export default function NewProductPage() {
// 1. React Hook Form ініціалізація
const {
register,
handleSubmit,
formState: { errors: clientErrors } // Помилки валідації на клієнті
} = useForm();
// 2. React Router хуки
const submit = useSubmit(); // Функція для запуску action
const serverErrors = useActionData(); // Помилки, що повернув action (якщо були)
// 3. Обробник сабміту
const onSubmit = (data) => {
// data — це чистий JS об'єкт { title: "...", price: "..." }
// Ми передаємо його в submit.
// Другим аргументом вказуємо метод і action (опціонально, якщо поточний URL)
// encType: "application/json" дозволить прочитати дані через request.json() в action
submit(data, { method: "post", encType: "application/json" });
};
return (
<div className="form-container">
<h1>Створити товар</h1>
{/* RHF handleSubmit перевіряє валідацію перед викликом onSubmit */}
<form onSubmit={handleSubmit(onSubmit)}>
<div className="form-group">
<label>Назва</label>
<input
{...register("title", { required: "Це поле обов'язкове" })}
className={clientErrors.title ? "error" : ""}
/>
{/* Показуємо помилку від RHF */}
{clientErrors.title && <span className="error-msg">{clientErrors.title.message}</span>}
{/* АБО помилку від сервера */}
{serverErrors?.title && <span className="error-msg">{serverErrors.title}</span>}
</div>
<div className="form-group">
<label>Ціна</label>
<input
type="number"
{...register("price", { min: { value: 1, message: "Ціна має бути > 0" } })}
/>
{clientErrors.price && <span className="error-msg">{clientErrors.price.message}</span>}
</div>
<button type="submit">Створити</button>
</form>
</div>
);
}
// Action для обробки JSON даних
export const createProductAction = async ({ request }) => {
// Оскільки ми вказали encType: "application/json", читаємо JSON
const data = await request.json();
// Тут також можна робити серверну валідацію
if (data.title === "Заборонене слово") {
return { title: "Ця назва недопустима" }; // Повернеться в useActionData
}
await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return redirect('/products');
};
action).6. Автоматична Ревалідація
Це магія, про яку не можна мовчати.
Після того, як action успішно завершився (і зробив редірект або просто повернув дані):
- React Router знає, що дані могли змінитися.
- Він автоматично перезапускає всі loaders, які зараз активні на сторінці.
- Ваш UI оновлюється свіжими даними з сервера.
Вам більше не потрібні refetchQueries (як в React Query) або ручне оновлення контексту. Ви просто робите дію, а сторінка "самозцілюється".
Резюме Data APIs
| Інструмент | Анатомія / Роль |
|---|---|
| Loader | ({ params, request }) => Data. Запускається ДО рендеру компонента. |
| Action | `({ params, request }) => Response |
| useLoaderData | Хук для отримання результату Loader-а. |
| useActionData | Хук для отримання помилок або даних, які повернув Action. |
| useSubmit | "Міст" для програмного запуску Action (ідеально для React Hook Form). |
Цей підхід перетворює React Router з простого "перемикача сторінок" на потужний State Management інструмент для серверних даних.
Динамічні Маршрути та Параметри
До цього моменту ми працювали з фіксованими URL: /about, /contact. Але реальні додатки працюють з динамічними даними: користувачі (/user/123), товари (/products/iphone-15) або замовлення (/order/abc-999).
Просунуті Патерни
Ми вже вміємо будувати повноцінні додатки. Але що робити зі складними сценаріями? Авторизація, оптимізація повільних запитів, інтерактивність без переходу на нову сторінку.