Skip to content

Commit 76f7b24

Browse files
committed
feat(frontend): align with modern TanStack Router + React Query patterns
lib/api.ts: - replace ApiClient class with plain request() function + api object - add typed ApiError(status, message, body) so callers can check e.status - handle 204 No Content (return undefined instead of trying to parse JSON) services/auth.ts (new): - sessionQueryOptions: canonical queryOptions factory for auth state queryKey ["auth","session"], staleTime 5min - single definition reused by root loader, components, and mutations routes/__root.tsx: - add beforeLoad that calls ensureQueryData(sessionQueryOptions) - return { session } into router context so all child routes have it without a per-page network request routes/dashboard.tsx: - replace useQuery + imperative navigate with beforeLoad auth guard - throw redirect({ to: "/login" }) if context.session is null - use Route.useRouteContext() to read session — no loading state needed - invalidate sessionQueryOptions on logout before navigating routes/login.tsx + register.tsx: - add beforeLoad to redirect authenticated users to /dashboard - remove useState(isLoading) — use form.Subscribe(s => s.isSubmitting) - invalidate sessionQueryOptions after successful auth before navigating docs/PATTERNS.md: - Service files with queryOptions factories - beforeLoad auth guards (protected routes + public redirects) - form.Subscribe(isSubmitting) over useState - ApiError handling pattern docs/DECISIONS.md: - queryOptions() over inline query objects - beforeLoad over component-level redirects (no flash, no loading state) - plain fetch over HTTP client libraries - no global auth state — query cache is the single source of truth
1 parent b6ece08 commit 76f7b24

8 files changed

Lines changed: 779 additions & 448 deletions

File tree

