Command Palette

Search for a command to run...

Hectal
PHASE 22Advanced ~29 min· topic 3 of 7

Topic 22.3

Separating Logic from UI

In one line

Put what the component does (state, data loading, rules) in a custom hook or a plain function, and how it looks in presentational components that only take props; headless components push this furthest by shipping behaviour and accessibility with no markup at all, and both halves become easy to test and reuse.

Think of it like this

A TV and its remote. The electronics inside decide what a button press does (change channel, raise volume); the plastic case and buttons decide how it looks and feels. A company can sell the same electronics in three different cases, and a repair person can test the circuit board without the case. In React, the circuit board is your logic (state, effects, rules) and the case is your UI (JSX and CSS). Keep them in separate pieces and you can swap, reuse and test each one alone.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Logic
What the component does: state, data loading, rules, calculations, keyboard behaviour.
Presentational component
A component that only renders what it receives in props and holds no data-loading logic.
Container component
An older pattern: a component that loads data and passes it to a presentational one. Mostly replaced by custom hooks.
Headless component
A component or hook that gives behaviour and accessibility but no styles or fixed markup.
Prop getter
A function such as getTabProps(i) that returns all the props an element needs, for you to spread onto it.
Pure function
A function whose result depends only on its arguments and that changes nothing outside itself.
Roving tabindex
Only the active item in a group has tabIndex={0}, the rest -1, so Tab enters the group once and arrow keys move inside it.

Step by step

01Before: one component does everything

Loading, the filtering rule, the input and the list are tangled together. To show the same products as chips somewhere else, you would copy all of it. To test the filtering rule, you need a fake server and a full render.

ProductSearch.jsx (before)whole filejsx
function ProductSearch() {
  const [query, setQuery] = useState("");
  const [products, setProducts] = useState([]);
  const [status, setStatus] = useState("loading");
  useEffect(() => {
    let ignore = false;
    fetchProducts().then((list) => { if (!ignore) { setProducts(list); setStatus("ready"); } });
    return () => { ignore = true; };
  }, []);
  const q = query.trim().toLowerCase();
  const results = products.filter((p) => p.name.toLowerCase().includes(q));
  return (
    <section>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      {status === "loading" ? <p>Loading...</p> : <ul>{results.map((p) => <li key={p.id}>{p.name}</li>)}</ul>}
    </section>
  );
}

02Pull the pure rule out first

The filtering rule doesn't need React. As a plain function it can be tested in a millisecond and reused on the server.

features/products/filterProducts.jswhole filejavascript
export function filterProducts(products, query) {
  const q = query.trim().toLowerCase();
  if (!q) return products;
  return products.filter((p) => p.name.toLowerCase().includes(q));
}

03Move state and effects into a hook

The hook returns plain data and functions, never JSX. Its name says what it does (useProductSearch), following Topic 10.6.

features/products/useProductSearch.jswhole filejavascript
export function useProductSearch({ initialQuery = "" } = {}) {
  const [query, setQuery] = useState(initialQuery);
  const [products, setProducts] = useState([]);
  const [status, setStatus] = useState("loading");
  useEffect(() => { /* load once, ignore stale results */ }, []);
  return { query, setQuery, status, results: filterProducts(products, query) };
}

04Let the UI only take props

ProductList renders whatever it's given. A screen connects the two in a couple of lines. A second screen can use the same hook with a completely different look.

Let the UI only take propsdiagram
Rendering diagram…

05Go headless when the behaviour is the hard part

For widgets with tricky accessibility (tabs, menus, comboboxes, date pickers), the hard part is the behaviour: roles, keyboard keys, focus. A headless hook writes that once and returns prop getters; every skin spreads them onto its own elements.

using a headless hookwhole filejsx
const tabs = useTabs({ count: 3 });
<div {...tabs.tabListProps}>
  <button {...tabs.getTabProps(0)}>Overview</button>
  <button {...tabs.getTabProps(1)}>Specs</button>
