Skip to content

Extend a feature's data

A feature package selects a fixed set of fields and sends a fixed set of variables. Your project needs one more. You do not fork the package: the package owns the view, and your app can own the data.

This guide uses one running example: a B2B shop that needs a cost center on every order. The buyer enters it in the checkout, and the checkout cannot be completed without it. The checkout comes from @evolve-storefront/feature-cart.

  • Check that the field is missing. Search the GraphQL reference and the package’s hooks. Delivery remarks and a purchase order number, for example, are already shipped, with useSetDeliveryRemarks and useSetPurchaseReference and the CheckoutDeliveryRemarks and CheckoutPurchaseReference parts.
  • Your site runs GraphQL codegen. The examples write documents with the graphql() function that codegen generates (#generated/gql.ts in the reference storefronts). Codegen types them against the supergraph and gives them a document id for trusted documents.

The package keeps its own query. You add a small extras query for your field, merge the two in your component, and hand the result to the package’s views. The extras query has its own cache key on purpose:

  1. The storefront can only ask for what the API serves. Add the field to the Cart type, and a mutation to set it, in a module of your own in the service that serves the cart (backend/purchase in the reference projects):

    extend type Cart {
    "The cost center the buyer books this order on. Null until one is set."
    costCenter: String
    }
    type CartSetCostCenterResponse {
    cart: Cart
    cartErrors: [CartError!]
    }
    extend type Mutation {
    cartSetCostCenter(id: ID!, costCenter: String): CartSetCostCenterResponse!
    }

    The resolvers read and write the value in your commerce engine. Add your own module shows how to write and compose the module, and Add a field how to store a field in the engine.

    Then regenerate. In the reference projects, pnpm codegen at the repository root rebuilds the service’s schema, the gateway’s supergraph and the site’s types.

  2. Select your field, and only your field. The package’s GetCart still fetches everything else.

    import { scopeKey } from "@evolve-storefront/feature-cart";
    import type { ClientFetcher } from "@labdigital/graphql-fetcher";
    import { queryOptions } from "@tanstack/react-query";
    import { graphql } from "#generated/gql.ts";
    const GetCartCostCenter = graphql(/* GraphQL */ `
    query GetCartCostCenter {
    cart {
    id
    costCenter
    }
    }
    `);
    export const costCenterKey = (businessUnitId: string | undefined, cartId: string) =>
    ["cart-cost-center", scopeKey(businessUnitId), cartId] as const;
    export const cartCostCenterQueryOptions = (args: {
    gqlClient: ClientFetcher;
    businessUnitId?: string;
    cartId: string;
    enabled?: boolean;
    }) =>
    queryOptions({
    queryKey: costCenterKey(args.businessUnitId, args.cartId),
    enabled: args.enabled ?? true,
    queryFn: async () => {
    const response = await args.gqlClient(GetCartCostCenter, {});
    return response.data?.cart?.costCenter ?? null;
    },
    });

    scopeKey is the business-unit part of every cart key, so switching company never shows the other company’s value. Give the operation a name of its own: codegen also loads the packages’ documents, and two operations with one name break the run.

  3. The feature views are generic over the object you hand them. Merge your field into the cart, and a checkout section of your own can read it with its type intact.

    import type { Cart } from "@evolve-storefront/feature-cart";
    import {
    billingAddressSection,
    contactSection,
    defineCheckoutSection,
    deliveryAddressSection,
    deliverySection,
    paymentSection,
    } from "@evolve-storefront/feature-cart/checkout-sections";
    export type CostCenterCart = Cart & { costCenter: string | null };
    const costCenterSection = defineCheckoutSection({
    id: "cost-center",
    isComplete: (cart: CostCenterCart) => Boolean(cart.costCenter?.trim()),
    });
    export const CHECKOUT_SECTIONS = [
    contactSection,
    billingAddressSection,
    deliveryAddressSection(),
    deliverySection,
    costCenterSection,
    paymentSection,
    ] as const;

    Because isComplete reads your field, the order cannot be placed until the buyer has entered a cost center. In the checkout component, wait for both queries, then pass the merged cart:

    import {
    Checkout,
    CheckoutSectionCard,
    CheckoutSkeleton,
    } from "@evolve-storefront/feature-cart/checkout";
    import { useCartContext } from "@evolve-storefront/feature-cart/context";
    import { useCart } from "@evolve-storefront/feature-cart/hooks";
    import { useClientGqlFetcher } from "@labdigital/graphql-fetcher/react";
    import { useQuery } from "@tanstack/react-query";
    import { cartCostCenterQueryOptions } from "../_data/cost-center.ts";
    import { CHECKOUT_SECTIONS, type CostCenterCart } from "../_data/sections.ts";
    import { CostCenterField } from "./cost-center-field.tsx";
    // …inside your checkout component:
    const gqlClient = useClientGqlFetcher();
    const { hasSession, businessUnitId } = useCartContext();
    const { data: cart, isPending } = useCart();
    const costCenter = useQuery(
    cartCostCenterQueryOptions({
    gqlClient,
    businessUnitId,
    cartId: cart?.id ?? "",
    enabled: hasSession && Boolean(cart),
    }),
    );
    // …the empty-cart branch
    if (isPending || !cart || costCenter.isPending) {
    return <CheckoutSkeleton sections={CHECKOUT_SECTIONS.length} />;
    }
    const checkoutCart: CostCenterCart = { ...cart, costCenter: costCenter.data ?? null };
    return (
    <Checkout
    cart={checkoutCart}
    sections={CHECKOUT_SECTIONS}
    renderSection={(section) => {
    switch (section.id) {
    case "cost-center":
    return (
    <CheckoutSectionCard
    section={section}
    title="Cost center"
    summary={checkoutCart.costCenter ?? undefined}
    >
    <CostCenterField cart={checkoutCart} />
    </CheckoutSectionCard>
    );
    // …the shipped sections
    default:
    return null;
    }
    }}
    />
    );

    Wait for the extras query before you render. The checkout decides which section to open when it mounts, so a cost center that arrives a moment later would leave its section open.

    The same works outside the checkout: Cart, CartLineItems and ShoppingListDetail take a widened object, and their renderItem callbacks get your type.

  4. The package’s hooks send only the variables they know, so the write is yours too:

    export const CartSetCostCenter = graphql(/* GraphQL */ `
    mutation CartSetCostCenter($id: ID!, $costCenter: String) {
    cartSetCostCenter(id: $id, costCenter: $costCenter) {
    cart {
    id
    costCenter
    }
    cartErrors {
    code
    message
    }
    }
    }
    `);
    import { cartKeys } from "@evolve-storefront/feature-cart";
    import { useCartContext } from "@evolve-storefront/feature-cart/context";
    import { useClientGqlFetcher } from "@labdigital/graphql-fetcher/react";
    import { useMutation, useQueryClient } from "@tanstack/react-query";
    import { CartSetCostCenter, costCenterKey } from "../_data/cost-center.ts";
    export function useSetCostCenter() {
    const gqlClient = useClientGqlFetcher();
    const queryClient = useQueryClient();
    const { businessUnitId, onTokenChange } = useCartContext();
    return useMutation({
    mutationFn: async (variables: { cartId: string; costCenter: string | null }) => {
    const response = await gqlClient(CartSetCostCenter, {
    id: variables.cartId,
    costCenter: variables.costCenter,
    });
    const result = response.data?.cartSetCostCenter;
    if (!result?.cart || result.cartErrors?.length) {
    throw new Error(result?.cartErrors?.[0]?.message ?? "Cost center not saved");
    }
    return result.cart;
    },
    onSuccess: (cart) => {
    queryClient.setQueryData(
    costCenterKey(businessUnitId, cart.id),
    cart.costCenter ?? null,
    );
    void queryClient.invalidateQueries({ queryKey: cartKeys.all(businessUnitId) });
    },
    onSettled: () => onTokenChange(),
    });
    }

    Two lines keep the package correct. Every cart write changes the cart’s version, and the package sends that version with its own writes, so invalidating cartKeys.all refetches the cart before its next write is refused as a conflict. And a cart write can rotate the session token, which is why the package calls onTokenChange after each of its own writes.

    The field commits when the buyer leaves it, as the shipped CheckoutDeliveryRemarks does, so typing does not send a write per keystroke:

    import { Input, Label, TextField } from "@evolve-storefront/ui/text-field";
    import type { CostCenterCart } from "../_data/sections.ts";
    import { useSetCostCenter } from "./use-set-cost-center.ts";
    export function CostCenterField({ cart }: { cart: CostCenterCart }) {
    const setCostCenter = useSetCostCenter();
    const current = cart.costCenter ?? "";
    return (
    <TextField key={current} defaultValue={current} disabled={setCostCenter.isPending} required>
    <Label>Cost center</Label>
    <Input
    onBlur={(event) => {
    const next = event.target.value.trim();
    if (next === current) return;
    setCostCenter.mutate({ cartId: cart.id, costCenter: next || null });
    }}
    />
    </TextField>
    );
    }

An extras query costs one more small request. You can also copy the package’s document, add your field, and fetch it under the package’s own key, so the package’s invalidation and cache updates still reach your view. This is only safe when the package’s writes patch or refetch that entry rather than replace it:

  • feature-shopping-list patches its detail entry by spreading it, and tests that an added field survives. Fetch your copy under shoppingListKeys.detail(id).
  • feature-cart replaces its entry on every write, which is why this guide uses an extras query.

When you copy a document:

  • Copy the selection, not a fragment. The packages’ fragments are not exported. The exception is feature-content-blocks, whose ContentBlocksBlockFields is meant to be spread into your page query.
  • Re-check the copy when you upgrade. A field the package adds to its own document does not reach yours.

If you are not sure which case applies, use an extras query. It is correct either way.