Conventions for React Apps

These are conventions I use in React apps so a folder looks like the last one I opened, and they are scoped to my own codebases rather than meant as a general style guide. For JavaScript hygiene I still point people at Clean Code JavaScript.

JavaScript

I write functional, declarative TypeScript with named functions, small modules, and types inferred at the edges.

Names should say what they are, and booleans get a verb: isDisabled, isLoading, hasError, shouldRetry.

I split large components into smaller ones with few props, because composition is easier to change than a 400-line file with a grab-bag of flags.

Related files live next to the component that owns them. pages/dashboard keeps dashboard-only hooks, types, and pieces. Something becomes shared when a second route needs it, not when I imagine it might.

Folders are lowercase and dashed: components/auth-wizard. Files carry a suffix so I can scan a directory:

  • .config.ts
  • .test.ts
  • .context.tsx
  • .type.ts
  • .service.ts
  • .lib.ts
  • .page.tsx (name matches the route, e.g. dashboard.page.tsx)

Example import: import { NftItem } from './nft-item'

└── nft-item
    β”œβ”€β”€ index.ts   ( exports )
    β”œβ”€β”€ nft-item.tsx
    β”œβ”€β”€ nft-item-header.tsx
    β”œβ”€β”€ nft-item-footer.tsx
    β”œβ”€β”€ nft-item-main.tsx
    β”œβ”€β”€ use-nft-item.ts
    β”œβ”€β”€ nft-item.type.ts
    β”œβ”€β”€ nft-item.context.tsx
    └── nft-item.test.tsx

I do not abstract on the first copy. Duplicate once, extract when the third use makes the shape obvious. Kent C. Dodds wrote this up as AHA.

I use named exports only, since default exports hide the name from the IDE and from grep.

I let TypeScript infer return types unless the public surface of a package needs an explicit contract.

Functions that take a pile of options use RORO: receive an object, return an object.

// services/account/account.service.ts
export async function getAccounts({ account, limit = 15, offset = 0 }: GetAccountsParams) {
  // Implementation...
  return { accounts: [] }
}
 
// types/services.type.ts
export interface ServiceParams {
  limit?: number
  offset?: number
}
 
// services/account/account.type.ts
export interface GetAccountsParams extends ServiceParams {
  account?: string
}

React

I declare components with function. Hook lint rules are less fussy than with const Component = () =>. Inner helpers inside the file are const so they do not look like components.

Inside a file, the exported component comes first, then inner components, then types, then copy. Loading and error states return early, and JSX uses ternaries instead of && so a 0 does not render.

// imports
 
export function MyReactComponent({ myParam }: MyReactComponentParams) {
  const { data, isLoading, error } = useAsyncFn(api.loadData)
 
  const myMethod = () => console.log(myParam)
 
  useEffect(() => {
    console.log('component mounted')
  })
 
  if (isLoading) return <p>loading...</p>
 
  if (error) return <p>error loading data.</p>
 
  return (
    <div className="bg-slate-100 md:flex">
      <p>{content.headline}</p>
      {data ? <h1>{data.title()}</h1> : null}
      {data?.items?.map(Item)}
      <button onClick={myMethod}>{content.button}</button>
    </div>
  )
}
 
function Item({ description }: { description: string }) {
  return <li className="text-blue md:flex">{description}</li>
}
 
export interface MyReactComponentParams {
  myParam: boolean
}
 
const content = {
  headline: 'A new world awaits. Be the first to discover it.',
  button: "Let's go!",
}

Copy in a content object keeps the component readable and is the same shape I need if strings later move to i18n.

Errors

Services and libs throw. The React component (or an async hook) catches and shows a message. Presentational code should not be a nest of try/catch.

For unknown catch values I use the small helpers Kent documented in Get a catch block error message with TypeScript:

export type ErrorWithMessage = {
  message: string
}
 
export function isErrorWithMessage(error: unknown): error is ErrorWithMessage {
  return (
    typeof error === 'object' &&
    error !== null &&
    'message' in error &&
    typeof (error as Record<string, unknown>).message === 'string'
  )
}
 
export function toErrorWithMessage(maybeError: unknown): ErrorWithMessage {
  if (isErrorWithMessage(maybeError)) return maybeError
 
  try {
    return new Error(JSON.stringify(maybeError))
  } catch {
    return new Error(String(maybeError))
  }
}
 
