Як розділяти бізнес-логіку і UI за допомогою кастомних хуків?
Принцип
Хук = "headless" контролер, компонент = "view".
- Хук нічого не знає про розмітку/стилі.
- Хук інкапсулює стан, ефекти, обчислення, валідацію, звернення до API і віддає дані + дії.
- Компонент займається лише відображенням, отримуючи все готове з хука.
Міні-чекліст для хука (бізнес-логіка)
- Повертай мінімальний і стабільний API:
{ state, derived, actions }. - Всередині:
useReducer/useStateдля стану,useEffectдля запитів/підписок,useMemo/useCallbackдля стабільності посилань. - Ніякого JSX і DOM-маніпуляцій.
- Не тягнути CSS/UI-турботи (класи, aria-атрибути) - максимум підготувати дані для них (наприклад,
isOpen,getItemProps()тощо).
Міні-чекліст для компонента (UI)
- Жодної бізнес-логіки/завантаження/валідації всередині - тільки рендер.
- Отримує з хука все готове.
- Легко переюзається і переоформлюється.
Патерн 1: "Headless hook" + будь-який UI
Хук (логіка списку задач)
javascript
// useTodos.ts
import { useEffect, useMemo, useReducer, useCallback } from "react";
type Todo = { id: string; title: string; done: boolean };
type State = { items: Todo[]; filter: "all"|"done"|"todo" };
type Action =
| { type: "load"; payload: Todo[] }
| { type: "add"; title: string }
| { type: "toggle"; id: string }
| { type: "setFilter"; filter: State["filter"] };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "load": return { ...state, items: action.payload };
case "add": return { ...state, items: [...state.items, { id: crypto.randomUUID(), title: action.title, done: false }] };
case "toggle": return { ...state, items: state.items.map(t => t.id === action.id ? { ...t, done: !t.done } : t) };
case "setFilter": return { ...state, filter: action.filter };
default: return state;
}
}
export function useTodos() {
const [state, dispatch] = useReducer(reducer, { items: [], filter: "all" });
useEffect(() => {
// бізнес-логіка завантаження (можна замінити fetch'ем)
const saved = localStorage.getItem("todos");
if (saved) dispatch({ type: "load", payload: JSON.parse(saved) });
}, []);
useEffect(() => {
localStorage.setItem("todos", JSON.stringify(state.items));
}, [state.items]);
const visible = useMemo(() => {
switch (state.filter) {
case "done": return state.items.filter(t => t.done);
case "todo": return state.items.filter(t => !t.done);
default: return state.items;
}
}, [state.items, state.filter]);
const add = useCallback((title: string) => dispatch({ type: "add", title }), []);
const toggle = useCallback((id: string) => dispatch({ type: "toggle", id }), []);
const setFilter = useCallback((filter: State["filter"]) => dispatch({ type: "setFilter", filter }), []);
return {
state, // "сирий" стейт, якщо потрібен
todos: visible, // похідні дані для UI
actions: { add, toggle, setFilter } // стабілізовані дії
};
}Будь-який UI-компонент на базі хука
javascript
// TodosView.tsx
import { useTodos } from "./useTodos";
export function TodosView() {
const { todos, state, actions } = useTodos();
return (
<div>
<div>
<button onClick={() => actions.setFilter("all")} aria-pressed={state.filter==="all"}>Усі</button>
<button onClick={() => actions.setFilter("todo")} aria-pressed={state.filter==="todo"}>Активні</button>
<button onClick={() => actions.setFilter("done")} aria-pressed={state.filter==="done"}>Готові</button>
</div>
<form onSubmit={(e) => {
e.preventDefault();
const input = (e.currentTarget.elements.namedItem("title") as HTMLInputElement);
if (input.value.trim()) actions.add(input.value.trim());
input.value = "";
}}>
<input name="title" placeholder="Нова задача" />
<button type="submit">Додати</button>
</form>
<ul>
{todos.map(t => (
<li key={t.id}>
<label>
<input type="checkbox" checked={t.done} onChange={() => actions.toggle(t.id)} />
{t.title}
</label>
</li>
))}
</ul>
</div>
);
}Що вийшло:
useTodosповністю "headless": завантаження/збереження, фільтри, дії, мемоізації.TodosView- чистий UI: кнопки, список, форми. Хочеш інший дизайн (Tailwind, MUI, shadcn) - міняєш тількиTodosView.
Патерн 2: "Controller/View" (як у Headless UI)
Хук може повертати props-геттери, щоб UI легко зв'язувався з логікою.
javascript
// useDisclosure.ts
export function useDisclosure(initial = false) {
const [open, setOpen] = useState(initial);
const openIt = useCallback(() => setOpen(true), []);
const close = useCallback(() => setOpen(false), []);
const toggle = useCallback(() => setOpen(o => !o), []);
const getButtonProps = () => ({
"aria-expanded": open,
onClick: toggle
});
const getPanelProps = () => ({
role: "region",
hidden: !open
});
return { open, openIt, close, toggle, getButtonProps, getPanelProps };
}UI залишається будь-яким:
javascript
const d = useDisclosure();
<button {...d.getButtonProps()}>Відкрити</button>
<section {...d.getPanelProps()}>Контент</section>Патерн 3: Контекст + useReducer (mini-store)
Логіку - в хук/редьюсер; UI-компоненти підписуються вибірково.
javascript
// cart.store.tsx
const CartContext = createContext<ReturnType<typeof useCartStore> | null>(null);
function useCartStore() {
const [state, dispatch] = useReducer(reducer, initial);
const total = useMemo(() => state.items.reduce((s,i)=>s+i.price*i.qty,0), [state.items]);
const add = useCallback((p: Product) => dispatch({ type: "add", p }), []);
return { state, total, add };
}
export function CartProvider({ children }: { children: React.ReactNode }) {
const api = useCartStore();
return <CartContext.Provider value={api}>{children}</CartContext.Provider>;
}
export function useCart() {
const ctx = useContext(CartContext);
if (!ctx) throw new Error("useCart must be used within CartProvider");
return ctx;
}Будь-які представлення:
javascript
function CartTotal() {
const { total } = useCart(); // тільки derived-дані
return <b>{total} $</b>;
}Структура проєкту
javascript
src/
hooks/
useTodos.ts
useDisclosure.ts
useDebouncedValue.ts
stores/
cart.store.tsx
components/
TodosView.tsx
Cart/
CartTotal.tsx
CartList.tsxТестованість
- Хук тестується окремо (
renderHook) - без UI. - Компонент тестується як "тупий" рендер - прості снапшоти/role-перевірки.
Антипатерни (щоб не змішувати шари)
- У хуку - DOM-маніпуляції, класи, таймінги анімацій (лиши в UI або зроби проп-геттери).
- У UI - мережа/валідація/бізнес-правила.
- Нестабільні посилання в API хука (загортай у
useCallback/useMemo). - Повертати зайве - тримай API мінімальним.
Швидкий шаблон "Headless hook"
javascript
export function useFeature(params: Params) {
// state
// effects (fetch/cache)
// derived (useMemo)
// actions (useCallback)
return { state, derived, actions };
}Коротка відповідь
Для співбесідиPremium
Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.