Conventions for React Apps

These are conventions I use in React apps so a folder looks like the last one I opened. They are not a style guide for the internet. For JavaScript hygiene I still point people at Clean Code JavaScript.

JavaScript

I write functional, declarative TypeScript. Named functions, small modules, types inferred at the edges.

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

I split large components into smaller ones with few props. Composition over 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.

Named exports only. 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.

Order inside a file: the exported component, then inner components, then types, then copy. Early returns for loading and error. Ternaries in JSX, not &&, 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

lib is pure functions. No storage, no HTTP, no incidental writes. Input in, value out.

services talks to the world: HTTP, WebSockets, web storage, third-party APIs. Plain functions, not React.

pages is route-owned UI and hooks.

shared is used by more than one page. In a monorepo that often becomes a package.

Layouts are containers. The page passes slots, not a soup of conditionals inside 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. The component only wraps them. Example: useAsyncFn(api.loadData).

If two fields always update together, they are one object. If I can derive a value from props or existing state while rendering, it is not state. I do not copy the same fact into two variables. Nested blobs are harder to update than a flat shape. Persistable state stays JSON-serializable: no class instances, no functions, no Map as the source of truth. 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