Skip to content

Commit 436fae7

Browse files
kevin-dpclaude
andcommitted
Docs for includes (#1317)
* Document includes subqueries in live queries guide Add an Includes section to docs/guides/live-queries.md covering: - Basic includes with correlation conditions - Additional filters including parent-referencing WHERE clauses - Ordering and limiting per parent - toArray() for plain array results - Aggregates per parent - Nested includes Also add packages/db/INCLUDES.md with architectural documentation and update the V2 roadmap to reflect implemented features. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Improve includes docs: use concrete examples instead of generic "parent/child" terminology Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Document how to use includes with React via subcomponents with useLiveQuery Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add React test for includes: child collection subscription via useLiveQuery Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bb09eb1 commit 436fae7

File tree

2 files changed

+328
-3
lines changed

2 files changed

+328
-3
lines changed

docs/guides/live-queries.md

Lines changed: 220 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ The result types are automatically inferred from your query structure, providing
3838
- [Select Projections](#select)
3939
- [Joins](#joins)
4040
- [Subqueries](#subqueries)
41+
- [Includes](#includes)
4142
- [groupBy and Aggregations](#groupby-and-aggregations)
4243
- [findOne](#findone)
4344
- [Distinct](#distinct)
@@ -748,9 +749,8 @@ A `join` without a `select` will return row objects that are namespaced with the
748749

749750
The result type of a join will take into account the join type, with the optionality of the joined fields being determined by the join type.
750751

751-
> [!NOTE]
752-
> We are working on an `include` system that will enable joins that project to a hierarchical object. For example an `issue` row could have a `comments` property that is an array of `comment` rows.
753-
> See [this issue](https://github.com/TanStack/db/issues/288) for more details.
752+
> [!TIP]
753+
> If you need hierarchical results instead of flat joined rows (e.g., each project with its nested issues), see [Includes](#includes) below.
754754
755755
### Method Signature
756756

@@ -1041,6 +1041,223 @@ const topUsers = createCollection(liveQueryCollectionOptions({
10411041
}))
10421042
```
10431043

1044+
## Includes
1045+
1046+
Includes let you nest subqueries inside `.select()` to produce hierarchical results. Instead of joins that flatten 1:N relationships into repeated rows, each parent row gets a nested collection of its related items.
1047+
1048+
```ts
1049+
import { createLiveQueryCollection, eq } from '@tanstack/db'
1050+
1051+
const projectsWithIssues = createLiveQueryCollection((q) =>
1052+
q.from({ p: projectsCollection }).select(({ p }) => ({
1053+
id: p.id,
1054+
name: p.name,
1055+
issues: q
1056+
.from({ i: issuesCollection })
1057+
.where(({ i }) => eq(i.projectId, p.id))
1058+
.select(({ i }) => ({
1059+
id: i.id,
1060+
title: i.title,
1061+
})),
1062+
})),
1063+
)
1064+
```
1065+
1066+
Each project's `issues` field is a live `Collection` that updates incrementally as the underlying data changes.
1067+
1068+
### Correlation Condition
1069+
1070+
The child query's `.where()` must contain an `eq()` that links a child field to a parent field — this is the **correlation condition**. It tells the system how children relate to parents.
1071+
1072+
```ts
1073+
// The correlation condition: links issues to their parent project
1074+
.where(({ i }) => eq(i.projectId, p.id))
1075+
```
1076+
1077+
The correlation condition can appear as a standalone `.where()`, or inside an `and()`:
1078+
1079+
```ts
1080+
// Also valid — correlation is extracted from inside and()
1081+
.where(({ i }) => and(eq(i.projectId, p.id), eq(i.status, 'open')))
1082+
```
1083+
1084+
The correlation field does not need to be included in the parent's `.select()`.
1085+
1086+
### Additional Filters
1087+
1088+
Child queries support additional `.where()` clauses beyond the correlation condition, including filters that reference parent fields:
1089+
1090+
```ts
1091+
q.from({ p: projectsCollection }).select(({ p }) => ({
1092+
id: p.id,
1093+
name: p.name,
1094+
issues: q
1095+
.from({ i: issuesCollection })
1096+
.where(({ i }) => eq(i.projectId, p.id)) // correlation
1097+
.where(({ i }) => eq(i.createdBy, p.createdBy)) // parent-referencing filter
1098+
.where(({ i }) => eq(i.status, 'open')) // pure child filter
1099+
.select(({ i }) => ({
1100+
id: i.id,
1101+
title: i.title,
1102+
})),
1103+
}))
1104+
```
1105+
1106+
Parent-referencing filters are fully reactive — if a parent's field changes, the child results update automatically.
1107+
1108+
### Ordering and Limiting
1109+
1110+
Child queries support `.orderBy()` and `.limit()`, applied per parent:
1111+
1112+
```ts
1113+
q.from({ p: projectsCollection }).select(({ p }) => ({
1114+
id: p.id,
1115+
name: p.name,
1116+
issues: q
1117+
.from({ i: issuesCollection })
1118+
.where(({ i }) => eq(i.projectId, p.id))
1119+
.orderBy(({ i }) => i.createdAt, 'desc')
1120+
.limit(5)
1121+
.select(({ i }) => ({
1122+
id: i.id,
1123+
title: i.title,
1124+
})),
1125+
}))
1126+
```
1127+
1128+
Each project gets its own top-5 issues, not 5 issues shared across all projects.
1129+
1130+
### toArray
1131+
1132+
By default, each child result is a live `Collection`. If you want a plain array instead, wrap the child query with `toArray()`:
1133+
1134+
```ts
1135+
import { createLiveQueryCollection, eq, toArray } from '@tanstack/db'
1136+
1137+
const projectsWithIssues = createLiveQueryCollection((q) =>
1138+
q.from({ p: projectsCollection }).select(({ p }) => ({
1139+
id: p.id,
1140+
name: p.name,
1141+
issues: toArray(
1142+
q
1143+
.from({ i: issuesCollection })
1144+
.where(({ i }) => eq(i.projectId, p.id))
1145+
.select(({ i }) => ({
1146+
id: i.id,
1147+
title: i.title,
1148+
})),
1149+
),
1150+
})),
1151+
)
1152+
```
1153+
1154+
With `toArray()`, the project row is re-emitted whenever its issues change. Without it, the child `Collection` updates independently.
1155+
1156+
### Aggregates
1157+
1158+
You can use aggregate functions in child queries. Aggregates are computed per parent:
1159+
1160+
```ts
1161+
import { createLiveQueryCollection, eq, count } from '@tanstack/db'
1162+
1163+
const projectsWithCounts = createLiveQueryCollection((q) =>
1164+
q.from({ p: projectsCollection }).select(({ p }) => ({
1165+
id: p.id,
1166+
name: p.name,
1167+
issueCount: q
1168+
.from({ i: issuesCollection })
1169+
.where(({ i }) => eq(i.projectId, p.id))
1170+
.select(({ i }) => ({ total: count(i.id) })),
1171+
})),
1172+
)
1173+
```
1174+
1175+
Each project gets its own count. The count updates reactively as issues are added or removed.
1176+
1177+
### Nested Includes
1178+
1179+
Includes nest arbitrarily. For example, projects can include issues, which include comments:
1180+
1181+
```ts
1182+
const tree = createLiveQueryCollection((q) =>
1183+
q.from({ p: projectsCollection }).select(({ p }) => ({
1184+
id: p.id,
1185+
name: p.name,
1186+
issues: q
1187+
.from({ i: issuesCollection })
1188+
.where(({ i }) => eq(i.projectId, p.id))
1189+
.select(({ i }) => ({
1190+
id: i.id,
1191+
title: i.title,
1192+
comments: q
1193+
.from({ c: commentsCollection })
1194+
.where(({ c }) => eq(c.issueId, i.id))
1195+
.select(({ c }) => ({
1196+
id: c.id,
1197+
body: c.body,
1198+
})),
1199+
})),
1200+
})),
1201+
)
1202+
```
1203+
1204+
Each level updates independently and incrementally — adding a comment to an issue does not re-process other issues or projects.
1205+
1206+
### Using Includes with React
1207+
1208+
When using includes with React, each child `Collection` needs its own `useLiveQuery` subscription to receive reactive updates. Pass the child collection to a subcomponent that calls `useLiveQuery(childCollection)`:
1209+
1210+
```tsx
1211+
import { useLiveQuery } from '@tanstack/react-db'
1212+
import { eq } from '@tanstack/db'
1213+
1214+
function ProjectList() {
1215+
const { data: projects } = useLiveQuery((q) =>
1216+
q.from({ p: projectsCollection }).select(({ p }) => ({
1217+
id: p.id,
1218+
name: p.name,
1219+
issues: q
1220+
.from({ i: issuesCollection })
1221+
.where(({ i }) => eq(i.projectId, p.id))
1222+
.select(({ i }) => ({
1223+
id: i.id,
1224+
title: i.title,
1225+
})),
1226+
})),
1227+
)
1228+
1229+
return (
1230+
<ul>
1231+
{projects.map((project) => (
1232+
<li key={project.id}>
1233+
{project.name}
1234+
{/* Pass the child collection to a subcomponent */}
1235+
<IssueList issuesCollection={project.issues} />
1236+
</li>
1237+
))}
1238+
</ul>
1239+
)
1240+
}
1241+
1242+
function IssueList({ issuesCollection }) {
1243+
// Subscribe to the child collection for reactive updates
1244+
const { data: issues } = useLiveQuery(issuesCollection)
1245+
1246+
return (
1247+
<ul>
1248+
{issues.map((issue) => (
1249+
<li key={issue.id}>{issue.title}</li>
1250+
))}
1251+
</ul>
1252+
)
1253+
}
1254+
```
1255+
1256+
Each `IssueList` component independently subscribes to its project's issues. When an issue is added or removed, only the affected `IssueList` re-renders — the parent `ProjectList` does not.
1257+
1258+
> [!NOTE]
1259+
> You must pass the child collection to a subcomponent and subscribe with `useLiveQuery`. Reading `project.issues` directly in the parent without subscribing will give you the collection object, but the component won't re-render when the child data changes.
1260+
10441261
## groupBy and Aggregations
10451262

10461263
Use `groupBy` to group your data and apply aggregate functions. When you use aggregates in `select` without `groupBy`, the entire result set is treated as a single group.

packages/react-db/tests/useLiveQuery.test.tsx

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2481,4 +2481,112 @@ describe(`Query Collections`, () => {
24812481
})
24822482
})
24832483
})
2484+
2485+
describe(`includes subqueries`, () => {
2486+
type Project = {
2487+
id: string
2488+
name: string
2489+
}
2490+
2491+
type ProjectIssue = {
2492+
id: string
2493+
title: string
2494+
projectId: string
2495+
}
2496+
2497+
const sampleProjects: Array<Project> = [
2498+
{ id: `p1`, name: `Alpha` },
2499+
{ id: `p2`, name: `Beta` },
2500+
]
2501+
2502+
const sampleProjectIssues: Array<ProjectIssue> = [
2503+
{ id: `i1`, title: `Bug in Alpha`, projectId: `p1` },
2504+
{ id: `i2`, title: `Feature for Alpha`, projectId: `p1` },
2505+
{ id: `i3`, title: `Bug in Beta`, projectId: `p2` },
2506+
]
2507+
2508+
it(`should render includes results and reactively update child collections`, async () => {
2509+
const projectsCollection = createCollection(
2510+
mockSyncCollectionOptions<Project>({
2511+
id: `includes-react-projects`,
2512+
getKey: (p) => p.id,
2513+
initialData: sampleProjects,
2514+
}),
2515+
)
2516+
2517+
const issuesCollection = createCollection(
2518+
mockSyncCollectionOptions<ProjectIssue>({
2519+
id: `includes-react-issues`,
2520+
getKey: (i) => i.id,
2521+
initialData: sampleProjectIssues,
2522+
}),
2523+
)
2524+
2525+
// Parent hook: runs includes query that produces child Collections
2526+
const { result: parentResult } = renderHook(() =>
2527+
useLiveQuery((q) =>
2528+
q.from({ p: projectsCollection }).select(({ p }) => ({
2529+
id: p.id,
2530+
name: p.name,
2531+
issues: q
2532+
.from({ i: issuesCollection })
2533+
.where(({ i }) => eq(i.projectId, p.id))
2534+
.select(({ i }) => ({
2535+
id: i.id,
2536+
title: i.title,
2537+
})),
2538+
})),
2539+
),
2540+
)
2541+
2542+
// Wait for parent to be ready
2543+
await waitFor(() => {
2544+
expect(parentResult.current.data).toHaveLength(2)
2545+
})
2546+
2547+
const alphaProject = parentResult.current.data.find(
2548+
(p: any) => p.id === `p1`,
2549+
)!
2550+
expect(alphaProject.name).toBe(`Alpha`)
2551+
2552+
// Child hook: subscribes to the child Collection from the parent row,
2553+
// simulating a subcomponent using useLiveQuery(project.issues)
2554+
const { result: childResult } = renderHook(() =>
2555+
useLiveQuery((alphaProject as any).issues),
2556+
)
2557+
2558+
await waitFor(() => {
2559+
expect(childResult.current.data).toHaveLength(2)
2560+
})
2561+
2562+
expect(childResult.current.data).toEqual(
2563+
expect.arrayContaining([
2564+
expect.objectContaining({ id: `i1`, title: `Bug in Alpha` }),
2565+
expect.objectContaining({ id: `i2`, title: `Feature for Alpha` }),
2566+
]),
2567+
)
2568+
2569+
// Add a new issue to Alpha — the child hook should reactively update
2570+
act(() => {
2571+
issuesCollection.utils.begin()
2572+
issuesCollection.utils.write({
2573+
type: `insert`,
2574+
value: { id: `i4`, title: `New Alpha issue`, projectId: `p1` },
2575+
})
2576+
issuesCollection.utils.commit()
2577+
})
2578+
2579+
await waitFor(() => {
2580+
expect(childResult.current.data).toHaveLength(3)
2581+
})
2582+
2583+
expect(childResult.current.data).toEqual(
2584+
expect.arrayContaining([
2585+
expect.objectContaining({ id: `i1`, title: `Bug in Alpha` }),
2586+
expect.objectContaining({ id: `i2`, title: `Feature for Alpha` }),
2587+
expect.objectContaining({ id: `i4`, title: `New Alpha issue` }),
2588+
]),
2589+
)
2590+
})
2591+
})
24842592
})

0 commit comments

Comments
 (0)