Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Як розділяти бізнес-логіку і UI за допомогою кастомних хуків?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Хук - це "headless" контролер**, а компонент - "view": хук інкапсулює стан, ефекти, обчислення, валідацію, звернення до API і віддає **дані + дії**, а компонент лише відображає те, що отримав готовим з хука. **Ключове:** хук повертає мінімальний і стабільний API `{ state, derived, actions }` і не містить JSX чи DOM-маніпуляцій, а компонент не містить бізнес-логіки, лише рендер.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення## Принцип **Хук = "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 }; } ```Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.