Loading Related Data
Routier has no declared relations and no .include(). A post's author is just another row, found by its key. This guide shows how to load related rows with the query API: groupJoin for a nested shape, join for flat pairs, two live queries for a UI that stays current, and a view for a shape you want stored.
Quick navigation
- When to use what
- The example schemas
- A nested shape with groupJoin
- Flat pairs with a join
- Live related data in React
- Live related data in Vue
- What
.foreignKey()does today - Notes
When to use what
| You want | Use |
|---|---|
Each parent with its children as an array, such as a user with posts[] | groupJoin |
| One row per parent and child pair, such as a post list showing each author's name | A join (join or leftJoin) |
| The nested shape in a component, refreshed when either collection changes | Two live queries, combined in useMemo or computed |
| A denormalized shape stored and queried like a collection | A view derived from both collections |
The example schemas
Every snippet below uses these two collections. authorId holds a user's id:
import { InferType, s } from "@routier/core/schema";
import { DataStore } from "@routier/datastore";
import { MemoryPlugin } from "@routier/memory-plugin";
export const userSchema = s
.define("users", {
id: s.string().key().identity(),
name: s.string(),
})
.compile();
export const postSchema = s
.define("posts", {
id: s.string().key().identity(),
authorId: s.string().foreignKey(userSchema, "id"),
title: s.string(),
})
.compile();
export type User = InferType<typeof userSchema>;
export type Post = InferType<typeof postSchema>;
export class BlogStore extends DataStore {
users = this.collection(userSchema).proxy().create();
posts = this.collection(postSchema).proxy().create();
constructor() {
super(new MemoryPlugin("blog"));
}
}
export const store = new BlogStore();A nested shape with groupJoin
groupJoin returns one [user, posts] tuple per user, where posts is an array of that user's posts. Map each tuple to a plain object to get the nested shape:
import type { UserWithPosts } from "./attach-posts";
import type { BlogStore } from "./store";
export const loadUsersWithPosts = (store: BlogStore): Promise<UserWithPosts[]> =>
store.users
.sort((u) => u.name)
.groupJoin((s) => s.posts, (u) => u.id, (p) => p.authorId)
.map(([user, posts]) => ({ ...user, posts }))
.toArrayAsync();Given Ada with two posts, Lin with one, and Max with none, it returns:
[
{ id: "u1", name: "Ada", posts: [{ id: "p1", authorId: "u1", title: "B post" }, { id: "p2", authorId: "u1", title: "A post" }] },
{ id: "u2", name: "Lin", posts: [{ id: "p3", authorId: "u2", title: "C post" }] },
{ id: "u3", name: "Max", posts: [] },
]How it behaves:
- Every user appears once. A user with no posts gets
posts: []. A post whose author isn't among the users is not returned. - Operators after
groupJoinwork on the groups.countcounts users, andtake(10)returns 10 users with all their posts. Awhere,sort, ortakebeforegroupJoinapplies to the user rows. - Posts inside a group have no guaranteed order.
sortorders the users. If the posts need an order, sort eachpostsarray after the query returns. - The result is read-only. Neither the user fields nor the posts are change-tracked. See Notes.
groupJoin takes the same arguments as join, including a collection passed directly for a cross-store join. See the Joins page for key rules and cost.
Flat pairs with a join
A join returns one [post, user] tuple for every matching pair. It suits lists where each row needs a field or two from the other side:
import type { BlogStore } from "./store";
export const listPostsWithAuthors = (store: BlogStore) =>
store.posts
.join((s) => s.users, (p) => p.authorId, (u) => u.id)
.sort(([p]) => p.title)
.toArrayAsync();join drops posts whose author doesn't exist. leftJoin keeps them, paired with undefined. A user with three posts appears in three tuples, so use groupJoin when you want one entry per user. Key rules, filtering, and cost are covered on the Joins page.
Live related data in React
groupJoin and join have no subscribe, so they can't drive a live query. A live nested shape uses two live queries instead, one per collection, and groups them in the component. Either collection changing produces a new result, and the component re-renders.
The grouping is a small helper typed from the schemas, so the compiler checks authorId and id against the real entity types:
import type { Post, User } from "./store";
export type UserWithPosts = User & { posts: Post[] };
export const groupPostsByAuthor = (posts: Post[]): Map<string, Post[]> => {
const groups = new Map<string, Post[]>();
for (const post of posts) {
const group = groups.get(post.authorId);
if (group == null) {
groups.set(post.authorId, [post]);
} else {
group.push(post);
}
}
return groups;
};
export const attachPosts = (users: User[], posts: Post[]): UserWithPosts[] => {
const postsByAuthor = groupPostsByAuthor(posts);
return users.map((user) => ({ ...user, posts: postsByAuthor.get(user.id) ?? [] }));
};The hook reads the users, then reads only their posts by passing the ids as a parameter. Query expressions are parsed, not run as closures, so write x.ids.includes(p.authorId) with { ids }, not a closed-over ids (see Filtering):
import { useMemo } from "react";
import { useQuery } from "@routier/react";
import { attachPosts, type UserWithPosts } from "../attach-posts";
import { store, type Post, type User } from "../store";
export const useUsersWithPosts = (): UserWithPosts[] | undefined => {
const users = useQuery<User[]>((callback) => store.users.subscribe().sort((u) => u.name).toArray(callback), []);
const ids = users.status === "success" ? users.data.map((user) => user.id) : [];
const posts = useQuery<Post[]>(
(callback) =>
store.posts
.subscribe()
.where(([p, x]) => x.ids.includes(p.authorId), { ids })
.sort((p) => p.title)
.toArray(callback),
[ids.join()],
);
return useMemo(
() => (users.status === "success" && posts.status === "success" ? attachPosts(users.data, posts.data) : undefined),
[users, posts],
);
};import { useUsersWithPosts } from "./useUsersWithPosts";
export function UsersWithPosts() {
const users = useUsersWithPosts();
if (users == null) {
return null;
}
return (
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} ({user.posts.length})
<ul>
{user.posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</li>
))}
</ul>
);
}How it behaves:
- Adding, editing, or removing a post updates the posts query, and
useMemoregroups. - Adding a user updates the users query. The id list changes, so the posts query resubscribes for the new set of parents.
- The posts dependency is
ids.join(), notusers. Editing a user's name produces new user data but the same ids, so the posts query keeps its subscription. - The hook returns
undefineduntil both queries succeed. When the parent ids change, the posts query briefly returns topending, and so does the combined result.
See React hooks for useQuery itself.
Live related data in Vue
@routier/vue works the same way with computed. useQuery tracks the refs its query reads, so reading ids.value inside the posts query is enough to resubscribe when the parents change:
import { computed, type ComputedRef } from "vue";
import { useQuery } from "@routier/vue";
import { attachPosts, type UserWithPosts } from "../attach-posts";
import { store, type Post, type User } from "../store";
const sameIds = (a: string[], b: string[]): boolean => a.length === b.length && a.every((id, i) => id === b[i]);
export const useUsersWithPosts = (): ComputedRef<UserWithPosts[] | undefined> => {
const users = useQuery<User[]>((callback) => store.users.subscribe().sort((u) => u.name).toArray(callback));
const ids = computed<string[]>((previous) => {
const next = users.value.status === "success" ? users.value.data.map((user) => user.id) : [];
return previous != null && sameIds(previous, next) ? previous : next;
});
const posts = useQuery<Post[]>((callback) =>
store.posts
.subscribe()
.where(([p, x]) => x.ids.includes(p.authorId), { ids: ids.value })
.sort((p) => p.title)
.toArray(callback),
);
return computed(() =>
users.value.status === "success" && posts.value.status === "success"
? attachPosts(users.value.data, posts.value.data)
: undefined,
);
};The ids computed returns its previous array when the ids haven't changed. Without that, every users update would produce a new array and resubscribe the posts query. Reading the previous value in a computed getter needs Vue 3.4 or later.
Use it in a component like any other computed ref:
<script setup lang="ts">
import { useUsersWithPosts } from "./useUsersWithPosts";
const users = useUsersWithPosts();
</script>
<template>
<ul v-if="users">
<li v-for="user in users" :key="user.id">
{{ user.name }} ({{ user.posts.length }})
</li>
</ul>
</template>See Vue for useQuery itself.
What .foreignKey() does today
s.string().foreignKey(userSchema, "id") records that authorId refers to a user's id. It is metadata only:
- It doesn't load, join, or attach anything.
groupJoinand the other recipes take their keys from the selectors you pass, not from the schema. - It doesn't cascade deletes or check that the referenced row exists.
- It can't be combined with
.index()on the same property. If the child side of a join needs an index on a backend that uses one, declare.index()instead.
Use it to document the relationship in the schema, where anyone reading the code finds it.
Notes
- Join and group join results are read-only. Neither the tuples nor the objects you map them to are change-tracked, so assigning to
user.nameorposts[0].titlesaves nothing. To edit a row, read it through its own collection. - In the live recipe, the posts are tracked.
attachPostscopies each user but keeps the posts the posts query returned, so an edit to one of those posts is saved by the nextsaveChangesAsync. To avoid surprises, edit through the collection there too. - Order is undefined without
sort. This applies to joins, to the posts inside a group, and to plain reads. Sort when order matters. - Bound the parents. Every parent brings all of its children. Page the parents with
sortandtake, either beforegroupJoinor after it, wheretakecounts parents. In the live recipe, the id list does the same job. Reading a child collection with no filter, such asstore.posts.toArrayAsync()to group everything, reads every row on every load, and on every change when it's live.
Related
- Joins:
join,leftJoin,groupJoin, key rules, and cost - Live Queries: subscriptions and when they fire
- Views: store a derived, denormalized shape
- Schema API:
.foreignKey()and the other modifiers