Web apps
Rules for TanStack Start apps, currently apps/web.
Top level: components/, features/, functions/, hooks/, libs/, providers/, routes/, widgets/. @/ points at src/. File and class rules are in Frontend.
Features
A feature in features/<names>/ uses only the layers it needs. Push logic down and keep UI dumb:
| Layer | Holds |
|---|---|
api/ |
Query contracts shared by loaders and pages, such as definePageQuery. |
libs/<domain>/ |
Pure code without React: transforms, parsing, query keys, types. |
hooks/ |
Stateful logic without markup. |
providers/ |
Context and runtime wiring. Never depends on widgets. |
components/<name>/ |
Presentational parts. Data and callbacks come through props only. |
widgets/<region>/ |
Composed regions that call data hooks, including single-use dialogs. |
pages/ |
Route entries. |
- Each layer exposes an
index.ts; a feature has no root barrel. Outside a feature, import its layer barrel (@/features/errors/hooks); inside, import relatively. functions/isomorphic/holds.isomorph.tsmodules,functions/server/holds.server.tsones. Client code deep-imports a.server.tsfile instead of the server barrel.- Route-only loaders and search schemas stay in
routes/. A feature’s rootconstants.tsandtypes.tshold only symbols several layers share. - Keep a helper, constant or type next to its only consumer. Constants used by one function live inside it; static arrays, objects and Lingui descriptors live at module scope. Derive result types with
ReturnTypenext to their owner. - Load shared data in the closest common parent and pass it down, instead of hooks that only forward it.
- Tests sit next to the code whose behaviour they check.
- Name mutation hooks
use<Entity><Action>. Components, hooks and*QueryOptionsare exempt from verb-first names.
Dialogs
- Open a dialog with a detached trigger:
useState(() => Dialog.createHandle<Payload>()), aDialog.Triggerwith thathandleand apayload, and oneDialogorDialogConfirmreadingpayloadfrom its render prop. - Don’t control dialogs with
openandonOpenChange. The mobile sidebar is the one exception.
Settings
| What is edited | How it saves |
|---|---|
| An independent text field | InlineTextField: on blur or Enter, Esc reverts, errors under the field. |
| An independent switch, select, checkbox | On change, disabled while saving, errors in a toast through meta.errorTitle. |
| Fields that only make sense together | One form with UnsavedChangesBar, useUnsavedChanges and useBlockUnsavedChanges. |
| A new entity or a list item | An explicit Create or Save button, in a dialog or on its own page. |
| A destructive action | The danger zone, behind DialogConfirm. |
| A state action: enable, mute, sync | A button or menu in the page header. |
Fields show no saving state of their own. A form with text fields confirms each save with a success toast that has one id per form, so repeated saves replace it; lists of standalone switches skip it. Creating, deleting and state actions toast their result, and a new entity opens after it’s created.