Skip to main content

Як розділяти бізнес-логіку і 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

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.