# Conventions for React Apps

Canonical: https://gaboesquivel.com/blog/2023-01-react-app-standards

**Published:** 2023-01-29
**Summary:** My conventions for naming, collocation, state, and folder structure in React applications as features become more complex.
**Categories:** Engineering, Frontend
**Technologies:** ReactJS, TypeScript, NextJS, TanStack Query, Zustand, Zod

These are conventions I use in [React](https://gaboesquivel.com/tech/reactjs) 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](https://github.com/ryanmcdermott/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](https://kentcdodds.com/blog/aha-programming).

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.

```ts
// 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.

```tsx
// 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 loading...

  if (error) return error loading data.

  return (
    
      {content.headline}
      {data ? {data.title()} : null}
      {data?.items?.map(Item)}
      {content.button}
    
  )
}

function Item({ description }: { description: string }) {
  return {description}
}

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](https://kentcdodds.com/blog/get-a-catch-block-error-message-with-typescript):

```tsx
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).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:

```tsx
import { RootLayout } from 'layouts/root'

export function Homepage() {
  return (
    
          
          
        
      }
      heading="Contracts"
    >
      
    
  )
}
```

## State

Async flags come from `useAsync`, `useAsyncFn`, or SWR / [TanStack Query](https://gaboesquivel.com/tech/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.