Published on

The use* Prefix Isn't a Naming Habit: Component and Hook Conventions Through React's Render-Time Contract

Authors
  • avatar
    Name
    Jack Qin
    Twitter

Hooks and components are the two cornerstones of the frontend. Once they get out of hand, a codebase rapidly turns into "everyone has their own way of doing things" — and the way it gets out of hand is usually not a glaring, obvious mistake but a handful of innocent-looking little habits that stack up into an unpredictable "shape."

What I want to do here is take three conventions most often dismissed as "style preferences" and trace them back to the hard mechanisms underneath: why calling a use* function inside an async function crashes at runtime, why the giant configurable component pays for flexibility on the wrong axis, and why accessibility is actually a testing contract rather than just a matter of user experience. Once you understand the mechanism, these conventions stop being "the team asks you to write it this way" and become "if you don't write it this way, it will break on you."

Hook ownership and data fetching: making the "shape" predictable

First, the basic rules. Their purpose is to make every hook's shape guessable at a glance:

  • Hooks always use the use* prefix.
  • Shared hooks go in src/hooks/.
  • Domain hooks go in src/features/<feature>/hooks/.
  • API-backed server state uses TanStack Query.
  • Push fetch and mutation logic down into hooks, keeping route files thin.

A feature hook usually wraps one of these patterns:

  • useQuery for reads
  • useMutation for writes
  • Invalidate or refetch the feature's query key after a write
  • Map the DTO into a UI-ready model before returning data

Naming should describe the returned behavior, e.g. useEmailSchedules, useUsersQuery, useWeatherMutations. Read-style hooks make the read intent obvious; mutation or mixed-workflow hooks describe the resource they manage. Within a feature, query-key naming and hook naming should line up — this alignment isn't for tidiness, it's so that "which mutation should invalidate which key" doesn't require jumping between files to confirm.

On data fetching, TanStack Query is the default server-state layer. Patterns already in use in the repo include: feature-scoped query keys provided by the feature's API layer; enabled for conditional fetching; passing signal when the query function supports it; invalidating the feature query key after a successful write.

The trap people fall into most: the use* prefix freezes you into React's render phase

This is a trap people hit repeatedly, and hitting it means a runtime crash — worth explaining at the mechanism level.

Symptom: some imperative helper (say signInWithMicrosoft()) needs OIDC discovery metadata, and you reach for AuthSession.useAutoDiscovery(authority) on autopilot, then crash at runtime: Invalid hook call. Hooks can only be called inside of the body of a function component.

Why it always crashes, rather than crashing sometimes: the key is understanding that the use* prefix isn't a naming habit. It's the marker of a contract, and the contract says "I use React's hook machinery internally (useState + useEffect), so I can only be called during React's render phase." React's hooks depend on an implicit, render-driven call-order state — a state that exists only while a component is rendering or another hook is executing. An imperative async function isn't in the render flow at all; that state doesn't exist; so React detects "a hook called outside a render context" and throws immediately. This isn't an edge case — it's the precondition of the hook machinery being violated, which makes it a deterministic crash.

Fix: most libraries that "use a hook" provide an imperative twin. For expo-auth-session that's AuthSession.fetchDiscoveryAsync(authority) — same result, returns a Promise, safe to await anywhere.

// Wrong — useAutoDiscovery is a React Hook, putting it in an async function always crashes
export async function signInWithMicrosoft(): Promise<{ idToken: string }> {
  const discovery = AuthSession.useAutoDiscovery(authority)
  // ...
}

// Correct — use the imperative twin
export async function signInWithMicrosoft(): Promise<{ idToken: string }> {
  const discovery = await AuthSession.fetchDiscoveryAsync(authority)
  // ...
}

Prevention: whenever you reach for any use* symbol in a third-party library, first check whether the library has an imperative variant (commonly named *Async, fetch*, or a method on a service singleton). One transferable dividing line: the use* form belongs to React's render phase; the imperative form belongs to event handlers, async functions, and effect bodies that need a single call. Seeing use* inside an async function is almost always a sign you've got the wrong pipeline.

Components: composition vs configuration, which axis you pay for flexibility on

Components get out of hand in traceable ways too. A component that should only serve one page's workflow gets moved into a shared directory too early; or, in the name of "flexibility," it becomes a giant configurable component stuffed with boolean toggles, and nobody can tell what it actually renders.

