How I write React
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:
BookingModaltakesBookingModalProps. Useinterfacefor object shapes. - Name component files in kebab-case, like
booking-modal.tsx, and hooks in camelCase, likeuseUserProfile.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 incomponents/<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 anonClickloses 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 withgetByRoleand 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
Iconnames, likeChevronRightIcon, without renaming them, and size them with a class. - Call handlers
handleClickinside a component andonClickas a prop. Start booleans withisorhas. - Name API types after the verb, the thing and the direction:
GetUserResponse,CreateUserPayload.