writing-tsx
Stack-agnostic within React/TSX: describes structure, naming, and shape, not specific libraries. Overlaps with writing-code; these rules win within frontend scope.
Components
Components are named function declarations, never arrow consts. Default export is a separate statement at the bottom, one per file, never inline or named. Prop types are type aliases named after the component (BoardCardProps), never bare Props, never inlined into the signature. Props come in as a single props param, destructured on the first line; no-prop components take empty (). Body order is fixed: destructure, data hooks, derived values, handlers, early-return guards, remaining derived values, JSX last. Handlers are function handleX() {} declarations except one-off inline callbacks. useCallback/useMemo stay out of presentational components, only providers, custom hooks, and genuinely expensive components get them. Guards form a strict ladder: special case, loading, error, empty/null, each returning before the main JSX. Loading always renders one shared component; empty/not-found returns null, list-level empties may show a short message. No comments, no doc blocks. memo() wraps the export only for expensive or frequently re-rendered components. Server-component frameworks get the client directive as the first line of top-level view/form/layout/page files, omitted on leaves. No forwardRef, components declare ref?: directly in their prop type and destructure it; components needing both an internal and forwarded ref hand-roll a merging callback. useImperativeHandle exposes a small named-method object, no dependency array, handle type inlined into ref?:. useRef<T>(null) for DOM nodes, useRef<T | undefined>(undefined) for mutable holders.
Data & hooks
Server data comes through per-resource hooks destructured with a renamed data: plus loading/error flags, multiple loading flags aliased and OR-ed in the guard; conditional JSX stays inline with &&/ternaries and array?.map(...), never pre-extracted. One query/mutation hook file per CRUD operation: list hooks are a thin query passing the service method as the fetcher, item hooks wrap the call in a closure and stay disabled until the id exists, same for any hook depending on an optional id, never called conditionally. Naming is useXxxList/useXxxs, useXxx, useCreate/Update/DeleteXxx. Query-key constants live in each query file, list keys as plain array literals, item keys as (id) => [...] factories, shaped ['domain', 'entity', id?]; mutation files import them rather than redefining. Mutations follow a fixed template: query client in, mutate with onSuccess invalidating the affected keys, Update/Delete invalidate list and item, Create invalidates list only. Response types are passed as the query's generic. Services export one plain object of endpoint methods, shared HTTP client imported first, methods are async functions returning a typed promise, endpoints share a base prefix with item-level ids interpolated via template literal, collection reads use a static path. Write operations pass the body through the client's JSON-body option. Create inputs are Omit<T, 'id'>, updates use a deep-partial type, bulk deletes take string[] as the body. Models are factory functions taking deep-partial input, defaults-merged, id seeded with a generated unique id (sometimes prefixed); primary model is the default export, sub-models are named exports in the same file. Domain types live in one types barrel, export type only, union literals, optional ? fields, redefined per feature rather than shared from a common module. Custom hooks stay single-responsibility, minimal deps, and throw when used outside their required provider; storage/value hooks return getters/setters, settings hooks return the raw context.
Forms
Forms declare their validation schema at the top of the module, validators carry inline human-readable messages, and the form type is inferred from the schema. Initialize with validate-on-change, the schema resolver, and default values, entity forms pulling defaults from a model factory, others from a literal object. Destructure form state, then validity/dirty/errors on a separate line. Entity forms hydrate from fetched data via a reset call inside an effect keyed on that data. Every input binds through the form library's controller with an error flag and helper message. Submit handler is named onSubmit, signature (formData) => ..., wired through the library's submit wrapper; submit button disabled while pristine or invalid, guarded by the shared loading component while pristine-or-loading. Multi-tab forms share state through the form provider, each tab reading from form context. The whole form value is captured into a local variable used for both submit payloads and pristine guards. Server-side field errors map back through the library's set-error API. Inline and dialog forms auto-save via debounced changes through an update mutation instead of an explicit submit.
Routing
Each route file default-exports one typed route object. Views are code-split with lazy(() => import(...)), declared after imports and before the route object; route files never wrap their own Suspense, that's applied once centrally around the layout outlet. A route's parent element is a bare outlet with no shell, a shell-wrapped outlet with a persistent shell, or the view directly for single-view routes. Index redirect is the first child of its route. Detail routes use :camelCaseId params nested under their list route, read untyped and narrowed with an inline as cast. Optional auth/settings keys live on the route object, children inherit parent auth unless overriding, explicit auth: null marks public routes while undefined inherits, the null-vs-undefined distinction is load-bearing. Pages hiding app chrome do it through route settings, never by branching in the layout. end: true stops a parent route staying active under an overlapping leaf URL. Routes are auto-discovered by globbing route files, no manual registration; URL grouping uses parenthesized route-group folders with no effect on the path. Router navigation and params are accessed through thin re-export hooks, never the router directly in features. Post-login redirect intent is stored in session storage, cleared on use, against an ignore-list of paths.
Context