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.embed() })
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").scalar("ID:!")] },
      ({ 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 };
        }
        return { error: null, user: result.unwrap().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").scalar("ID:!")],
    },
    ({ $ }) => [
      //
      $colocate({
        userCard: userCardFragment.embed({ userId: $.userId }),
        postList: postListFragment.embed({ 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({}, ({ f }) => [
      //
      f.user({ id: "1" })(({ f }) => [
        //
        f.id(),
        f.name(),
      ]),
    ]),
  )
  .attach(
    createProjectionAttachment({
      paths: ["$.user"],
      handle: (result) => {
        // Transform the sliced result
        if (result.isError()) return { error: result.error };
        if (result.isEmpty()) return { data: null };
        return { data: result.unwrap().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").scalar("ID:!")] },
      ({ f, $ }) => [
        //
        f.user({ id: $.id })(({ f }) => [
          //
          f.id(),
          f.name(),
          f.email(),
        ]),
      ],
    ),
  )
  .attach(
    createProjectionAttachment({
      paths: ["$.user"],
      handle: (result) => result.safeUnwrap((data) => data.user),
    }),
  );

// fragments/PostList.ts
export const postListFragment = gql
  .default(({ fragment }, { $var }) =>
    fragment.Query(
      {
        variables: [
          //
          $var("userId").scalar("ID:!"),
          $var("limit").scalar("Int:?"),
        ],
      },
      ({ f, $ }) => [
        //
        f.user({ id: $.userId })(({ f }) => [
          //
          f.posts({ limit: $.limit })(({ f }) => [
            //
            f.id(),
            f.title(),
          ]),
        ]),
      ],
    ),
  )
  .attach(
    createProjectionAttachment({
      paths: ["$.user.posts"],
      handle: (result) => result.safeUnwrap((data) => data.user?.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").scalar("ID:!")] },
    ({ $ }) => [
      //
      $colocate({
        userCard: userCardFragment.embed({ id: $.userId }),
        postList: postListFragment.embed({ userId: $.userId, limit: 10 }),
      }),
    ],
  ),
);

export const parseUserPageResult = createExecutionResultParser({
  userCard: userCardFragment,
  postList: postListFragment,
});

Next Steps