Suggest an editImprove this articleRefine the answer for “How do you separate business logic and UI with custom hooks?”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**A hook is a "headless" controller**, and the component is the "view": the hook encapsulates state, effects, computations, validation, and API calls, and returns **data + actions**, while the component only renders what it gets ready-made from the hook. **Key point:** a hook returns a minimal and stable API `{ state, derived, actions }` with no JSX or DOM manipulation, while the component contains no business logic, only rendering.Shown above the full answer for quick recall.Answer (EN)Image## Principle **A hook = a "headless" controller**, a component = a "view". - The hook knows nothing about markup/styles. - The hook encapsulates state, effects, computations, validation, API calls, and returns **data + actions**. - The component only handles rendering, getting everything ready-made from the hook. --- ## Mini-checklist for the hook (business logic) - Return a **minimal and stable API**: `{ state, derived, actions }`. - Inside: `useReducer/useState` for state, `useEffect` for requests/subscriptions, `useMemo`/`useCallback` for reference stability. - No JSX and no DOM manipulation. - Do not pull in CSS/UI concerns (classes, aria attributes) - at most prepare data for them (for example, `isOpen`, `getItemProps()`, etc.). ## Mini-checklist for the component (UI) - No business logic/loading/validation inside - only rendering. - Gets everything ready-made from the hook. - Easy to reuse and restyle. --- ### Pattern 1: "Headless hook" + any UI #### The hook (todo list logic) ```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(() => { // business logic for loading (can be replaced with a 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, // "raw" state, if needed todos: visible, // derived data for the UI actions: { add, toggle, setFilter } // stabilized actions }; } ``` #### Any UI component built on the hook ```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"}>All</button> <button onClick={() => actions.setFilter("todo")} aria-pressed={state.filter==="todo"}>Active</button> <button onClick={() => actions.setFilter("done")} aria-pressed={state.filter==="done"}>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="New task" /> <button type="submit">Add</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> ); } ``` **What we got:** - `useTodos` is fully "headless": loading/saving, filters, actions, memoization. - `TodosView` is pure UI: buttons, list, forms. Want a different design (Tailwind, MUI, shadcn)? Change only `TodosView`. --- ### Pattern 2: "Controller/View" (as in Headless UI) A hook can return **prop getters**, so the UI can easily wire up to the logic. ```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 }; } ``` The UI stays completely free: ```javascript const d = useDisclosure(); <button {...d.getButtonProps()}>Open</button> <section {...d.getPanelProps()}>Content</section> ``` --- ### Pattern 3: Context + `useReducer` (mini-store) The logic goes into a hook/reducer; UI components subscribe selectively. ```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; } ``` Any views: ```javascript function CartTotal() { const { total } = useCart(); // only derived data return <b>{total} $</b>; } ``` --- ### Project structure ```javascript src/ hooks/ useTodos.ts useDisclosure.ts useDebouncedValue.ts stores/ cart.store.tsx components/ TodosView.tsx Cart/ CartTotal.tsx CartList.tsx ``` --- ### Testability - **The hook** is tested separately (`renderHook`) - without UI. - **The component** is tested as a "dumb" render - simple snapshots/role checks. --- ### Anti-patterns (to avoid mixing layers) - In the hook - DOM manipulation, classes, animation timings (leave those to the UI, or make prop getters). - In the UI - networking/validation/business rules. - Unstable references in the hook's API (wrap them in `useCallback`/`useMemo`). - Returning extra things - keep the API minimal. --- ### Quick "Headless hook" template ```javascript export function useFeature(params: Params) { // state // effects (fetch/cache) // derived (useMemo) // actions (useCallback) return { state, derived, actions }; } ```For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.