</div>
<p {...tabs.getPanelProps(0)}>...</p>

Try it yourself

  1. 1

    A third look

    In the first example, write a ProductCount({ results, status }) component that shows 3 products and a CountBadge screen that uses useProductSearch({ initialQuery: "tea" }). Predict the number before you run it.

  2. 2

    Add Home and End keys

    In useTabs, make Home select the first tab and End the last. You change the hook once; predict whether you need to touch either skin.

  3. 3

    Make a test fail on purpose

    In the testing example, change filterProducts to skip .toLowerCase() on the product name. Predict which test fails and what the FAIL line says, then run it.

Code & diagrams

After: one hook, two looks New tab

Each screen calls the hook, so each has its own state (hooks share logic, not state). Type in the search box: the chips below don't change. "ch" matches Chai masala, Cheddar and Chocolate.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

CheeseShelf shows 3 items
A headless useTabs hook with prop getters, two skins New tab

The hook knows nothing about buttons, pills or colours. It hands out ids, roles, `aria-*`, a roving `tabIndex`, a ref and keyboard handlers; each skin spreads them onto its own elements. Click a tab, then use the arrow keys.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Focused tab: Specs | aria-selected: true
Visible panel: Size, weight and battery.
Pill skin starts on: Reviews
The testing pay-off: logic and UI tested apart New tab

Five tests, zero mocks. Every screen state is reached by passing props, which is also how Storybook stories work. Break the rule (remove `.trim()`) and watch the first test fail.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

PASS matches ignoring case and spaces
PASS empty query returns everything
PASS loading state
PASS empty state
PASS list state
Testing the hook itself with renderHook (Vitest, see Topic 17.6)jsx
// useProductSearch.test.jsx
import { renderHook, act, waitFor } from "@testing-library/react";
import { test, expect, vi } from "vitest";
import { useProductSearch } from "./useProductSearch";
import * as api from "./api";

test("filters loaded products by the query", async () => {
  vi.spyOn(api, "fetchProducts").mockResolvedValue([
    { id: 1, name: "Green tea" }, { id: 2, name: "Cheddar" },
  ]);
  const { result } = renderHook(() => useProductSearch());
  await waitFor(() => expect(result.current.status).toBe("ready"));
  act(() => result.current.setQuery("ched"));
  expect(result.current.results.map((p) => p.id)).toEqual([2]);
});

// $ npx vitest run useProductSearch
//  ✓ src/features/products/useProductSearch.test.jsx (1 test)
//
//  Test Files  1 passed (1)
//       Tests  1 passed (1)

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Expecting a hook to share state

In the first example, type tea in the search box and expect the chips (which also use useProductSearch) to follow.

terminal
$ Preview
── what you'll see ──
The list shows Green tea. The chips still show Chai masala, Cheddar and Chocolate.

Break #2

A prop getter whose props get overwritten

In a skin, write <button {...tabs.getTabProps(i)} onKeyDown={logKey}> to add your own key logging.

terminal
$ Preview
── what you'll see ──
Arrow keys stop moving between tabs; the log works.

Myth vs fact

Myth

Every component should be split into a container and a presentational part.

Fact

That was a pre-hooks habit. Split when the logic is reused, complex or worth testing alone. A small component with one piece of state is clearest written in one place.

Myth

Headless means "no HTML at all".

Fact

Headless means no imposed look. Headless hooks return props for your elements; headless components (Radix primitives, React Aria components) may render unstyled elements. Either way, you own the styling.

Myth

A custom hook makes two components share data.

Fact

