Style Guide
These conventions are enforced by the repo’s TypeScript, ESLint, and Prettier config and are consistent across the codebase. Follow them so new code reads like the surrounding code.
Type system
Section titled “Type system”type, notinterfacefor props and object shapes. (The onlyinterfacein the app is the requiredProcessEnvaugmentation.)- Prefer
undefinedovernull. Reservenullfor explicit “load failed / no provider” sentinels (e.g..catch(() => null),createContext<T | null>). - Name ID types as
Model["id"], never a barestring— e.g.BondRequest["id"],User["id"],Document["id"]. Use these for params and map keys. - Use framework helper types —
PageProps,LayoutProps,PropsWithChildren,ComponentProps<...>— instead of hand-rollingchildrenor prop shapes. - Type-only imports use the
typekeyword (import { type Foo } from "…"), perverbatimModuleSyntax.
Functions & components
Section titled “Functions & components”- Arrow functions + named
constexports for components, hooks, and utilities:export const Foo = (...) => …. export defaultonly for Next special files —page.tsx,layout.tsx,loading.tsx,error.tsx,not-found.tsx,template.tsx,default.tsx(plus co-locatedview.tsx/crumb.tsxhelpers). Everything else is a named export.- Component props are typed as
type FooProps = { … }. UsePropsWithChildren<FooProps>for components that take children.
type FooProps = { label: string;};
export const Foo = ({ label, children }: PropsWithChildren<FooProps>) => ( <div> <span>{label}</span> {children} </div>);React & hooks
Section titled “React & hooks”- No
Reactnamespace in app code — import hooks directly (import { useState } from "react"), notReact.useState. - Avoid
useCallback— the React Compiler (infermode) handles memoization; manual memoization is noise. - Don’t
setStatedirectly insideuseEffect— encapsulate the non-reactive logic (e.g. viauseEffectEvent) so the reactive and non-reactive parts stay separate.
Data fetching
Section titled “Data fetching”Suspense-first (see
Architecture › Data & state):
a component is either data-pure (takes data as props) or data-owning
(fetches with useSuspenseQuery and owns its loading state). Avoid the middle
state in an SSR’d / prerendered tree: there, a bare useQuery with a
{data ? <A/> : <B/>} structural branch is a hydration-mismatch bug. Inside a
client-only subtree that never prerenders — e.g. the bond editor tools panel —
useQuery (including a load-state branch) is fine; see the carve-outs below.
- Data-owning components self-wrap. The exported
FoorendersFooInnerinsideSuspensewith the co-locatedFooSkeletonas fallback. Consumers never add boundaries. - Parallel fetches use one
trpc.useSuspenseQueriescall. SeparateuseSuspenseQueryhooks suspend serially — render stops at the first pending one, so N hooks are N sequential round trips. Dependent fetches (input needs a prior result) stay sequential by nature. Dynamic fan-out maps inside the same call:trpc.useSuspenseQueries((t) => items.map((i) => t.foo.get({ id: i.id }))). - Pages read data with
useSuspenseQueryand suspend into a route boundary. Under Cache Components a page-root suspense query must have a boundary (loading.tsxor<Suspense>inpage.tsx) or the build fails with “uncached data during prerendering”. - Conditional fetches can’t suspend (
useSuspenseQueryhas noskipToken/enabled): branch into a child component rendered only when the condition holds, or re-key the query to an entry that’s already cached (seeuseDisplayOrginframe.tsx). - Client-only external stores (set by effects — breadcrumbs, mount state)
are read via
useSyncExternalStorewith a server snapshot matching the SSR output, so late-hydrating Suspense boundaries stay consistent (seeuseCrumbStore,useMounted).
const FooInner = ({ id }: FooProps) => { const [[bar, baz]] = trpc.useSuspenseQueries((t) => [ t.bar.get({ id }), t.baz.list({ barIDIn: [id] }), ]); return <div>…</div>;};
export const Foo = (props: FooProps) => ( <Suspense fallback={<FooSkeleton />}> <FooInner {...props} /> </Suspense>);
export const FooSkeleton = () => <Skeleton className="h-5 w-40" />;useQuery remains correct where the fetch only fills values on stable DOM
and never changes its shape:
- Interaction-gated fetches (drawer/dialog/dropdown content, open-gated
skipToken). - Progressive enhancement — image/thumbnail links where a fallback renders meanwhile (avatars, PDF thumbnails).
- Data that only drives
disabled/attribute values (action buttons, theAppFramecolor). - Error-path components (
PageNotFound,auth/dialog.tsx) where a throwing query would replace the error UI itself. - URL-conditional crumbs and table-cell text fills.
- Client-only panels that never prerender — e.g. the bond editor tools
sections (
bond-form/editor/tools.tsx: contractor / surety / request info and the power-of-attorney list), which fill a fixedInfoSectiontable withuseQueryand may branch on load state because the surrounding editor is ause clienttree, not an SSR’d one. These were converted fromuseSuspenseQuery+Suspensewrappers to plainuseQuery.
Mutations
Section titled “Mutations”Mutation hooks live in src/hooks/use-*-mutation.ts, wrap trpc.*.useMutation(),
usually surface a toast.promise(...), and invalidate related queries in
onSuccess. Variables returned from a mutation hook are suffixed Mut:
| Hook | Variable |
|---|---|
useBondRequestMutation |
createMut, updateMut, … |
useUserMutation |
userMut |
const createMut = useBondRequestMutation();const handleSubmit = (data) => createMut.mutate(data);Imports & formatting
Section titled “Imports & formatting”@alias for absolute imports fromsrc(@/components/...); relative./only for same-or-child directory. Prettier orders@/…imports first, then relative.- Bun for everything (install, scripts, dev). Not npm or yarn.
- Prettier: 80 columns, 2-space indent, double quotes, semicolons.
Forms & UI
Section titled “Forms & UI”- Forms use TanStack Form via
useAppForm(see Architecture › Forms). Never react-hook-form. - UI composes Base UI primitives with the
renderprop (<Button render={<Link … />} />), notasChild. - Role gating is middleware-driven (
proxy.ts+pages.ts) — there is noRoleGatecomponent and nouseOrgTypehook. Read role/org fromtrpc.auth.session.
Dialog forms
Section titled “Dialog forms”Every form dialog follows one shape (exemplar:
src/components/power-of-attorney/mutate-dialog.tsx):
- The trigger is a required
trigger: ReactElementprop, rendered with<DialogTrigger render={trigger} />. Callers own the trigger button; a dialog never defines its trigger internally. - Open state is controlled and closes only on successful submit.
onSubmitwraps its mutations intry/catch: on successformApi.reset()thensetOpen(false); on failure fall through — the mutation hook’s toast surfaces the error and the dialog stays open with the user’s input intact. - Never wrap the submit button in
DialogClose. Base UI closes the dialog immediately on click — it neither waits for the async submit nor knows whether it succeeded. - The
<form>element gets auseId()id; the submit button lives inDialogFooteroutside the form and is associated viaform={formId}. - Create + edit share one
Mutate<Entity>Dialoginmutate-dialog.tsxwith a discriminatedmodeprop ({ mode: "create"; parentID; entity?: never } | { mode: "update"; entity; parentID?: never }) and the Delete button in the edit-mode footer. Per-mode zod schemas are namedCreate…/Update…and must be the same zod type (bothZodPipevia.transform) sharing the full form-value input shape, or thevalidatorsternary fails to typecheck.
export const MutateFooDialog = ({ mode, foo, trigger }: MutateFooDialogProps) => { const formId = useId(); const fooMut = useFooMutation(); const [open, setOpen] = useState(false);
const form = useAppForm({ // defaultValues, validators … onSubmit: async ({ value, formApi }) => { try { await fooMut.create(value); formApi.reset(); setOpen(false); } catch { // Failures are surfaced by the mutation toasts; stay on the form. } }, });
return ( <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger render={trigger} /> <DialogContent> <form id={formId} onSubmit={(e) => { e.preventDefault(); form.handleSubmit(); }} > {/* form.AppField … */} </form> <DialogFooter> <form.AppForm> <form.SubmitButton form={formId}>Save</form.SubmitButton> </form.AppForm> </DialogFooter> </DialogContent> </Dialog> );};A dialog whose success state is itself dialog content — e.g.
InviteMemberDialog, which shows the invite link after sending — stays open on
success; everything else closes.