apps/frontend/docs/DECISIONS.md

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,3 +435,144 @@ routes/
435435
PostCard.tsx
436436
PostForm.tsx
437437
```
438+
439+
---
440+
441+
## `queryOptions()` Factories over Inline Query Objects
442+
443+
**Choice**: Define query options in service files, not inline in components.
444+
445+
**Why**:
446+
447+
Defining a query inline in a component creates three problems:
448+
449+
1. You cannot use the same query in a route `loader` without duplicating the `queryKey` and `queryFn`.
450+
2. The query key is scattered — invalidating it requires remembering the exact string used in the component.
451+
3. It's harder to test; you'd need to render the component just to test the fetch logic.
452+
453+
`queryOptions()` solves all three:
454+
455+
```typescript
456+
// services/auth.ts — one definition, used everywhere
457+
export const sessionQueryOptions = queryOptions({
458+
queryKey: ["auth", "session"] as const,
459+
queryFn: () => authClient.getSession().then((r) => r.data ?? null),
460+
staleTime: 1000 * 60 * 5,
461+
});
462+
463+
// In a route loader:
464+
loader: ({ context: { queryClient } }) =>
465+
queryClient.ensureQueryData(sessionQueryOptions),
466+
467+
// In a component:
468+
const session = Route.useLoaderData();
469+
470+
// When invalidating after a mutation:
471+
queryClient.invalidateQueries({ queryKey: sessionQueryOptions.queryKey });
472+
```
473+
474+
One object. All usages point to the same key, staleTime, and fetcher. Rename it and TypeScript catches every consumer.
475+
476+
**Trade-offs**:
477+
478+
- One more file to create per domain
479+
- Pattern requires understanding TanStack Router's loader + context model
480+
481+
---
482+
483+
## `beforeLoad` for Auth Guards over Component-Level Redirects
484+
485+
**Choice**: Auth checks in `beforeLoad`, not inside the rendered component.
486+
487+
**Why**:
488+
489+
The old pattern — `useQuery` for session inside the component, then `navigate()` if null — has three problems:
490+
491+
1. **Flash of unauthenticated content**: The component renders (briefly showing the protected content or a loading spinner) before the redirect fires. With `beforeLoad`, the component never mounts if the guard fails.
492+
493+
2. **Dependent state management**: You need `isLoading` + `if (!session) return null` — two extra states the component has to manage.
494+
495+
3. **Escape from the component lifecycle**: Calling `navigate()` inside a `useEffect` or render is a side effect of the render phase, not a declarative description of what the route requires.
496+
497+
`beforeLoad` is a declarative contract: "this route requires a session." The router enforces it before the component touches the DOM.
498+
499+
```typescript
500+
// ✗ Component-level guard — the old React Router mental model
501+
function DashboardPage() {
502+
const { data: session, isLoading } = useQuery({ queryKey: ["session"], ... });
503+
if (isLoading) return <Spinner />;
504+
if (!session) { navigate({ to: "/login" }); return null; }
505+
return <div>{session.user.name}</div>;
506+
}
507+
508+
// ✓ Router-level guard — declarative, no flash, no loading state
509+
export const Route = createFileRoute("/dashboard")({
510+
beforeLoad: ({ context }) => {
511+
if (!context.session) throw redirect({ to: "/login" });
512+
},
513+
component: DashboardPage,
514+
});
515+
516+
function DashboardPage() {
517+
const { session } = Route.useRouteContext();
518+
if (!session) return null; // Defensive — never reached
519+
return <div>{session.user.name}</div>;
520+
}
521+
```
522+
523+
The root route's `beforeLoad` fetches the session once and injects it into router context. All child routes read `context.session` without fetching again.
524+
525+
**Trade-offs**:
526+
527+
- Requires understanding TanStack Router's context model
528+
- `context.session` is typed as `Session | null` even on protected routes (TS can't narrow through `throw redirect()`); requires a defensive null check
529+
530+
---
531+
532+
## Plain Fetch API over HTTP Client Libraries
533+
534+
**Choice**: `fetch` directly, wrapped in a thin `request()` function.
535+
536+
**Why**:
537+
538+
The browser's `fetch` API is a web standard. Libraries like `axios`, `ky`, and `got` add:
539+
- Interceptors (you can do this with a wrapper function)
540+
- Automatic retries (handle in `queryOptions.retry`)
541+
- Request cancellation (use `AbortSignal` from the `loader` context)
542+
- Error normalisation (our `ApiError` class does this)
543+
544+
None of these require a library. `fetch` supports all of them natively or via `@tanstack/react-query`'s built-in mechanisms.
545+
546+
The `api` object in `lib/api.ts` is a plain object of functions — not a class instance. This is intentionally simpler: no `new`, no `this`, tree-shakeable, testable by calling the functions directly.
547+
548+
**The shift**: `ApiError` is thrown for non-2xx responses and carries `status` and `body`. Callers check `instanceof ApiError` for specific status handling and fall back to a generic message otherwise. This replaces the opaque `Error("Request failed")` that an unmaintained class was throwing.
549+
550+
**Trade-offs**:
551+
552+
- No interceptors (add a wrapper if upload progress or auth headers become necessary)
553+
- Manual `Content-Type` header (acceptable — all our API calls are JSON)
554+
555+
---
556+
557+
## No Global Auth State (Zustand/Context) — Use the Query Cache
558+
559+
**Choice**: Session state lives in the React Query cache (`["auth", "session"]`), not a Zustand store or React context.
560+
561+
**Why**:
562+
563+
A common pattern is to create a `useAuthStore` or `AuthContext` to hold the current user. This creates a redundant source of truth that can drift from the server state.
564+
565+
The query cache *is* the client-side state for server data. The session is server state. It:
566+
- Has a known staleness (5 minutes)
567+
- Must be re-fetched after mutations (signIn/signOut)
568+
- Can be invalidated from anywhere via `queryClient.invalidateQueries`
569+
570+
Zustand is reserved for *UI state* — things with no server equivalent (sidebar open/closed, modal state, selected tab). Anything that has a server source lives in the React Query cache.
571+
572+
| State | Solution |
573+
|---|---|
574+
| Current user session | React Query `sessionQueryOptions` |
575+
| Server resource (posts, users) | React Query `queryOptions` |
576+
| Form state | TanStack Form |
577+
| URL/navigation state | TanStack Router search params |
578+
| UI-only state (modals, sidebar) | Zustand |

apps/frontend/docs/PATTERNS.md

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,3 +804,173 @@ if (error) {
804804
);
805805
}
806806
```
807+
808+
---
809+
810+
## Service Files — `queryOptions` Factories
811+
812+
Define query options in a service file, not inline in a component. This makes them reusable across loaders, components, and tests — and keeps the query key co-located with the fetcher.
813+
814+
```typescript
815+
// services/auth.ts
816+
import { queryOptions } from "@tanstack/react-query";
817+
import { authClient } from "@/lib/auth-client";
818+
819+
export const sessionQueryOptions = queryOptions({
820+
queryKey: ["auth", "session"] as const,
821+
queryFn: () => authClient.getSession().then((r) => r.data ?? null),
822+
staleTime: 1000 * 60 * 5,
823+
});
824+
```
825+
826+
Use the same options object in the router loader and the component:
827+
828+
```typescript
829+
// Route loader — runs before render, no loading state needed
830+
loader: ({ context: { queryClient } }) =>
831+
queryClient.ensureQueryData(sessionQueryOptions),
832+
833+
// Component — reads from cache, no extra fetch
834+
const session = Route.useLoaderData();
835+
```
836+
837+
Name service files after the domain they serve: `services/auth.ts`, `services/users.ts`, `services/posts.ts`.
838+
839+
---
840+
841+
## Auth Guards with `beforeLoad`
842+
843+
Protect routes at the router level — not inside the component. `beforeLoad` runs before the component mounts; if a redirect is thrown, the component never renders.
844+
845+
### Protected route
846+
847+
```typescript
848+
// routes/dashboard.tsx
849+
export const Route = createFileRoute("/dashboard")({
850+
beforeLoad: ({ context }) => {
851+
// context.session was set by the root beforeLoad.
852+
// If null, redirect to /login before the page loads.
853+
if (!context.session) {
854+
throw redirect({ to: "/login" });
855+
}
856+
},
857+
component: DashboardPage,
858+
});
859+
860+
function DashboardPage() {
861+
// session is null-narrowed away by the guard above.
862+
// The null check here is defensive — it satisfies TypeScript.
863+
const { session } = Route.useRouteContext();
864+
if (!session) return null;
865+
866+
return <div>Hello, {session.user.name}</div>;
867+
}
868+
```
869+
870+
### Redirect authenticated users away from login/register
871+
872+
```typescript
873+
// routes/login.tsx
874+
export const Route = createFileRoute("/login")({
875+
beforeLoad: ({ context }) => {
876+
if (context.session) {
877+
throw redirect({ to: "/dashboard" });
878+
}
879+
},
880+
component: LoginPage,
881+
});
882+
```
883+
884+
### How the root feeds session into context
885+
886+
```typescript
887+
// routes/__root.tsx
888+
export const Route = createRootRouteWithContext<RouterContext>()({
889+
beforeLoad: async ({ context }) => {
890+
const session = await context.queryClient.ensureQueryData(sessionQueryOptions);
891+
return { session }; // Available as context.session on all child routes
892+
},
893+
});
894+
```
895+
896+
### After a mutation that changes auth state (signIn, signOut, signUp)
897+
898+
```typescript
899+
// Invalidate first, then navigate.
900+
// The router re-runs beforeLoad, which calls ensureQueryData.
901+
// Because the query is stale (invalidated), it refetches, and context.session
902+
// is updated before the new route's component renders.
903+
await queryClient.invalidateQueries({ queryKey: sessionQueryOptions.queryKey });
904+
navigate({ to: "/dashboard" });
905+
```
906+
907+
---
908+
909+
## Form Submitting State
910+
911+
TanStack Form tracks its own submitting state. You do not need `useState`.
912+
913+
```typescript
914+
// ✗ Don't do this
915+
const [isLoading, setIsLoading] = useState(false);
916+
const form = useForm({
917+
onSubmit: async ({ value }) => {
918+
setIsLoading(true);
919+
try { /* ... */ } finally { setIsLoading(false); }
920+
},
921+
});
922+
<button disabled={isLoading}>{isLoading ? "Loading..." : "Submit"}</button>
923+
924+
// ✓ Do this
925+
const form = useForm({
926+
onSubmit: async ({ value }) => {
927+
// TanStack Form sets isSubmitting = true automatically for onSubmit's duration.
928+
// No manual state management needed.
929+
},
930+
});
931+
932+
<form.Subscribe selector={(s) => s.isSubmitting}>
933+
{(isSubmitting) => (
934+
<button type="submit" disabled={isSubmitting}>
935+
{isSubmitting ? "Loading..." : "Submit"}
936+
</button>
937+
)}
938+
</form.Subscribe>
939+
```
940+
941+
---
942+
943+
## Handling API Errors
944+
945+
`api.ts` throws `ApiError` for non-2xx responses. Check the status code for specific handling:
946+
947+
```typescript
948+
import { api, ApiError } from "@/lib/api";
949+
950+
try {
951+
await api.post("/api/posts", data);
952+
} catch (e) {
953+
if (e instanceof ApiError) {
954+
if (e.status === 409) {
955+
toast.error("A post with that title already exists");
956+
return;
957+
}
958+
if (e.status === 422) {
959+
toast.error("Invalid data");
960+
return;
961+
}
962+
}
963+
toast.error("Something went wrong");
964+
}
965+
```
966+
967+
For queries, use `onError` or `throwOnError` in `queryOptions`:
968+
969+
```typescript
970+
export const postsQueryOptions = queryOptions({
971+
queryKey: ["posts"],
972+
queryFn: () => api.get<ApiSuccess<Post[]>>("/api/posts"),
973+
});
974+
```
975+
976+
Errors surface in `useQuery()` as `error: ApiError` — typed, not opaque.

0 commit comments

Comments
 (0)