Пошук уроків, статей та іншого контенту
Винесіть бізнес-правила з JSX у хуки, сервіси та модулі, зберігаючи UI простим і зручним для тестування.
Компонент React має відповідати передусім за відображення інтерфейсу та реакцію на дії користувача. Бізнес-логіка повинна бути винесена в окремі функції, хуки або модулі.
До бізнес-правил належать, наприклад:
розрахунок знижки;
визначення вартості доставки;
перевірка допустимості кількості товару;
об’єднання однакових товарів у кошику;
підготовка даних перед відправленням на сервер;
визначення доступності певної дії.
Якщо розміщувати такі правила безпосередньо в JSX, компонент швидко стає складним:
{items.reduce((sum, item) => sum + item.price * item.quantity, 0) >= 1500 &&
items.reduce((sum, item) => sum + item.price * item.quantity, 0) * 0.1}Такий код важко читати, повторно використовувати й тестувати. Натомість JSX має працювати з готовими значеннями:
<p>Знижка: {summary.discount} грн</p>Зручно розділяти код на три рівні.
Містять правила предметної області. Вони не повинні знати про React, JSX або стан компонента.
const summary = calculateOrder(items);Такі функції легко тестувати, оскільки для них достатньо передати вхідні дані й перевірити результат.
Керують станом і пов’язують бізнес-логіку з життєвим циклом React.
Хук може:
зберігати стан;
викликати сервісні функції;
надавати обробники подій;
мемоізувати обчислені значення.
Відображають дані та передають події в хук або сервіс.
Компонент не повинен вирішувати, як саме розраховується знижка. Його завдання — показати вже підготовлений результат.
Розглянемо такі правила:
знижка 10% надається, якщо сума товарів становить щонайменше 1500 грн;
доставка безкоштовна, якщо сума після знижки становить щонайменше 1000 грн;
в інших випадках доставка коштує 80 грн.
Створимо файл pricing.js:
export const DISCOUNT_LIMIT = 1500;
export const FREE_SHIPPING_LIMIT = 1000;
export const SHIPPING_COST = 80;
export function calculateOrder(items) {
const subtotal = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
const discount = subtotal >= DISCOUNT_LIMIT
? Math.round(subtotal * 0.1)
: 0;
const amountAfterDiscount = subtotal - discount;
const shipping = amountAfterDiscount >= FREE_SHIPPING_LIMIT
? 0
: SHIPPING_COST;
return {
subtotal,
discount,
shipping,
total: amountAfterDiscount + shipping,
};
}Функція calculateOrder не залежить від React. Її можна викликати з компонента, хука, серверного коду або тесту.
Важливо, що вона не змінює масив items, не читає стан компонента й не виконує побічних ефектів. Це чиста функція.
Створимо файл useCart.js:
import { useCallback, useMemo, useState } from "react";
import { calculateOrder } from "./pricing";
export function useCart(initialItems = []) {
const [items, setItems] = useState(initialItems);
const addItem = useCallback((product) => {
setItems((currentItems) => {
const existingItem = currentItems.find(
(item) => item.id === product.id
);
if (existingItem) {
return currentItems.map((item) =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
}
return [...currentItems, { ...product, quantity: 1 }];
});
}, []);
const removeItem = useCallback((productId) => {
setItems((currentItems) =>
currentItems
.map((item) =>
item.id === productId
? { ...item, quantity: item.quantity - 1 }
: item
)
.filter((item) => item.quantity > 0)
);
}, []);
const summary = useMemo(() => {
return calculateOrder(items);
}, [items]);
return {
items,
addItem,
removeItem,
summary,
};
}Хук відповідає за змінний стан кошика та надає компоненту готовий API:
items — поточні товари;
addItem — додавання товару;
removeItem — зменшення кількості;
summary — розраховані суми.
При цьому правила знижки та доставки залишаються в pricing.js.
Створимо файл Cart.jsx:
import { useCart } from "./useCart";
const products = [
{ id: 1, name: "Клавіатура", price: 900 },
{ id: 2, name: "Миша", price: 700 },
{ id: 3, name: "Навушники", price: 1200 },
];
const initialItems = [
{ id: 1, name: "Клавіатура", price: 900, quantity: 1 },
];
function formatPrice(value) {
return `${value} грн`;
}
export default function Cart() {
const { items, addItem, removeItem, summary } = useCart(initialItems);
return (
<main>
<h1>Кошик</h1>
<section>
<h2>Товари</h2>
{products.map((product) => (
<button
key={product.id}
type="button"
onClick={() => addItem(product)}
>
Додати {product.name} — {formatPrice(product.price)}
</button>
))}
</section>
<section>
<h2>Ваше замовлення</h2>
{items.length === 0 ? (
<p>Кошик порожній</p>
) : (
<ul>
{items.map((item) => (
<li key={item.id}>
{item.name}: {item.quantity} × {formatPrice(item.price)}
<button
type="button"
onClick={() => removeItem(item.id)}
>
Видалити одну одиницю
</button>
</li>
))}
</ul>
)}
<dl>
<div>
<dt>Сума товарів</dt>
<dd>{formatPrice(summary.subtotal)}</dd>
</div>
<div>
<dt>Знижка</dt>
<dd>{formatPrice(summary.discount)}</dd>
</div>
<div>
<dt>Доставка</dt>
<dd>
{summary.shipping === 0
? "Безкоштовно"
: formatPrice(summary.shipping)}
</dd>
</div>
<div>
<dt>Разом</dt>
<dd>{formatPrice(summary.total)}</dd>
</div>
</dl>
</section>
</main>
);
}Компонент не містить умов на кшталт:
if (subtotal >= 1500) {
// ...
}Він лише:
отримує дані та обробники з useCart;
відображає список товарів;
передає події натискання;
показує готовий summary.
Це робить JSX передбачуваним і значно спрощує його читання.
Виносьте правило в окрему функцію, якщо воно:
не потребує React;
працює лише з аргументами;
може використовуватися в різних місцях;
має окремий предметний зміст.
Наприклад:
export function canApplyDiscount(user, order) {
return user.isVerified && order.subtotal >= 1500;
}Використовуйте хук, якщо логіка пов’язана зі станом React:
const { items, addItem, summary } = useCart();Хук підходить для:
керування формою;
фільтрації, що залежить від стану;
асинхронного завантаження;
керування кошиком;
обробки послідовності дій користувача.
У компоненті допустимі прості UI-умови:
{items.length === 0 ? (
<p>Кошик порожній</p>
) : (
<CartItems items={items} />
)}Такі умови описують відображення, а не бізнес-правила.
Чисті функції можна тестувати окремо від React-компонентів.
Наприклад, файл pricing.test.js:
import { describe, expect, it } from "vitest";
import { calculateOrder } from "./pricing";
describe("calculateOrder", () => {
it("додає знижку для замовлення від 1500 грн", () => {
const items = [
{ id: 1, price: 1000, quantity: 1 },
{ id: 2, price: 500, quantity: 1 },
];
expect(calculateOrder(items)).toEqual({
subtotal: 1500,
discount: 150,
shipping: 80,
total: 1430,
});
});
it("робить доставку безкоштовною після знижки", () => {
const items = [
{ id: 1, price: 1200, quantity: 1 },
];
expect(calculateOrder(items)).toEqual({
subtotal: 1200,
discount: 0,
shipping: 0,
total: 1200,
});
});
it("рахує доставку для порожнього кошика", () => {
expect(calculateOrder([])).toEqual({
subtotal: 0,
discount: 0,
shipping: 80,
total: 80,
});
});
});Тест не потребує:
рендерингу компонента;
пошуку елементів у DOM;
симуляції натискань;
створення браузерного середовища.
Тестується лише бізнес-правило: для певного набору товарів функція має повернути правильний результат.
Поганий підхід — повторювати однакове обчислення в кількох місцях:
<p>
Разом:{" "}
{items.reduce((sum, item) => sum + item.price * item.quantity, 0) + 80}
</p>
<button
disabled={
items.reduce((sum, item) => sum + item.price * item.quantity, 0) < 500
}
>
Оформити замовлення
</button>У такому коді легко отримати суперечливі результати: текст показує одну суму, а кнопка використовує іншу логіку.
Краще один раз підготувати дані:
const { summary } = useCart();
return (
<>
<p>Разом: {summary.total} грн</p>
<button disabled={summary.total === 0}>
Оформити замовлення
</button>
</>
);Єдине джерело правди зменшує кількість помилок.
useMemo і useCallbackuseMemo у прикладі використовується для обчислення summary лише тоді, коли змінюється items:
const summary = useMemo(() => {
return calculateOrder(items);
}, [items]);Це також чітко показує залежність розрахунку від стану кошика.
useCallback стабілізує посилання на обробники:
const addItem = useCallback((product) => {
// ...
}, []);Це може бути корисно, якщо обробники передаються дочірнім компонентам, які оптимізують повторний рендеринг.
Втім, не потрібно автоматично обгортати кожну функцію в useMemo або useCallback. Спочатку відокремте логіку та зробіть код зрозумілим. Оптимізацію варто додавати, коли для неї є практична причина.
Не обов’язково одразу створювати багато файлів. Практичний процес може бути таким:
Знайдіть умови та обчислення в JSX.
Дайте їм зрозумілі назви.
Винесіть чисті розрахунки в окремі функції.
Винесіть роботу зі станом у кастомний хук.
Залиште в компоненті лише відображення та передачу подій.
Додайте тести для чистих функцій.
Наприклад, спочатку можна замінити складний вираз локально:
const subtotal = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);Після цього функцію можна перенести в pricing.js, коли стане зрозуміло, що вона є частиною бізнес-правил.
function handleCheckout() {
const subtotal = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
if (subtotal < 500) {
alert("Мінімальна сума замовлення — 500 грн");
return;
}
// Відправлення замовлення
}Якщо це правило важливе для застосунку, його краще винести в окрему функцію:
export function canCheckout(items) {
const subtotal = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
return subtotal >= 500;
}Компонент тоді лише викликає цю функцію та показує відповідний стан.
Не варто створювати функцію, яка одночасно розраховує суму й повертає готовий рядок:
function getTotalLabel(items) {
// Розрахунок, округлення та HTML-представлення змішані разом
}Краще повертати числові значення з бізнес-модуля:
const { total } = calculateOrder(items);А форматування залишати на рівні UI:
<span>{total} грн</span>Такий компонент складніше використовувати:
<OrderSummary
subtotal={subtotal}
discount={discount}
shipping={shipping}
total={total}
hasFreeShipping={hasFreeShipping}
canCheckout={canCheckout}
/>Якщо ці значення належать до одного результату, їх можна об’єднати:
<OrderSummary summary={summary} />Не кожна функція потребує кастомного хука. Якщо логіка не використовує стан або React API, краще залишити її чистою функцією.
export function calculateOrder(items) {
// Це не хук, оскільки React тут не потрібен
}Хук потрібен тоді, коли він справді керує станом або пов’язує кілька React-операцій.
JSX має описувати структуру інтерфейсу, а не реалізацію бізнес-правил.
Чисті розрахунки та перевірки виносьте в сервіси або звичайні модулі.
Логіку, що працює зі станом React, оформлюйте як кастомні хуки.
Компоненти мають отримувати готові дані та обробники.
Єдине обчислення краще використовувати в кількох місцях, ніж дублювати.
Чисті функції легко тестувати без рендерингу React.
Розділення відповідальності робить компоненти коротшими, зрозумілішими та зручнішими для тестування.