Each call gets its own state. To share data, lift state up, use context, or use a store.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Prop getters should accept user props and merge them (getTabProps(i, userProps)), composing event handlers and refs (a merged ref callback) and joining className. Otherwise every consumer has to know which props are "reserved".

  • ▸

    Returning a new getTabProps function on every render is fine for normal elements, but if consumers pass it to memoised children, stabilise it with useCallback (or rely on React Compiler, Topic 16.5).

  • ▸

    The logic/UI split is also what makes React Server Components practical (Phase 20): presentational components without hooks or browser APIs can render on the server, while the interactive hook-driven parts are marked "use client".

  • ▸

    State machines (XState, or a reducer with explicit states, Topic 15.5) are a natural home for complex widget logic: the machine is the pure, testable "logic", and the component only renders the current state and sends events.

Remember this

  1. 1

    A component that does everything (fetches data, tracks loading, filters, handles the keyboard and renders markup) is hard to read, hard to reuse with a different look, and hard to test: every test needs a fake server and a full render just to check a filtering rule. The fix is not a new React feature; it is a split along the line between "what" and "how it looks".

  2. 2

    Logic goes into a custom hook (Topic 10.1) or, even better when no React state is involved, into a plain function. A hook such as useProductSearch() owns the state and effects and returns plain values and functions: { query, setQuery, results, status }. The pure part (the filtering rule) can live in filterProducts(products, query), a function with no React at all, which is the easiest code in the world to test.

  3. 3

    UI goes into presentational components: components that receive everything through props and only render. Given status="loading" they show a spinner; given results they show a list. They have no idea where data comes from. That means one hook can feed many looks (a list, a grid, chips), and one look can be fed by many sources (live data, a test, a Storybook story).

  4. 4

    History note: before hooks, React developers split container components (fetch data, hold state) from presentational components (render). Dan Abramov, who popularised the pattern in 2015, later wrote that he no longer recommends splitting this way by habit, because hooks let you extract the logic without an extra component layer. Today the split is: hooks for logic, components for UI, and a thin component that connects them only where it helps.

  5. 5

    A headless component (or headless hook) is the extreme version: it provides behaviour, state and accessibility (ARIA roles, keyboard handling, focus management) but renders no styled markup, so you supply all of the look. A common API shape is prop getters: functions such as getTabProps(i) that return every prop an element needs (role, aria-selected, tabIndex, onKeyDown, ref), which you spread onto your own element. Downshift, TanStack Table, React Aria hooks and Radix's unstyled primitives all work this way (libraries compared in Topic 11.5).

  6. 6

    Testing gets easier at every level. Pure functions are tested with plain inputs and outputs. Hooks are tested with Testing Library's renderHook (Topic 17.6). Presentational components are tested by passing props for each state (loading, empty, error, full) with no network mocks. Only a few integration tests need the whole thing wired together. Keep the split proportional, though: a ten-line component with one useState needs no hook.

Explain it without notes

01

Why separate logic from UI in React? What do you gain?

02

What is a headless component, and why do design systems use them?

03

How would you test a component whose logic lives in a custom hook?

04

When is splitting logic from UI not worth it?

Practice

01

Split this component: function Clock() { const [now, setNow] = useState(new Date()); useEffect(() => { const t = setInterval(() => setNow(new Date()), 1000); return () => clearInterval(t); }, []); return <p className="clock">{now.toLocaleTimeString()}</p>; }

02

Write a headless useDisclosure({ defaultOpen }) hook that returns open, toggle, getButtonProps() and getPanelProps() with the right ARIA.

Trade-offs

  • ↔

    Splitting adds files and indirection; the benefit appears only when logic is reused, complex or tested. Split on the second use or the first test, not by default.

  • ↔

    Headless libraries give great accessibility but you write all the styling and markup; styled libraries are faster to start but harder to bend to your design (Topic 11.5).

Done when you can

  • I can move state and effects from a component into a custom hook that returns plain values.

  • I can pull pure rules into plain functions and test them without React.

  • I can write presentational components that render every state from props.

  • I can build a headless hook that returns prop getters with ARIA and keyboard support.

  • I can explain why a custom hook shares logic but not state.