Skip to content

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, not interface for props and object shapes. (The only interface in the app is the required ProcessEnv augmentation.)
  • Prefer undefined over null. Reserve null for explicit “load failed / no provider” sentinels (e.g. .catch(() => null), createContext<T | null>).
  • Name ID types as Model["id"], never a bare string — e.g. BondRequest["id"], User["id"], Document["id"]. Use these for params and map keys.
  • Use framework helper typesPageProps, LayoutProps, PropsWithChildren, ComponentProps<...> — instead of hand-rolling children or prop shapes.
  • Type-only imports use the type keyword (import { type Foo } from "…"), per verbatimModuleSyntax.
  • Arrow functions + named const exports for components, hooks, and utilities: export const Foo = (...) => ….
  • export default only for Next special filespage.tsx, layout.tsx, loading.tsx, error.tsx, not-found.tsx, template.tsx, default.tsx (plus co-located view.tsx/crumb.tsx helpers). Everything else is a named export.
  • Component props are typed as type FooProps = { … }. Use PropsWithChildren<FooProps> for components that take children.
type FooProps = {
label: string;
};
export const Foo = ({ label, children }: PropsWithChildren<FooProps>) => (
<div>
<span>{label}</span>
{children}
</div>
);
  • No React namespace in app code — import hooks directly (import { useState } from "react"), not React.useState.
  • Avoid useCallback — the React Compiler (infer mode) handles memoization; manual memoization is noise.
  • Don’t setState directly inside useEffect — encapsulate the non-reactive logic (e.g. via useEffectEvent) so the reactive and non-reactive parts stay separate.

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 Foo renders FooInner inside Suspense with the co-located FooSkeleton as fallback. Consumers never add boundaries.
  • Parallel fetches use one trpc.useSuspenseQueries call. Separate useSuspenseQuery hooks 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 useSuspenseQuery and suspend into a route boundary. Under Cache Components a page-root suspense query must have a boundary (loading.tsx or <Suspense> in page.tsx) or the build fails with “uncached data during prerendering”.
  • Conditional fetches can’t suspend (useSuspenseQuery has no skipToken / enabled): branch into a child component rendered only when the condition holds, or re-key the query to an entry that’s already cached (see useDisplayOrg in frame.tsx).
  • Client-only external stores (set by effects — breadcrumbs, mount state) are read via useSyncExternalStore with a server snapshot matching the SSR output, so late-hydrating Suspense boundaries stay consistent (see useCrumbStore, 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, the AppFrame color).
  • 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 fixed InfoSection table with useQuery and may branch on load state because the surrounding editor is a use client tree, not an SSR’d one. These were converted from useSuspenseQuery + Suspense wrappers to plain useQuery.

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);
  • @ alias for absolute imports from src (@/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 use TanStack Form via useAppForm (see Architecture › Forms). Never react-hook-form.
  • UI composes Base UI primitives with the render prop (<Button render={<Link … />} />), not asChild.
  • Role gating is middleware-driven (proxy.ts + pages.ts) — there is no RoleGate component and no useOrgType hook. Read role/org from trpc.auth.session.

Every form dialog follows one shape (exemplar: src/components/power-of-attorney/mutate-dialog.tsx):

  • The trigger is a required trigger: ReactElement prop, 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. onSubmit wraps its mutations in try/catch: on success formApi.reset() then setOpen(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 a useId() id; the submit button lives in DialogFooter outside the form and is associated via form={formId}.
  • Create + edit share one Mutate<Entity>Dialog in mutate-dialog.tsx with a discriminated mode prop ({ mode: "create"; parentID; entity?: never } | { mode: "update"; entity; parentID?: never }) and the Delete button in the edit-mode footer. Per-mode zod schemas are named Create…/Update… and must be the same zod type (both ZodPipe via .transform) sharing the full form-value input shape, or the validators ternary 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.