Fragment Colocation
Fragment colocation is a pattern where GraphQL fragments are defined alongside the components that use them. soda-gql provides powerful tools for this pattern with type-safe data projection.
How soda-gql Colocation Differs from GraphQL
Standard GraphQL colocation (e.g., with Relay or Apollo) uses fragment spreads:
# UserCard.graphql
fragment UserCardFragment on User {
id
name
avatarUrl
}
# Parent query
query UserPage($id: ID!) {
user(id: $id) {
...UserCardFragment
...PostListFragment
}
}
soda-gql takes a different approach:
| Aspect | Traditional GraphQL | soda-gql |
|---|
| Composition | Fragment spread ...Name | $colocate({ label: fragment.spread() }) |
| Labeling | Implicit by fragment name | Explicit labels for each slice |
| Data Extraction | Manual traversal | createExecutionResultParser with projections |
| Error Handling | Manual per-fragment | Built-in SlicedExecutionResult with Success/Error/Empty states |
| Type Safety | Requires codegen | Full inference with Projection types |
TIP
The $colocate helper with explicit labels enables soda-gql to route data and errors to the correct fragment handlers automatically.
The Colocation Workflow
Step 1: Define Component Fragment with Projection
Each component defines its fragment and attaches a projection for data extraction:
// UserCard.tsx
import { gql } from "@/graphql-system";
import { createProjectionAttachment } from "@soda-gql/colocation-tools";
export const userCardFragment = gql
.default(({ fragment, $var }) =>
fragment.Query({
variables: { ...$var("userId").ID("!") },
fields: ({ f, $ }) => ({
...f.user({ id: $.userId })(({ f }) => ({
...f.id(),
...f.name(),
...f.avatarUrl(),
})),
}),
}),
)
.attach(
createProjectionAttachment({
paths: ["$.user"],
handle: (result) => {
if (result.isError()) {
return { error: result.error, user: null };
}
if (result.isEmpty()) {
return { error: null, user: null };
}
const [user] = result.unwrap();
return { error: null, user };
},
}),
);
// Component using the fragment data
export function UserCard({
data,
}: {
data: ReturnType<typeof userCardFragment.projection.projector>;
}) {
if (data.error) return <ErrorDisplay error={data.error} />;
if (!data.user) return <Loading />;
return <div>{data.user.name}</div>;
}
Step 2: Compose Fragments in Parent Operation
Use $colocate to combine multiple fragments with explicit labels:
// UserPage.tsx
import { gql } from "@/graphql-system";
import { userCardFragment } from "./UserCard";
import { postListFragment } from "./PostList";
export const userPageQuery = gql.default(({ query, $var, $colocate }) =>
query.operation({
name: "UserPage",
variables: { ...$var("userId").ID("!") },
fields: ({ $ }) => $colocate({
userCard: userCardFragment.spread({ userId: $.userId }),
postList: postListFragment.spread({ userId: $.userId }),
}),
}),
);
Step 3: Create Result Parser
Create a parser that routes data to each fragment's projection:
import { createExecutionResultParser } from "@soda-gql/colocation-tools";
const parseUserPageResult = createExecutionResultParser({
userCard: userCardFragment,
postList: postListFragment,
});
Step 4: Execute and Distribute Data
Execute the query and distribute results to components:
// In your page component
async function UserPage({ userId }: { userId: string }) {
const response = await graphqlClient({
document: userPageQuery.document,
variables: { userId },
});
const { userCard, postList } = parseUserPageResult(response);
return (
<div>
<UserCard data={userCard} />
<PostList data={postList} />
</div>
);
}
Projections
Projections define how to extract and transform data from execution results.
createProjectionAttachment
Attach a projection directly to a fragment:
import { createProjectionAttachment } from "@soda-gql/colocation-tools";
const fragment = gql
.default(({ fragment }) =>
fragment.Query({
fields: ({ f }) => ({
...f.user({ id: "1" })(({ f }) => ({
...f.id(),
...f.name(),
})),
}),
}),
)
.attach(
createProjectionAttachment({
paths: ["$.user"],
handle: (result) => {
// Transform the sliced result (receives tuple of values for each path)
if (result.isError()) return { error: result.error };
if (result.isEmpty()) return { data: null };
const [user] = result.unwrap();
return { data: user };
},
}),
);
Projection Paths
Paths specify which parts of the result to extract:
paths: ["$.user"] // Extract user field
paths: ["$.user.posts"] // Extract nested field
paths: ["$.user", "$.meta"] // Extract multiple fields
Path format:
- Always start with
$.
- Use dot notation for nested fields
- The first path segment maps to the
$colocate label
SlicedExecutionResult
The handle function receives a SlicedExecutionResult, which can be one of three states:
Success
Data was extracted successfully:
handle: (result) => {
if (result.isSuccess()) {
const data = result.unwrap(); // Get typed data
return { data };
}
// ...
}
Error
An error occurred (GraphQL error, network error, or parse error):
handle: (result) => {
if (result.isError()) {
const error = result.error; // NormalizedError
return { error, data: null };
}
// ...
}
Empty
No data or error (null result):
handle: (result) => {
if (result.isEmpty()) {
return { data: null };
}
// ...
}
Safe Unwrapping
Use safeUnwrap for convenient error handling:
handle: (result) => {
const { data, error } = result.safeUnwrap((user) => ({
formatted: user.name.toUpperCase(),
}));
return { data, error };
}
Error Routing
The createExecutionResultParser automatically routes GraphQL errors to the correct fragments based on the error's path:
// If the GraphQL response contains:
{
"data": { "userCard_user": null },
"errors": [{
"message": "User not found",
"path": ["userCard_user"]
}]
}
// The error is routed to the userCard projection:
const { userCard } = parseResult(response);
userCard.error; // Contains the "User not found" error
Complete Example
// fragments/UserCard.ts
import { gql } from "@/graphql-system";
import { createProjectionAttachment } from "@soda-gql/colocation-tools";
export const userCardFragment = gql
.default(({ fragment, $var }) =>
fragment.Query({
variables: { ...$var("id").ID("!") },
fields: ({ f, $ }) => ({
...f.user({ id: $.id })(({ f }) => ({
...f.id(),
...f.name(),
...f.email(),
})),
}),
}),
)
.attach(
createProjectionAttachment({
paths: ["$.user"],
handle: (result) => result.safeUnwrap(([user]) => user),
}),
);
// fragments/PostList.ts
export const postListFragment = gql
.default(({ fragment, $var }) =>
fragment.Query({
variables: {
...$var("userId").ID("!"),
...$var("limit").Int("?"),
},
fields: ({ f, $ }) => ({
...f.user({ id: $.userId })(({ f }) => ({
...f.posts({ limit: $.limit })(({ f }) => ({
...f.id(),
...f.title(),
})),
})),
}),
}),
)
.attach(
createProjectionAttachment({
paths: ["$.user.posts"],
handle: (result) => result.safeUnwrap(([posts]) => posts ?? []),
}),
);
// pages/UserPage.ts
import { createExecutionResultParser } from "@soda-gql/colocation-tools";
import { userCardFragment } from "./fragments/UserCard";
import { postListFragment } from "./fragments/PostList";
export const userPageQuery = gql.default(({ query, $var, $colocate }) =>
query.operation({
name: "UserPage",
variables: { ...$var("userId").ID("!") },
fields: ({ $ }) => $colocate({
userCard: userCardFragment.spread({ id: $.userId }),
postList: postListFragment.spread({ userId: $.userId, limit: 10 }),
}),
}),
);
export const parseUserPageResult = createExecutionResultParser({
userCard: userCardFragment,
postList: postListFragment,
});
Single Fragment Operations
For simple operations like mutations with a single fragment spread, you can use
createDirectParser instead of $colocate + createExecutionResultParser:
import { createProjectionAttachment, createDirectParser } from "@soda-gql/colocation-tools";
// Define mutation fragment with projection
const createProductFragment = gql
.default(({ fragment, $var }) =>
fragment.Mutation({
variables: { ...$var("input").ProductInput("!") },
fields: ({ f, $ }) => ({
...f.insert_products_one({ object: $.input })(({ f }) => ({
...f.id(),
...f.name(),
})),
}),
}),
)
.attach(
createProjectionAttachment({
paths: ["$.insert_products_one"],
handle: (result) => result.safeUnwrap(([product]) => product),
}),
);
// Create operation (no $colocate needed)
const createProductMutation = gql.default(({ mutation, $var }) =>
mutation.operation({
name: "CreateProduct",
variables: { ...$var("input").ProductInput("!") },
fields: ({ $ }) => createProductFragment.spread({ input: $.input }),
}),
);
// Use createDirectParser (returns projected value directly)
const parseCreateProduct = createDirectParser(createProductFragment);
// Usage
const response = await client.execute(createProductMutation);
const { data, error } = parseCreateProduct(response);
This is simpler than $colocate when you only have one fragment to parse.
Next Steps