export function getErrorMessage(error: unknown) {
  return toErrorWithMessage(error).message
}

Folders

.
β”œβ”€β”€ index.html
β”œβ”€β”€ package.json
β”œβ”€β”€ postcss.config.ts
β”œβ”€β”€ public
β”‚   β”œβ”€β”€ favicon.png
β”‚   β”œβ”€β”€ images
β”‚   β”‚   └── icons
β”‚   β”œβ”€β”€ index.tsx
β”‚   β”œβ”€β”€ manifest.webmanifest
β”‚   └── styles
β”‚       β”œβ”€β”€ global.css
β”‚       └── tailwind.css
β”œβ”€β”€ src
β”‚   β”œβ”€β”€ app.tsx
β”‚   β”œβ”€β”€ config
β”‚   β”‚   β”œβ”€β”€ chain
β”‚   β”‚   β”œβ”€β”€ client
β”‚   β”‚   └── site.ts
β”‚   β”œβ”€β”€ icons
β”‚   β”‚   β”œβ”€β”€ index.ts
β”‚   β”‚   └── lucide.icon.tsx
β”‚   β”œβ”€β”€ lib
β”‚   β”‚   β”œβ”€β”€ encoding
β”‚   β”‚   └── error
β”‚   β”œβ”€β”€ hooks
β”‚   β”‚   β”œβ”€β”€ use-hook.ts
β”‚   β”‚   └── use-other-hook.ts
β”‚   β”œβ”€β”€ context
β”‚   β”‚   β”œβ”€β”€ global.context.ts
β”‚   β”‚   └── other-global.context.ts
β”‚   β”œβ”€β”€ layouts
β”‚   β”‚   β”œβ”€β”€ root.layout.ts
β”‚   β”‚   └── sidebar.layout.ts
β”‚   β”œβ”€β”€ main.tsx
β”‚   β”œβ”€β”€ pages
β”‚   β”‚   β”œβ”€β”€ dashboard
β”‚   β”‚   β”‚   β”œβ”€β”€ index.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ dashboard.page.tsx
β”‚   β”‚   β”‚   β”œβ”€β”€ dashboard.type.tsx
β”‚   β”‚   β”‚   β”œβ”€β”€ dashboard-main.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ dashboard-footer.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ dashboard-header.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ use-dashboard.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ dashboard.context.ts
β”‚   β”‚   β”‚   └── dashboard.lib.ts
β”‚   β”‚   └── wallet
β”‚   β”œβ”€β”€ services
β”‚   β”‚   β”œβ”€β”€ chain
β”‚   β”‚   β”œβ”€β”€ pinata
β”‚   β”‚   └── sentry
β”‚   β”œβ”€β”€ shared
β”‚   β”‚   β”œβ”€β”€ button
β”‚   β”‚   └── modal
β”‚   └── vite-env.d.ts
β”œβ”€β”€ tailwind.config.ts
β”œβ”€β”€ tsconfig.json
β”œβ”€β”€ tsconfig.node.json
└── vite.config.ts

Each top-level folder has one job:

  • lib holds pure functions with no storage, HTTP, or incidental writes, so input goes in and a value comes out.
  • services talks to the world through HTTP, WebSockets, web storage, and third-party APIs, as plain functions rather than React code.
  • pages holds route-owned UI and hooks.
  • shared holds code used by more than one page, which in a monorepo often becomes a package.

Layouts are containers, and the page passes them slots instead of pushing a soup of conditionals into the shell:

import { RootLayout } from 'layouts/root'
 
export function Homepage() {
  return (
    <RootLayout
      aside={
        <>
          <HelpBox />
          <Statistics />
        </>
      }
      heading="Contracts"
    >
      <Contracts />
    </RootLayout>
  )
}

State

Async flags come from useAsync, useAsyncFn, or SWR / TanStack Query: { data, isLoading, error }. The fetch lives in services as vanilla functions, and the component only wraps them, as in useAsyncFn(api.loadData).

If two fields always update together, they are one object, and if I can derive a value from props or existing state while rendering, it is not state. I don't copy the same fact into two variables, and I keep shapes flat because nested blobs are harder to update. Persistable state stays JSON-serializable, with no class instances, functions, or Map as the source of truth, and collections in UI state are arrays unless I have a measured reason for a Map.

Zustand and Zod show up when the app needs a small client store or a parser at the boundary. They do not replace these rules.

Related writing