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.
Before you start
Section titled “Before you start”- 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
useSetDeliveryRemarksanduseSetPurchaseReferenceand theCheckoutDeliveryRemarksandCheckoutPurchaseReferenceparts. - Your site runs GraphQL codegen. The examples write documents with the
graphql()function that codegen generates (#generated/gql.tsin the reference storefronts). Codegen types them against the supergraph and gives them a document id for trusted documents.
How it fits together
Section titled “How it fits together”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:
-
Add the field to the schema
Section titled “Add the field to the schema”The storefront can only ask for what the API serves. Add the field to the
Carttype, and a mutation to set it, in a module of your own in the service that serves the cart (backend/purchasein 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: CartcartErrors: [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 codegenat the repository root rebuilds the service’s schema, the gateway’s supergraph and the site’s types. -
Read it with an extras query
Section titled “Read it with an extras query”Select your field, and only your field. The package’s
GetCartstill 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 {idcostCenter}}`);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;},});scopeKeyis 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. -
Show it in the package’s view
Section titled “Show it in the package’s view”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
isCompletereads 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 branchif (isPending || !cart || costCenter.isPending) {return <CheckoutSkeleton sections={CHECKOUT_SECTIONS.length} />;}const checkoutCart: CostCenterCart = { ...cart, costCenter: costCenter.data ?? null };return (<Checkoutcart={checkoutCart}sections={CHECKOUT_SECTIONS}renderSection={(section) => {switch (section.id) {case "cost-center":return (<CheckoutSectionCardsection={section}title="Cost center"summary={checkoutCart.costCenter ?? undefined}><CostCenterField cart={checkoutCart} /></CheckoutSectionCard>);// …the shipped sectionsdefault: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,CartLineItemsandShoppingListDetailtake a widened object, and theirrenderItemcallbacks get your type. -
Send it with your own mutation
Section titled “Send it with your own mutation”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 {idcostCenter}cartErrors {codemessage}}}`);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 invalidatingcartKeys.allrefetches 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 callsonTokenChangeafter each of its own writes.The field commits when the buyer leaves it, as the shipped
CheckoutDeliveryRemarksdoes, 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><InputonBlur={(event) => {const next = event.target.value.trim();if (next === current) return;setCostCenter.mutate({ cartId: cart.id, costCenter: next || null });}}/></TextField>);}
Replace the package’s document instead
Section titled “Replace the package’s document instead”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-listpatches its detail entry by spreading it, and tests that an added field survives. Fetch your copy undershoppingListKeys.detail(id).feature-cartreplaces 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, whoseContentBlocksBlockFieldsis 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.

