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:
libholds pure functions with no storage, HTTP, or incidental writes, so input goes in and a value comes out.servicestalks to the world through HTTP, WebSockets, web storage, and third-party APIs, as plain functions rather than React code.pagesholds route-owned UI and hooks.sharedholds 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.