Composing Data Requirements
Learn React Client-Server Communication - Part 024
A production-grade guide to composing data requirements in React applications using dependency graphs, route-level requirements, parallel queries, dependent queries, view models, batching, and backend-for-frontend trade-offs.
Part 024 — Composing Data Requirements
Target mental model: a screen is not a component tree that happens to fetch. A screen is a data dependency graph that happens to render through components. If you compose data accidentally through nested components, the network graph becomes accidental too.
Most real screens need more than one remote resource.
A case detail page might need:
- case detail;
- assigned officer profile;
- current user's permissions;
- workflow transitions;
- timeline events;
- attachments;
- comments;
- related cases;
- SLA counters;
- audit metadata;
- feature flags;
- reference data for dropdowns.
A naive React tree turns this into nested fetches.
Page fetches case
Header fetches assignee
Toolbar fetches permissions
Timeline fetches events
EventRow fetches actor
Attachments fetches files
RelatedCases fetches related list
That may work in local development.
It fails under real latency.
This part is about designing the data graph before the component tree traps you.
1. Data Requirement vs Component Requirement
A component requirement says:
“This component needs
user.name.”
A data requirement says:
“This route needs case detail, assignee profile, permission decision, and timeline summary before primary content is useful.”
Those are not the same level.
If every component independently discovers and fetches its own data, the app gets:
- request waterfalls;
- duplicate requests;
- inconsistent loading boundaries;
- inconsistent error handling;
- hidden authorization dependencies;
- difficult prefetching;
- hard-to-test screens;
- fragile SSR/streaming behavior;
- impossible product-level loading design.
Component encapsulation is good.
Network encapsulation is dangerous when it hides critical dependencies.
2. Start With the Screen Contract
Before writing hooks, define the screen contract.
type CaseDetailScreenData = {
case: CaseDetail
permissions: CasePermissions
transitions: WorkflowTransition[]
timelinePreview: TimelineEvent[]
assignee?: UserSummary
}
Then decide how it is loaded:
- one backend endpoint;
- multiple parallel queries;
- dependent queries;
- route loader;
- server component;
- streamed partial data;
- client-side progressive sections;
- mix of above.
The screen contract does not require a single API call. It requires explicit dependency design.
3. Draw the Data Dependency Graph
A dependency graph makes waterfalls visible.
Now classify dependencies.
Independent
Can start immediately.
current user
reference data
feature flags
Case-dependent
Needs caseId, already available from route params.
case detail
timeline
attachments
workflow transitions
Data-dependent
Needs data returned by another request.
assignee profile requires case.assigneeId
related entity requires case.relatedEntityId
Authorization-dependent
Needs permission context before exposing commands or fields.
available actions
restricted notes
redacted fields
A data graph gives you a performance and correctness map.
4. Component Tree Is the Wrong Default Graph
A component tree expresses rendering ownership.
A data graph expresses network dependencies.
They often differ.
Component tree:
Data graph:
If the component tree controls fetching, the network graph is an accident.
If the data graph controls fetching, the component tree can stay clean.
5. Query Composition Modes
There are five common modes.
Single aggregate query
useCaseDetailScreen(caseId)
Backend returns the complete screen data.
Parallel queries
useQueries({
queries: [
caseQuery(caseId),
timelineQuery(caseId),
permissionsQuery(caseId),
],
})
Independent requests start together.
Dependent queries
const caseQuery = useCase(caseId)
const assigneeQuery = useUser(caseQuery.data?.assigneeId, {
enabled: Boolean(caseQuery.data?.assigneeId),
})
Second query needs result of first.
Section-level progressive queries
Primary content loads first. Secondary sections load independently.
case detail: blocking
timeline: progressive
related cases: progressive
activity analytics: lazy
Server-side composition
Server Component, route loader, or BFF composes data before the client sees it.
client asks for screen
server fans out to services
client receives ready or streamed representation
Each mode has trade-offs.
6. Parallelize Everything That Is Truly Independent
If three requests only need route params and session context, they should not wait for each other.
Bad:
const caseQuery = useCase(caseId)
const timelineQuery = useTimeline(caseQuery.data?.id)
const attachmentsQuery = useAttachments(caseQuery.data?.id)
If caseId is already known, timeline and attachments do not need caseQuery.data.id.
Better:
const caseQuery = useCase(caseId)
const timelineQuery = useTimeline(caseId)
const attachmentsQuery = useAttachments(caseId)
Or:
const [caseQuery, timelineQuery, attachmentsQuery] = useQueries({
queries: [
caseQueryOptions(caseId),
timelineQueryOptions(caseId),
attachmentsQueryOptions(caseId),
],
})
Dependent queries are sometimes necessary. But many are accidental.
Rule:
Do not wait for data you already know.
7. Dependent Queries Are Latency Multipliers
A dependent query creates a serial chain.
A -> B -> C
If each request takes 300 ms, the chain takes about 900 ms before all data is ready.
A parallel graph:
A
B
C
can complete in about 300 ms plus overhead.
Dependent queries are legitimate when:
- B needs an id only known after A;
- B requires a server-computed cursor from A;
- B must wait for authorization returned by A;
- B's request is invalid without A's result;
- B depends on a version/ETag from A.
They are not legitimate when:
- B only needs route params;
- B can derive id from URL;
- B waits because hook is nested too deeply;
- B waits because a component was hidden behind a conditional;
- B waits because no one designed a screen-level data graph.
8. Flattening Accidental Dependencies
Consider:
function CasePage() {
const caseQuery = useCase(caseId)
if (!caseQuery.data) return <Loading />
return <Timeline caseId={caseQuery.data.id} />
}
function Timeline({ caseId }: { caseId: string }) {
const timelineQuery = useTimeline(caseId)
return <TimelineView query={timelineQuery} />
}
Timeline waits for CasePage to finish, even though caseId came from the route.
Flatten:
function CasePage() {
const caseQuery = useCase(caseId)
const timelineQuery = useTimeline(caseId)
return (
<CaseLayout
caseQuery={caseQuery}
timelineQuery={timelineQuery}
/>
)
}
Or use route-level loader/query prefetch.
The point is not to move every hook to the page forever. The point is to avoid accidental network ordering.
9. Route-Level Data Ownership
Routes are natural data boundaries because route params often define resource identity.
/cases/:caseId
The route knows caseId before rendering children.
Therefore the route can start primary queries early.
function CaseRoute() {
const { caseId } = useParams()
const caseQuery = useCase(caseId)
const permissionsQuery = useCasePermissions(caseId)
const timelineQuery = useTimelinePreview(caseId)
return (
<CaseRouteContext.Provider value={{ caseQuery, permissionsQuery, timelineQuery }}>
<CasePage />
</CaseRouteContext.Provider>
)
}
But avoid turning route context into a dumping ground.
Route-level ownership is best for:
- primary route resource;
- permission gate;
- navigation-critical data;
- shell/header data;
- prefetchable data;
- shared data used by many children.
Deep components can still fetch optional or isolated data.
10. Blocking vs Progressive Data
Not every data requirement should block first render.
Classify each requirement.
| Requirement | Blocking? | Why |
|---|---|---|
| case title/status | yes | primary identity of page |
| permission to view page | yes | security and UX gate |
| available actions | maybe | toolbar can skeleton/disable briefly |
| full timeline | no | section can stream or load progressively |
| attachments | no | secondary section |
| related cases | no | useful but not primary |
| audit metadata | maybe | depends on product context |
| reference dropdowns | no until form opens | lazy |
A screen with one global spinner for everything is often slower than necessary.
A screen with too many independent skeletons can feel chaotic.
Design the loading contract intentionally.
11. The Data Readiness State Machine
For composed data, isLoading is not enough.
Create a screen-level readiness model.
type CaseScreenState =
| { tag: 'blocking-loading' }
| { tag: 'not-found' }
| { tag: 'forbidden' }
| { tag: 'blocking-error'; error: AppError }
| {
tag: 'ready'
case: CaseDetail
permissions: CasePermissions
timeline: RemoteSection<TimelineEvent[]>
attachments: RemoteSection<Attachment[]>
}
type RemoteSection<T> =
| { tag: 'loading' }
| { tag: 'ready'; data: T; stale?: boolean }
| { tag: 'empty' }
| { tag: 'error'; error: AppError; previousData?: T }
Then map query results into this model.
The UI consumes a coherent product state, not raw query booleans scattered everywhere.
12. Composing Query Results into a View Model
A view model is a derived object optimized for rendering.
type CaseHeaderViewModel = {
title: string
statusLabel: string
assigneeName: string
canAssign: boolean
canClose: boolean
lastUpdatedText: string
}
Build it from server-state queries.
function buildCaseHeaderViewModel(input: {
case: CaseDetail
permissions: CasePermissions
assignee?: UserSummary
}): CaseHeaderViewModel {
return {
title: input.case.title,
statusLabel: formatStatus(input.case.status),
assigneeName: input.assignee?.displayName ?? 'Unassigned',
canAssign: input.permissions.actions.includes('assign'),
canClose: input.permissions.actions.includes('close'),
lastUpdatedText: formatRelativeTime(input.case.updatedAt),
}
}
Keep view models pure.
They should not fetch.
They should not mutate.
They should not read global state invisibly.
13. Avoid Copying Query Data Into Local State
Bad composition often looks like this:
const caseQuery = useCase(caseId)
const [caseData, setCaseData] = useState<CaseDetail | null>(null)
useEffect(() => {
if (caseQuery.data) setCaseData(caseQuery.data)
}, [caseQuery.data])
This creates a second replica.
Now you must answer:
- which one is fresh?
- which one updates after invalidation?
- which one participates in optimistic updates?
- which one resets on route change?
Use derived values instead.
const vm = caseQuery.data
? buildCaseViewModel(caseQuery.data)
: null
Local state is for client-owned drafts and interactions, not for mirroring server cache.
14. Batching and Backend Composition
Sometimes the right frontend composition is to stop composing on the frontend.
If a screen always needs five resources together, a backend-composed endpoint may be better.
GET /api/case-detail-screen/:caseId
Response:
type CaseDetailScreenResponse = {
case: CaseDetail
permissions: CasePermissions
transitions: WorkflowTransition[]
timelinePreview: TimelineEvent[]
attachmentsSummary: AttachmentsSummary
}
Benefits:
- fewer round trips;
- consistent authorization;
- server-side fanout can be closer to services;
- one versioned screen contract;
- simpler client readiness state;
- easier SSR/streaming.
Costs:
- endpoint can become screen-specific;
- backend coupling to UI needs;
- larger payloads if poorly designed;
- harder reuse across screens;
- cache invalidation may be coarser;
- backend team must own composition performance.
This is the Backend-for-Frontend trade-off.
Do not reject BFF endpoints because they are “not pure REST”. A user waits for a screen, not for your resource taxonomy.
15. When to Use a BFF or Aggregate Endpoint
Use server-side composition when:
- the screen needs many small resources;
- dependent queries create unavoidable latency;
- authorization rules must be applied consistently;
- data comes from multiple backend services;
- mobile or high-latency clients suffer from round trips;
- the response is strongly tied to one user journey;
- SSR/streaming needs server-controlled data orchestration;
- product requires one coherent error/loading contract.
Prefer client-side composition when:
- resources are independently cacheable;
- sections are optional/lazy;
- users navigate between views reusing the same data;
- backend composition would duplicate many variants;
- the data graph is shallow;
- endpoint ownership is unclear;
- independent refetching is valuable.
The decision is architectural, not ideological.
16. Data Composition Decision Tree
Use this decision tree during design reviews.
17. useQueries for Dynamic Parallelism
When the number of queries is dynamic, use a controlled parallel query pattern.
function useUsers(userIds: string[]) {
const uniqueIds = [...new Set(userIds)].sort()
return useQueries({
queries: uniqueIds.map((id) => ({
queryKey: userKeys.detail(id),
queryFn: ({ signal }) => userApi.getUser(id, { signal }),
staleTime: 5 * 60_000,
})),
})
}
But do not blindly fire 200 detail queries.
If the screen needs many users, ask for a batch endpoint.
GET /api/users?ids=1,2,3,4
or:
POST /api/users/batch-lookup
with an idempotent lookup body.
Dynamic parallelism is useful for small sets. It is not a substitute for bulk API design.
18. N+1 Requests in React
N+1 is not only a backend ORM problem.
Frontend N+1:
function Timeline({ events }: { events: TimelineEvent[] }) {
return events.map((event) => <TimelineRow key={event.id} actorId={event.actorId} />)
}
function TimelineRow({ actorId }: { actorId: string }) {
const actorQuery = useUser(actorId)
return <span>{actorQuery.data?.displayName}</span>
}
For 100 events, you might trigger dozens of user lookups.
Better options:
- include actor summaries in timeline response;
- batch actor lookup;
- prefetch unique actors at timeline level;
- use normalized entity cache intentionally;
- lazy-load actors only on hover/details.
Example batch composition:
function useTimelineWithActors(caseId: string) {
const timelineQuery = useTimeline(caseId)
const actorIds = useMemo(
() => [...new Set(timelineQuery.data?.map((e) => e.actorId) ?? [])].sort(),
[timelineQuery.data]
)
const actorsQuery = useUsersBatch(actorIds, {
enabled: actorIds.length > 0,
})
return { timelineQuery, actorsQuery }
}
Even this has a dependency. The better endpoint might return actor summaries directly.
19. Request Waterfall Patterns
Common accidental waterfalls:
Parent gate waterfall
if (!parentQuery.data) return <Spinner />
return <ChildThatFetches />
The child cannot start until parent finishes.
Conditional mount waterfall
{activeTab === 'timeline' && <Timeline />}
Timeline fetch waits until tab activation. That may be desired or not.
Derived id waterfall
const userId = caseQuery.data?.assigneeId
useUser(userId, { enabled: Boolean(userId) })
Legitimate if assignee id is unknown otherwise.
Permission waterfall
if (!permissionsQuery.data?.canViewTimeline) return null
return <Timeline />
May be necessary. But if server already enforces permissions, timeline can start and return 403/empty appropriately. Choose deliberately.
Lazy import plus fetch waterfall
const Timeline = lazy(() => import('./Timeline'))
If Timeline fetches internally, you wait for JS chunk, then network data.
Prefetch both when likely.
20. Prefetching as Composition
Prefetching is starting a query before the component needs it.
Examples:
- hover over case link;
- row visible in viewport;
- user opens command palette;
- route loader before navigation completes;
- after login, prefetch shell data;
- after viewing list, prefetch first detail page.
function CaseLink({ caseId, children }: Props) {
const queryClient = useQueryClient()
return (
<Link
to={`/cases/${caseId}`}
onMouseEnter={() => {
queryClient.prefetchQuery({
queryKey: caseKeys.detail(caseId),
queryFn: ({ signal }) => caseApi.getCase(caseId, { signal }),
staleTime: 30_000,
})
}}
>
{children}
</Link>
)
}
Prefetching needs good query keys. Otherwise the prefetched data is not the data the screen uses.
21. Route Transition Composition
During navigation, compose data around the route transition.
user clicks case row
prefetch case detail
prefetch permissions
navigate
render with warm cache
load secondary sections progressively
This is different from:
user clicks case row
navigate
mount page
page fetches case
case loaded
child fetches timeline
The second graph is slower because it waits for rendering to discover network needs.
A strong router/data layer makes data requirements explicit before render.
22. Error Composition
Multiple queries can fail differently.
A screen must decide which failures block the whole page.
Example:
| Query | Error | Screen behavior |
|---|---|---|
| case detail | 404 | show not found |
| case detail | 403 | show forbidden |
| permissions | 500 | show restricted fallback or blocking error |
| timeline | 500 | show timeline section error |
| attachments | 404 | treat as empty if contract allows |
| related cases | timeout | show retryable section error |
Do not collapse all errors into one global screen error.
Map errors by role in the screen contract.
function buildCaseScreenState(input: Queries): CaseScreenState {
if (input.case.error?.kind === 'not-found') return { tag: 'not-found' }
if (input.case.error?.kind === 'forbidden') return { tag: 'forbidden' }
if (input.case.error) return { tag: 'blocking-error', error: input.case.error }
if (!input.case.data) return { tag: 'blocking-loading' }
return {
tag: 'ready',
case: input.case.data,
permissions: input.permissions.data ?? defaultSafePermissions(),
timeline: sectionFromQuery(input.timeline),
attachments: sectionFromQuery(input.attachments),
}
}
Error composition is product design plus architecture.
23. Authorization Composition
Authorization is often both a data dependency and a rendering boundary.
Avoid this pattern:
if (canCloseCase) {
return <CloseButton />
}
when canCloseCase was guessed locally from role names.
Prefer server-provided permission decisions or allowed transitions.
type CasePermissions = {
canView: boolean
actions: Array<'assign' | 'close' | 'reopen' | 'comment'>
fieldVisibility: Record<string, 'visible' | 'redacted' | 'hidden'>
}
Then compose UI from that contract.
<CommandBar actions={permissions.actions} />
But do not make every section wait for a separate permission query if the backend can include permission metadata with the primary resource.
A useful API shape:
type CaseDetailResponse = {
data: CaseDetail
meta: {
permissions: CasePermissions
version: string
}
}
Security is still enforced server-side. Client permission data is for UX and safe rendering.
24. Reference Data Composition
Reference data includes:
- countries;
- enum labels;
- workflow states;
- queue lists;
- departments;
- static configuration;
- allowed dropdown options.
Reference data often has longer freshness windows.
useQuery({
queryKey: referenceKeys.countries(),
queryFn: referenceApi.getCountries,
staleTime: 24 * 60 * 60_000,
})
But not all reference data is global.
Queue list may be tenant-specific:
['tenants', tenantId, 'reference-data', 'queues']
Allowed workflow transitions may be case-specific:
['cases', 'detail', caseId, 'workflow-transitions']
Do not overgeneralize reference data.
Ask who owns it and what scopes it.
25. Forms and Data Composition
Forms combine server data, client draft state, validation contracts, and mutation lifecycle.
Example edit case form needs:
- case detail initial values;
- reference data for dropdowns;
- permission/field visibility;
- server validation schema or constraints;
- submit mutation;
- conflict version.
The form draft is not the query data.
const caseQuery = useCase(caseId)
const referenceQuery = useCaseFormReferenceData(tenantId)
const permissionsQuery = useCasePermissions(caseId)
const form = useForm({
values: caseQuery.data ? toCaseFormValues(caseQuery.data) : undefined,
})
Be explicit about reinitialization.
If server data refetches while the user is editing, should the form reset?
Usually no.
Instead:
- initialize once when data first arrives;
- keep dirty draft local;
- warn if server version changed;
- submit with
If-Matchor version field; - handle conflict.
Data composition for forms is also concurrency design.
26. Composing Mutations with Queries
A mutation affects the data graph.
Example: close case.
Before mutation:
case detail
case lists
workflow transitions
timeline
dashboard counts
After mutation:
- detail status changes;
- open list may remove the case;
- closed list may include it;
- transitions change;
- timeline gets closure event;
- counts change.
Mutation composition plan:
const closeCaseMutation = useMutation({
mutationFn: closeCase,
onSuccess: async (updatedCase) => {
queryClient.setQueryData(caseKeys.detail(updatedCase.id), updatedCase)
await Promise.all([
queryClient.invalidateQueries({ queryKey: caseKeys.lists() }),
queryClient.invalidateQueries({ queryKey: caseKeys.transitions(updatedCase.id) }),
queryClient.invalidateQueries({ queryKey: caseKeys.timeline(updatedCase.id) }),
queryClient.invalidateQueries({ queryKey: dashboardKeys.counts() }),
])
},
})
Do not let mutation components invent invalidation ad hoc.
Put mutation impact in the domain module.
27. Composing Data With Suspense
Suspense can make async rendering cleaner, but it does not remove dependency design.
Suspense boundary placement should follow the product loading contract.
<Suspense fallback={<CaseHeaderSkeleton />}>
<CaseHeader />
</Suspense>
<Suspense fallback={<TimelineSkeleton />}>
<Timeline />
</Suspense>
This allows independent sections to reveal independently.
But if each Suspense child starts fetching only after it renders, you can still create waterfalls.
Use preloading, route loaders, framework data APIs, or query prefetch to start critical work before Suspense boundaries are encountered.
Suspense is a rendering primitive, not a data graph planner.
28. Server Components and Data Composition
Server Components can move data composition to the server side.
Conceptually:
async function CasePage({ caseId }: { caseId: string }) {
const [caseData, timeline, permissions] = await Promise.all([
getCase(caseId),
getTimeline(caseId),
getPermissions(caseId),
])
return (
<CasePageView
case={caseData}
timeline={timeline}
permissions={permissions}
/>
)
}
This has benefits:
- server can access backend services directly;
- no browser CORS/token exposure for internal calls;
- lower client JavaScript burden;
- easier server-side parallelism;
- streaming can progressively reveal UI;
- initial HTML can contain useful content.
But client interactivity still needs careful boundaries.
If a Client Component later refetches the same data under a different key or contract, you get duplication.
Server composition and client cache composition must be aligned.
29. GraphQL Composition
GraphQL lets a client request a graph-shaped payload.
That can reduce overfetching and underfetching when the schema is well designed.
Example:
query CaseDetailScreen($caseId: ID!) {
case(id: $caseId) {
id
title
status
assignee {
id
displayName
}
permissions {
actions
}
timeline(limit: 20) {
id
type
createdAt
actor {
id
displayName
}
}
}
}
GraphQL does not automatically remove composition problems.
You still need:
- field ownership;
- error policy;
- pagination model;
- authorization model;
- normalized cache strategy;
- fragment discipline;
- schema evolution rules;
- backend resolver performance.
GraphQL moves composition into the query document and schema layer. It does not make dependency design optional.
30. REST Composition
REST-style APIs often expose resources separately.
GET /cases/:id
GET /cases/:id/timeline
GET /cases/:id/attachments
GET /users/:id
GET /cases/:id/permissions
This can be clean and cacheable.
But screens often need combinations.
Practical REST composition options:
Include parameter
GET /cases/:id?include=assignee,permissions,timelinePreview
Dedicated aggregate endpoint
GET /case-detail-screens/:id
Batch endpoint
POST /users/batch-lookup
Independent resource queries
GET /cases/:id
GET /cases/:id/timeline
The right choice depends on latency, cacheability, ownership, and reuse.
31. Avoid the “One Giant Hook” Trap
A screen hook can coordinate data.
function useCaseDetailScreen(caseId: string) {
const caseQuery = useCase(caseId)
const permissionsQuery = useCasePermissions(caseId)
const timelineQuery = useTimelinePreview(caseId)
return buildCaseScreenState({
caseQuery,
permissionsQuery,
timelineQuery,
})
}
This is useful.
But it can become a giant hook that knows every tab, modal, dropdown, and optional section.
Keep layers:
- route hook: blocking and shared data;
- section hooks: section-specific data;
- form hooks: form draft + reference data;
- mutation hooks: command lifecycle + invalidation;
- view model builders: pure composition.
A good screen hook coordinates. It does not become a second application framework.
32. Composition Boundaries
Use these boundaries deliberately.
Route boundary
Owns primary resource and authorization gate.
Layout boundary
Owns shell data shared across nested routes.
Section boundary
Owns optional data for a visible section.
Interaction boundary
Owns data fetched after user intent: modal open, hover, command palette.
Form boundary
Owns draft initialization, reference data, validation, mutation.
Server boundary
Owns secure composition, fanout, sensitive data filtering, SSR/RSC.
Cache boundary
Owns dedupe, freshness, invalidation, persistence.
Confusion happens when one boundary secretly performs another boundary's job.
33. Example: Case Detail Composition Plan
Implementation sketch:
function useCaseDetailRouteData(caseId: string) {
const caseQuery = useCase(caseId)
const permissionsQuery = useCasePermissions(caseId)
const timelinePreviewQuery = useTimelinePreview(caseId)
const attachmentsSummaryQuery = useAttachmentsSummary(caseId)
const assigneeId = caseQuery.data?.assigneeId
const assigneeQuery = useUserSummary(assigneeId, {
enabled: Boolean(assigneeId),
staleTime: 5 * 60_000,
})
return buildCaseDetailRouteState({
caseQuery,
permissionsQuery,
timelinePreviewQuery,
attachmentsSummaryQuery,
assigneeQuery,
})
}
Then rendering:
function CaseDetailRoute() {
const { caseId } = useRequiredParams(['caseId'])
const state = useCaseDetailRouteData(caseId)
if (state.tag === 'blocking-loading') return <CasePageSkeleton />
if (state.tag === 'not-found') return <NotFound />
if (state.tag === 'forbidden') return <Forbidden />
if (state.tag === 'blocking-error') return <RetryablePageError error={state.error} />
return <CaseDetailPage state={state} />
}
The page receives a coherent state.
34. Example: Search Page Composition
Search pages are different from detail pages.
They often involve:
- URL state;
- debounced draft state;
- list query;
- facets;
- saved views;
- selection state;
- bulk action permissions;
- export job mutation.
Composition plan:
URL params -> committed search identity -> list query
user input -> draft state -> debounce/submit -> URL update
list result -> selected ids validation
permissions -> available bulk actions
facets -> maybe query independently or from list response
Do not use raw input directly as server-state identity unless immediate search is intended.
function CaseSearchRoute() {
const params = useCaseSearchParamsFromUrl()
const listQuery = useCaseSearch(params)
const facetsQuery = useCaseFacets(params.withoutPagination())
const permissionsQuery = useBulkActionPermissions(params.tenantId)
return <CaseSearchPage state={buildCaseSearchState({ listQuery, facetsQuery, permissionsQuery })} />
}
For high-traffic search, consider server-side search sessions, saved query ids, or cursor contracts.
35. Example: Command Palette Composition
A command palette is interaction-triggered.
It should not fetch heavy data on app boot unless product requires it.
function CommandPalette() {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const debounced = useDebouncedValue(query, 150)
const resultsQuery = useCommandSearch(debounced, {
enabled: open && debounced.length >= 2,
})
return <CommandPaletteView open={open} query={query} resultsQuery={resultsQuery} />
}
Composition rules:
- open state is client state;
- typed text is draft state;
- debounced query is server-state identity;
- results are server state;
- command execution is mutation state.
36. Testing Composed Data
Test the data graph, not only components.
Important cases:
- independent queries start without waiting for each other;
- dependent query waits only for true dependency;
- primary resource 404 maps to page not found;
- secondary section failure does not block page;
- mutation invalidates all affected keys;
- URL params produce expected query key;
- user/tenant switch clears or scopes cache;
- stale previous data is handled intentionally;
- loading states match product contract;
- slow secondary data does not hide primary content.
With MSW or equivalent, control response order.
server.use(
http.get('/api/cases/:id', async () => {
await delay(300)
return HttpResponse.json(caseFixture)
}),
http.get('/api/cases/:id/timeline', async () => {
await delay(50)
return HttpResponse.json(timelineFixture)
})
)
Then verify timeline request did not wait for case detail if it only needed caseId.
37. Observability for Data Composition
Production observability should reveal the data graph.
Log or measure:
- route id;
- query key hash/name;
- request start/end;
- cache hit/miss;
- stale vs fresh status;
- waterfall depth;
- blocking data duration;
- section data duration;
- mutation invalidation fanout;
- error role: blocking vs section;
- retry count;
- abort/cancel reason.
A useful metric:
route_data_ready_ms
Another:
secondary_section_ready_ms{section="timeline"}
And:
client_request_waterfall_depth{route="case-detail"}
If you cannot see the waterfall, you cannot improve it reliably.
38. Review Checklist
For every screen, ask:
- What is the primary route resource?
- Which data blocks first useful render?
- Which data can load progressively?
- Which data is independent and can start immediately?
- Which dependencies are truly data-dependent?
- Are any dependencies accidental because of component nesting?
- Are query keys canonical and shared with prefetch/loaders?
- Should the backend compose this data instead?
- Are authorization decisions included at the right boundary?
- Are secondary errors isolated?
- Are mutation impacts mapped to the data graph?
- Is form draft state separated from server cache?
- Is URL state canonicalized into query identity?
- Can tests control response ordering and verify no waterfall?
- Can observability show blocking vs secondary readiness?
If you cannot answer these, the data graph is implicit.
Implicit data graphs become production mysteries.
39. Design Rules
- A screen is a data dependency graph before it is a component tree.
- Route params should start route-critical queries early.
- Do not wait for data you already know.
- Use dependent queries only for true dependencies.
- Compose loading and error states by product role.
- Use query keys consistently across fetch, prefetch, loader, and mutation.
- Avoid frontend N+1; batch or include when needed.
- Consider BFF/aggregate endpoints when round trips dominate.
- Keep form drafts separate from server-state replicas.
- Suspense improves rendering composition, not data graph design by itself.
- Server Components can move composition server-side, but boundaries still matter.
- Mutation impact belongs to the domain data graph, not random buttons.
- Test response ordering to expose accidental waterfalls.
- Instrument route data readiness in production.
40. What You Should Now Be Able To Do
After this part, you should be able to:
- model a screen as a data dependency graph;
- distinguish component requirements from data requirements;
- decide what blocks render and what can be progressive;
- flatten accidental waterfalls;
- use parallel and dependent queries deliberately;
- identify frontend N+1 request patterns;
- choose between client composition and backend composition;
- build screen-level readiness models and view models;
- compose authorization, reference data, forms, and mutations safely;
- test and observe composed data behavior.
This closes Phase 3: React Rendering Model Meets Remote Data.
In the next part, we enter Phase 4 and move into server-state engines in detail, starting with Query Client Architecture.
References
- TanStack Query React Docs — Parallel Queries: https://tanstack.com/query/v5/docs/framework/react/guides/parallel-queries
- TanStack Query React Docs — Dependent Queries: https://tanstack.com/query/v5/docs/framework/react/guides/dependent-queries
- TanStack Query React Docs — Query Keys: https://tanstack.com/query/v5/docs/framework/react/guides/query-keys
- TanStack Query React Docs — Prefetching & Router Integration: https://tanstack.com/query/v5/docs/framework/react/guides/prefetching
- React Docs — Server Components: https://react.dev/reference/rsc/server-components
- React Docs — Suspense: https://react.dev/reference/react/Suspense
You just completed lesson 24 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.