Skip to main content

How do you separate business logic and UI with custom hooks?

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 }; }

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.