Template
The starting point for an app with a React frontend. Same server, env, database and API skeleton as template-htmx — the two differ only in what is served at / — with a React 19 SPA, Tailwind v4 and shadcn/ui on top. Carried from a working project rather than designed in the abstract, and carrying the lessons that cost it time: - The dev script states NODE_ENV=development rather than inheriting it. React resolves its runtime against NODE_ENV: Bun emits jsxDEV() in development and React's production jsx-dev-runtime defines jsxDEV as void 0. Inherit production while transforming for development and the app dies at first paint. Verified: the dev bundle resolves the development runtime and contains no void-0 stub. - Asset URLs in components are relative and <base href="/"> is in the HTML, so a path-prefix proxy can rewrite one attribute and every asset follows. A test enforces it. - Routing is in the hash, because the app may be mounted under a prefix it only learns at runtime and a hash means the same thing at either mount. - index.html holds __PUBLIC_URL__ and gen-index.ts substitutes it, because Bun's HTML bundler fails the build on a root-relative favicon href. - APP_ENV failing closed, env files before the ambient environment, the database enforced by name, one Postgres client per process — as in the htmx template. The database has no tables. The App is a shell with one tab and no features, named as a placeholder. Renaming `app` is the first step and the README lists every place it appears. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
309 lines
9.3 KiB
Markdown
309 lines
9.3 KiB
Markdown
# Code Conventions
|
|
|
|
This document outlines the coding patterns and conventions used in this project.
|
|
|
|
A living document — adjust it as the project's needs change.
|
|
|
|
## Component Organization
|
|
|
|
### Feature Folder Pattern
|
|
|
|
Components that represent a feature or screen should use a folder structure with a barrel export.
|
|
|
|
```
|
|
Feature/
|
|
├── index.tsx # exports from Feature.tsx
|
|
├── Feature.tsx # main component
|
|
├── SubComponent.tsx
|
|
└── utils.ts
|
|
```
|
|
|
|
**index.tsx:**
|
|
|
|
```tsx
|
|
export * from './Feature';
|
|
```
|
|
|
|
**Rationale:** Keeps imports clean (`import { Feature } from './Feature'`) while allowing the feature to grow into multiple files without changing import paths. Subcomponents that only serve this feature live in the same directory rather than being abstracted to a shared components folder.
|
|
|
|
### Types Alongside Components
|
|
|
|
Export component prop types from the same file as the component.
|
|
|
|
```tsx
|
|
export type FeatureProps = {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
};
|
|
|
|
export const Feature = ({ value, onChange }: FeatureProps) => {
|
|
// ...
|
|
};
|
|
```
|
|
|
|
**Rationale:** Keeps types discoverable and colocated with their usage. Consumers can import both the component and its types from the same path.
|
|
|
|
## State Management
|
|
|
|
### Manager Pattern for Hooks
|
|
|
|
Hooks that manage complex state should return a "manager" object. Components receive this manager as a prop.
|
|
|
|
```tsx
|
|
// Hook
|
|
export const useFeatureManager = () => {
|
|
const [state, setState] = useState('');
|
|
const [filter, setFilter] = useState('');
|
|
|
|
const filteredItems = (() => {
|
|
// derived state computation
|
|
})();
|
|
|
|
return {
|
|
state,
|
|
setState,
|
|
filter,
|
|
setFilter,
|
|
filteredItems,
|
|
};
|
|
};
|
|
|
|
export type FeatureManager = ReturnType<typeof useFeatureManager>;
|
|
|
|
// Parent component
|
|
const Parent = () => {
|
|
const manager = useFeatureManager();
|
|
return <Child manager={manager} />;
|
|
};
|
|
|
|
// Child component
|
|
const Child = ({ manager }: { manager: FeatureManager }) => {
|
|
const { state, filteredItems } = manager;
|
|
// ...
|
|
};
|
|
```
|
|
|
|
**Rationale:** Centralizes state logic in one place. Child components don't need to know about individual state setters - they just receive the manager. Makes refactoring easier since state shape changes only affect the hook.
|
|
|
|
### State Colocation in Hooks
|
|
|
|
All local state (search, filters, pagination, expanded states) should live in the feature's hook, not scattered across components.
|
|
|
|
```tsx
|
|
// Good
|
|
export const useFeatureManager = () => {
|
|
const [search, setSearch] = useState('');
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
// ...
|
|
};
|
|
|
|
// Avoid
|
|
const Component = () => {
|
|
const [search, setSearch] = useState(''); // state in component
|
|
// ...
|
|
};
|
|
```
|
|
|
|
**Rationale:** Single source of truth for feature state. Easier to understand, test, and modify. Prevents state synchronization bugs.
|
|
|
|
### Derived State in Hooks
|
|
|
|
Computed/derived values should be calculated in the hook, not in components.
|
|
|
|
```tsx
|
|
export const useFeatureManager = () => {
|
|
const [items, setItems] = useState([]);
|
|
const [filter, setFilter] = useState('');
|
|
|
|
// Derived state computed in hook
|
|
const filteredItems = (() => {
|
|
return items.filter((item) => item.name.includes(filter));
|
|
})();
|
|
|
|
const itemCount = filteredItems.length;
|
|
|
|
return { items, filter, setFilter, filteredItems, itemCount };
|
|
};
|
|
```
|
|
|
|
**Rationale:** Components stay focused on rendering. Business logic stays in one place. Derived values are computed once and shared across all consuming components.
|
|
|
|
## React Patterns
|
|
|
|
### Reach for useMemo / useCallback only when they do work
|
|
|
|
Default to writing the code plainly. Most derived values are cheap and re-computing them per render costs
|
|
less than the memo that guards them.
|
|
|
|
```tsx
|
|
// ✅ Fine — cheap, so just write it
|
|
const filteredItems = items.filter((item) => item.active);
|
|
const handleClick = () => doSomething();
|
|
```
|
|
|
|
But they are ordinary tools, not forbidden ones. Use them where they earn it:
|
|
|
|
- a value or callback in a **dependency array**, where an unstable identity re-runs an effect or
|
|
re-subscribes a socket every render;
|
|
- a genuinely **expensive** computation over a large list;
|
|
- a prop passed to a **memoized** child.
|
|
|
|
```tsx
|
|
// ✅ Earns it — an unstable callback here would re-subscribe on every render
|
|
const onConnectionChange = useCallback((state) => setConnState(state), [setConnState]);
|
|
```
|
|
|
|
The React Compiler is a separate, opt-in build plugin and is **not installed here** (there is no
|
|
`babel-plugin-react-compiler` in `package.json`); React 19 on its own memoizes nothing.
|
|
|
|
### Computation Functions Outside Components
|
|
|
|
Extract complex computations into functions declared below the component, not as IIFEs inside.
|
|
|
|
```tsx
|
|
export const Component = ({ data }: Props) => {
|
|
const metrics = computeMetrics(data);
|
|
const stats = computeStats(data, metrics.total);
|
|
|
|
return <div>{/* ... */}</div>;
|
|
};
|
|
|
|
// Functions below component
|
|
function computeMetrics(data: Data[]): Metrics {
|
|
// complex computation
|
|
}
|
|
|
|
function computeStats(data: Data[], total: number): Stats {
|
|
// complex computation
|
|
}
|
|
```
|
|
|
|
**Rationale:** Keeps the component body focused on rendering logic. Functions are testable in isolation. Easier to read and understand the component's purpose.
|
|
|
|
### Fragment Shorthand
|
|
|
|
Use `<>` for fragments. Only import and use `Fragment` when a `key` prop is required.
|
|
|
|
```tsx
|
|
// Good - no key needed
|
|
return (
|
|
<>
|
|
<Header />
|
|
<Content />
|
|
</>
|
|
);
|
|
|
|
// Good - key required
|
|
import { Fragment } from 'react';
|
|
|
|
return items.map((item) => (
|
|
<Fragment key={item.id}>
|
|
<ItemHeader item={item} />
|
|
<ItemContent item={item} />
|
|
</Fragment>
|
|
));
|
|
```
|
|
|
|
**Rationale:** `<>` is cleaner and more concise. `Fragment` is only needed for the `key` prop which `<>` doesn't support.
|
|
|
|
## Imports
|
|
|
|
### Single-Line Imports
|
|
|
|
Keep imports on a single line. If an import has too many items, split into multiple import statements.
|
|
|
|
```tsx
|
|
// Good
|
|
import { Button, Input, Select } from '@/components/ui';
|
|
import { Card, CardHeader, CardContent } from '@/components/ui/card';
|
|
|
|
// Good - split when too long
|
|
import { TableBody, TableCell, TableHead } from '@/components/ui/table';
|
|
import { TableHeader, TableRow } from '@/components/ui/table';
|
|
|
|
// Avoid - multiline imports
|
|
import { Button, Input, Select, Card } from '@/components/ui';
|
|
```
|
|
|
|
**Rationale:** Single-line imports are easier to scan and take less vertical space. Splitting by source module keeps related imports together.
|
|
|
|
## Utilities
|
|
|
|
### Use Existing Helpers
|
|
|
|
Prefer existing helper functions over inline implementations.
|
|
|
|
```tsx
|
|
// Good
|
|
import { formatCurrency } from 'helpers/formatters';
|
|
|
|
const display = formatCurrency(amount / 100, currency, 0);
|
|
|
|
// Avoid
|
|
const display = new Intl.NumberFormat('en-US', {
|
|
style: 'currency',
|
|
currency: currency,
|
|
minimumFractionDigits: 0,
|
|
}).format(amount / 100);
|
|
```
|
|
|
|
**Rationale:** Consistent formatting across the app. Single place to modify behavior. Less code duplication and potential for bugs.
|
|
|
|
---
|
|
|
|
## Project Structure
|
|
|
|
```
|
|
src/
|
|
server.ts Bun.serve — public routes, /api/*, then the SPA
|
|
env-file.ts APP_ENV, layered env files, ambient environment last
|
|
servers/
|
|
create-router.ts createRouter() and HonoVariables
|
|
hono.ts the MOUNTS list, mounted under /api
|
|
errors.ts the one error envelope
|
|
serialize.ts every API serializer, in one module — its test walks the exports
|
|
api/<name>.ts one router per resource
|
|
databases/app_db/ Drizzle — see docs/DATABASE.md
|
|
apps/frontend/
|
|
index.html tracked template, holds __PUBLIC_URL__
|
|
index.gen.html generated, gitignored, what the server imports
|
|
index.tsx React entry
|
|
App.tsx the shell
|
|
globals.css Tailwind and the shadcn theme
|
|
components/ui/ shadcn primitives
|
|
lib/routes.ts hash routing
|
|
lib/api.ts the one fetch helper
|
|
features/<Feature>/ one folder per screen — see above
|
|
public/ served verbatim, globbed into routes at boot
|
|
scripts/gen-index.ts __PUBLIC_URL__ substitution
|
|
```
|
|
|
|
`@/*` resolves to `src/apps/frontend/*`, so `@/components/ui/button` and `@/lib/utils` work as shadcn
|
|
generates them.
|
|
|
|
## Assets and URLs
|
|
|
|
**Asset URLs in components must be relative — never start with `/`.**
|
|
|
|
```tsx
|
|
<img src="hero.webp" /> // ✅ resolves against <base>
|
|
<img src="/hero.webp" /> // ❌ escapes a path-prefix proxy
|
|
```
|
|
|
|
The app is served at `/` directly, and may be served under a prefix by a proxy. `<base href="/">` in
|
|
`index.html` is what a rewriting proxy changes, so relative URLs follow the app; a leading slash
|
|
bypasses it. `src/apps/frontend/asset-urls.test.ts` enforces this.
|
|
|
|
`fetch('/api/...')` is the exception — proxies that patch `window.fetch` rewrite absolute API paths.
|
|
|
|
Anything in `index.html` that a bundler would resolve on disk (a favicon `href`) must be an absolute
|
|
URL via `__PUBLIC_URL__` — Bun fails the build on a root-relative one.
|
|
|
|
## Configuration
|
|
|
|
`APP_ENV`, never `NODE_ENV` — see README. `NODE_ENV` is still set explicitly by the `dev` script
|
|
because React's conditional exports resolve against it: Bun emits `jsxDEV()` in development, and
|
|
React's production jsx-dev-runtime defines `jsxDEV` as `void 0`. The transform and the runtime must
|
|
agree.
|