Rules:

  • Reusable primitives go in src/components/ui or other shared src/components/* areas.
  • Page/feature-specific UI lives near the feature or route that owns it.
  • Prefer composition + typed props over one big do-everything one-off configurable abstraction.
  • Build interactive UI with accessible roles, labels, and button semantics.

The real tradeoff here is "composition vs configuration." A giant component stuffed with showHeader, enableSort, mode, density looks flexible, but it pays for that flexibility on the worst possible axis: the number of boolean-toggle combinations grows exponentially with the number of toggles, and the vast majority of those combinations never occur in reality, yet all of them have to be implemented, tested, and maintained. Worse, a reader can't infer what it will render from the call site — all the branching logic is hidden inside the component. The composition pattern pays the same bill on a different axis: slots and children expose extension points, the flexibility shows up in "what the caller puts inside," and the call site itself is the spec for the rendered result.

Shared components (like DataTable, Pagination, ProvidersWrapper) usually: accept typed props; expose composition points like toolbar, headerActions, children; and don't embed feature-specific API logic.

Props conventions:

  • Define props with a TS interface or type alias near the component.
  • Prefer explicit props over a catch-all config object.
  • Use render slots or React nodes as extension points, not a pile of boolean toggles.
  • Carry domain data, typed, all the way to the component boundary.

For example, DataTable exposes typed generics and explicit props (columns, rowKey, toolbar, pagination); ProvidersWrapper narrows its prop surface down to just children — the narrower the surface, the less room for misuse.

Accessibility is a testing contract, not just user experience

Accessibility is usually filed under "nice for users" bonus points. But in this set of conventions it has a second identity: it's the testing interface.

The mechanism is this: a semantic interactive element carrying a role and an accessible name lets a test locate it with a stable selector like getByRole("button", { name: "..." }). A div onClick with no role and no name leaves the test stuck with brittle DOM traversal — feeling its way by hierarchy and index, and going red the moment the product changes structure. In other words, missing accessibility doesn't just hurt users, it forces tests to assert in a fragile way. Adding the role/label hands both the user and the test a stable handle.

So accessibility is expected in both component code and tests:

  • Use semantic interactive elements like button.
  • Add attributes like aria-label, aria-current where needed.
  • Align test selectors with accessible names and roles.

For example, Pagination uses nav aria-label="Pagination", button labels, and aria-current; the login-page tests query the UI directly by role and label.

Counter-examples

// Counter-example 1: fetching directly in a page when the existing feature-hook pattern fits perfectly
function ReportPage() {
  const [data, setData] = useState()
  useEffect(() => {
    fetch('/api/v1/reports').then(/* ... */)
  }, []) // ❌
}
// Counter-example 2: putting a feature-specific hook in src/hooks/
// src/hooks/useWeeklyReportQueries.ts  ❌ only weekly-reports uses it
// should be src/features/weekly-reports/hooks/useWeeklyReportQueries.ts
// Counter-example 3: building a giant configurable component for a single page workflow
<MegaPanel
  showHeader showFooter showToolbar enableSort enableFilter
  variant="weekly" mode="edit" density="compact" /* ...and 12 more booleans */
/> // ❌ nobody can tell what it will render

// Correct: composition + slots
<Panel toolbar={<WeeklyToolbar />} headerActions={<ExportButton />}>
  <WeeklyReportTable />
</Panel>
// Counter-example 4: an interactive element with no accessible name, leaving tests with brittle DOM traversal
<div onClick={goNext}></div> // ❌

// Correct
<button aria-label="Next page" onClick={goNext}></button> // ✅

Other things to avoid: don't fetch directly in a page component when the existing feature-hook pattern fits; don't put feature-specific hooks in src/hooks/; don't bypass the feature query key for ad-hoc cache invalidation; don't mirror React Query data into a second piece of state unless the UI is editing/staging it; don't return a loosely-typed any payload when the API and feature types already exist; don't move feature-specific components into shared directories prematurely; don't ship a dialog, button, or form input that lacks the accessible name the tests expect.

Putting it into practice

  1. Keep route files thin: push fetch and mutation logic into hooks; the page only orchestrates and renders.
  2. Look for the imperative twin before grabbing a third-party use*: *Async, fetch* are often right next to it — don't drag a hook into an async function.
  3. Hook ownership follows reuse scope: only shared-across-many goes into src/hooks/; otherwise it stays in the feature directory.
  4. Prefer composition over configuration in components: use slots/nodes for extension points, not a sea of boolean toggles.
  5. Treat accessibility as a testing contract: adding role/label isn't only for users, it's also so tests can assert stably with semantic selectors.

The transferable layer

Take these three conventions to the bottom and they share one insight: the "shape" of a symbol or a structure determines where it can safely be placed and how it can be consumed. The shape of use* confines it to the render phase; the shape of the giant config buries complexity inside itself and amplifies it exponentially; the shape of a role-less element forces tests back into brittle traversal. Before writing any hook or component, instead of asking "will this work," ask: is its shape honest about how it will be used? Get the shape right and the convention is redundant; get the shape wrong and no amount of comments will hold it.