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/useStatefor state,useEffectfor requests/subscriptions,useMemo/useCallbackfor 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:
useTodosis fully "headless": loading/saving, filters, actions, memoization.TodosViewis pure UI: buttons, list, forms. Want a different design (Tailwind, MUI, shadcn)? Change onlyTodosView.
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.tsxTestability
- 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 readyPremium
A concise answer to help you respond confidently on this topic during an interview.