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:

AspectTraditional GraphQLsoda-gql
CompositionFragment spread ...Name$colocate({ label: fragment.spread() })
LabelingImplicit by fragment nameExplicit labels for each slice
Data ExtractionManual traversalcreateExecutionResultParser with projections
Error HandlingManual per-fragmentBuilt-in SlicedExecutionResult with Success/Error/Empty states
Type SafetyRequires codegenFull 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