This document explains how data flows through the application, from user interaction to API and back.
User Action
│
▼
┌─────────────────┐
│ React Router │──► Route matched
│ Router │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Loader │──► Server-side data fetch (SSR)
│ (server) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Component │──► Page renders with data
│ (client) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ React Query │──► Client-side caching & updates
│ useQuery() │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Watch API │──► Real-time updates via SSE
│ (EventSource) │
└─────────────────┘
When a route is requested, the loader runs on the server first:
// app/routes/organizations/index.tsx
import { getOrganizations } from '@/resources/organizations';
export async function loader({ context }: Route.LoaderArgs) {
const { session, logger } = context;
// Ensure user is authenticated
if (!session) {
throw redirect('/login');
}
// Fetch data server-side
logger.info('Loading organizations');
const organizations = await getOrganizations();
// Return data to component
return { organizations };
}The context object contains:
| Property | Type | Description |
|---|---|---|
session |
Session | null |
User session with access token |
logger |
Logger |
Request-scoped logger |
requestId |
string |
Unique request identifier |
cspNonce |
string |
CSP nonce for inline scripts |
- Fast initial load - HTML includes data, no loading spinner
- SEO friendly - Content visible to crawlers
- No flash of loading state - Page renders complete
After SSR, React Query takes over for client-side state:
// In component
export default function OrganizationsPage({ loaderData }: Route.ComponentProps) {
// Use SSR data as initial, React Query manages updates
const { data: organizations } = useOrganizations({
initialData: loaderData.organizations,
});
return <OrganizationList items={organizations} />;
}// app/resources/organizations/organization.queries.ts
export function useOrganizations(options?: { initialData?: Organization[] }) {
return useQuery({
queryKey: ['organizations'],
queryFn: () => organizationService.list(),
initialData: options?.initialData,
staleTime: 30_000, // Consider fresh for 30s
});
}| Scenario | Behavior |
|---|---|
| Initial load | Uses SSR data (no fetch) |
| Navigation | Uses cached data if fresh |
| Stale data | Background refetch |
| Window focus | Automatic revalidation |
| Manual invalidation | Immediate refetch |
export function useDeleteOrganization() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => organizationService.delete(id),
// Optimistic update - instant UI feedback
onMutate: async (id) => {
// Cancel in-flight queries
await queryClient.cancelQueries({ queryKey: ['organizations'] });
// Snapshot current data
const previous = queryClient.getQueryData(['organizations']);
// Optimistically remove item
queryClient.setQueryData(['organizations'], (old: Organization[]) =>
old.filter((org) => org.id !== id)
);
return { previous };
},
// Rollback on error
onError: (err, id, context) => {
queryClient.setQueryData(['organizations'], context?.previous);
},
// Refetch to ensure consistency
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['organizations'] });
},
});
}User clicks "Delete"
│
▼
┌─────────────────┐
│ onMutate() │──► Immediately remove from UI
└────────┬────────┘
│
▼
┌─────────────────┐
│ mutationFn() │──► API call in background
└────────┬────────┘
│
┌────┴────┐
│ │
▼ ▼
Success Error
│ │
▼ ▼
onSettled onError()
│ Rollback UI
▼
Refetch latest
The Watch API uses Server-Sent Events (SSE) to stream updates from Kubernetes:
Browser Server K8s
│ │ │
│──── EventSource ────────►│ │
│ /api/watch?... │──── Watch Request ────►│
│ │ │
│◄─── SSE: ADDED ─────────│◄─── Event Stream ──────│
│◄─── SSE: MODIFIED ──────│ │
│◄─── SSE: DELETED ───────│ │
│ │ │
// In component
function DNSZonesPage() {
const { data: zones } = useDNSZones();
// Enable real-time updates
useDNSZonesWatch({
onEvent: (event) => {
// Cache automatically updated
console.log('Zone changed:', event.type, event.object);
},
});
return <ZoneList zones={zones} />;
}| Event | Meaning | Cache Action |
|---|---|---|
ADDED |
New resource created | Add to list |
MODIFIED |
Resource updated | Update in list |
DELETED |
Resource removed | Remove from list |
Services encapsulate API calls and transformations:
// app/resources/organizations/organization.service.ts
import { toOrganizations } from './organization.adapter';
import { getOrganizations as apiGetOrganizations } from '@/modules/control-plane/iam';
export async function list(): Promise<Organization[]> {
const response = await apiGetOrganizations();
return toOrganizations(response.data?.items ?? []);
}Component
│
▼
React Query Hook (useOrganizations)
│
▼
Service (organization.service.ts)
│
▼
Generated Client (@hey-api)
│
▼
Axios (with interceptors)
│
▼
Control Plane API
Component
│
▼
React Query Hook (useOrganizationsGql)
│
▼
GQL Service (organization.gql-service.ts)
│
▼
Gqlts Client (createGqlClient)
│
▼
Axios (with interceptors)
│
▼
GraphQL Gateway
GraphQL provides an alternative to REST for complex queries with field selection.
The Gqlts client automatically routes based on environment:
┌─────────────────────────────────────────────────────────────┐
│ createGqlClient(scope) │
└─────────────────────────────────────────────────────────────┘
│
┌───────────────┴───────────────┐
│ │
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ Server (SSR/Loader) │ │ Client (Browser) │
├─────────────────────────┤ ├─────────────────────────┤
│ - Direct to GRAPHQL_URL │ │ - Through /api/graphql │
│ - Auth from context │ │ - Auth via cookies │
│ - Curl logging │ │ - Sentry capture │
└─────────────────────────┘ └─────────────────────────┘
GraphQL endpoints are scoped to different contexts:
| Scope | Use Case |
|---|---|
user |
User-specific data (memberships, preferences) |
org |
Organization resources |
project |
Project resources |
global |
Cross-cutting queries |
// In loader (server-side)
const client = createGqlClient({ type: 'user', userId: 'me' });
const result = await client.query({
listOrganizationMemberships: {
items: { metadata: { name: true }, status: { organization: { displayName: true } } },
},
});See GraphQL Architecture for full details.
export async function loader({ context }: Route.LoaderArgs) {
try {
const data = await service.getData();
return { data };
} catch (error) {
context.logger.error('Failed to load data', { error });
// Throw to error boundary
throw new Response('Failed to load', { status: 500 });
}
}const { data, error, isError } = useOrganizations();
if (isError) {
return <ErrorMessage error={error} />;
}React Router's error boundary catches unhandled errors:
// app/root.tsx
export function ErrorBoundary() {
const error = useRouteError();
return <ErrorPage error={error} />;
}- Domain Modules - Resource module structure
- GraphQL - GraphQL client architecture
- Watch API - Real-time implementation details
- ADR-002 - Module design decisions
- ADR-008 - GraphQL integration decision