How I write React

Jovanus Hartono

The React habits I've kept after a few years of writing and reviewing it.

Components

  • Write components as arrow functions with named exports. A named export keeps the same name in every file that imports it, so it's easy to search for and rename. The only default exports left are the ones Next.js needs for pages and layouts.
  • Destructure props in the parameters, and name their interface after the component: BookingModal takes BookingModalProps. Use interface for object shapes.
  • Name component files in kebab-case, like booking-modal.tsx, and hooks in camelCase, like useUserProfile.ts.
interface BookingModalProps {
  isOpen: boolean;
  onClose: () => void;
}

export const BookingModal = ({ isOpen, onClose }: BookingModalProps) => (
  <dialog open={isOpen}>
    <button onClick={onClose} type="button">
      Close
    </button>
  </dialog>
);

Structure

  • Put a component in ui/ only if it knows nothing about the product. Once it knows what a user or an invoice is, it belongs in components/<feature>/.
  • Return early for loading, error and empty states, so the main render reads top to bottom.
  • Use {isOpen && <Dialog />} to show and hide. Save the ternary for a real else branch, and when a branch grows past a few lines of JSX, give it its own component. In a long ternary it's easy to lose track of which branch you're reading.
  • Compound components, like <Tabs> with <Tabs.Item>, read better than one component with a long config prop.

State

  • Start with local state. When two components need it, lift it to their nearest shared parent. After that, use a provider the app already has. Zustand comes last, and most of the time I don't need it.
  • Before building a new mechanism, check whether the app already has one.
  • Keep form inputs controlled, with react-hook-form and Zod.
  • Memoize at boundaries: a list item that re-renders because its parent does, or a sort over a big array. Cheap renders don't need it.
  • Move complex logic into a custom hook. One hook per API endpoint, with the URL written inside it.

Markup

  • Pick the element for what it does: <button> for actions, <a href> for navigation. A <div> with an onClick loses the focus, keyboard support and role the right element gives you for free.
  • Reach for native controls first: <dialog>, <details>, real input types. Use ARIA roles only when no element fits.
  • One <main> per page, headings in order, a <label> for every input. Tests can then find things with getByRole and need no test IDs.

Styling and naming

  • Tailwind first, with cn() for conditional classes. A stylesheet only when Tailwind can't do the job, kept in its own folder.
  • Import lucide icons by their Icon names, like ChevronRightIcon, without renaming them, and size them with a class.
  • Call handlers handleClick inside a component and onClick as a prop. Start booleans with is or has.
  • Name API types after the verb, the thing and the direction: GetUserResponse, CreateUserPayload.