Files
template-react/CONVENTIONS.md
T
pastilhasandClaude Opus 5 c08dab4243 Initial commit: Bun · Hono · React SPA · Postgres/Drizzle 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>
2026-08-18 02:02:52 +00:00

9.3 KiB

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:

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.

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.

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

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

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.

// ✅ 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.
// ✅ 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.

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.

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

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

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

<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.