Working with Mixed Sources
How to organize GraphQL, ts-rest, and manual calls in one Callsheet.
Use this guide to learn how to combine requests from GraphQL documents, typed REST contracts, and your own hand-authored requests into a single shared surface in Callsheet.
A Mixed Source Call Tree
Here is a call tree that combines multiple source types.
import { defineCalls, query, mutation } from '@callsheet/react-query';
import {
query as tsRestQuery,
mutation as tsRestMutation,
} from '@callsheet/ts-rest';
// Manually typed GraphQL documents
import { FeaturedFilmsDocument } from './graphql/featured-films';
import { FilmByIdDocument } from './graphql/film-by-id';
import { UpdateFilmDocument } from './graphql/update-film';
// ts-rest contract
import { contract } from './rest/contract';
export const calls = defineCalls({
films: {
featured: query(FeaturedFilmsDocument),
byId: query(FilmByIdDocument, {
family: ['films', 'detail'],
key: ({ input }) => [input.id],
}),
update: mutation(UpdateFilmDocument, {
invalidates: [
['films', 'list'],
['films', 'detail'],
],
}),
},
accounts: {
byId: tsRestQuery(contract.accounts.byId, {
family: ['accounts', 'detail'],
key: ({ input }) => [input.params.id],
}),
update: tsRestMutation(contract.accounts.update, {
invalidates: [['accounts', 'detail']],
}),
},
analytics: {
overview: query<void, { views: number; revenue: number }>({
family: ['analytics', 'overview'],
staleTime: 60_000,
}),
},
});Components can consume all of them through the same React Query APIs:
const film = useQuery(queryOptions(calls.films.byId, { input: { id } }));
const account = useQuery(
queryOptions(calls.accounts.byId, { input: { params: { id } } }),
);
const analytics = useQuery(queryOptions(calls.analytics.overview));Organizing by source vs by domain
There are two common ways to organize a mixed-source tree. The right choice depends on how your app thinks about its operations.
By domain
Group calls by what they represent in the app, regardless of which typed source they come from:
export const calls = defineCalls({
films: {
featured: query(FeaturedFilmsDocument),
byId: query(FilmByIdDocument),
update: mutation(UpdateFilmDocument),
},
accounts: {
byId: tsRestQuery(contract.accounts.byId),
update: tsRestMutation(contract.accounts.update),
},
analytics: {
overview: query<void, { views: number; revenue: number }>(),
},
});This is usually the better fit. Components look up calls by what they need (calls.films.byId), not by where the data comes from. The typed source is an implementation detail.
By source
Group calls by their origin. This can make sense when sources map to separate systems:
export const calls = defineCalls({
graphql: {
films: {
featured: query(FeaturedFilmsDocument),
byId: query(FilmByIdDocument),
update: mutation(UpdateFilmDocument),
},
},
rest: {
accounts: {
byId: tsRestQuery(contract.accounts.byId),
update: tsRestMutation(contract.accounts.update),
},
},
internal: {
analytics: {
overview: query<void, { views: number; revenue: number }>(),
},
},
});The tradeoff is that components now need to know the source type to find the right call (calls.graphql.films.byId vs calls.films.byId). For most apps, domain-based organization avoids that extra indirection.
Using pathPrefix with code generation
If you use Callsheet's code generation for GraphQL and ts-rest sources together, pathPrefix controls where each source lands in the generated tree.
import { defineConfig } from '@callsheet/codegen';
export default defineConfig({
sources: {
graphql: [
{
rootDir: './src/graphql',
tsconfigFile: './tsconfig.json',
entries: ['generated.ts'],
},
],
tsRest: [
{
importFrom: './src/rest/contract',
exportName: 'contract',
pathPrefix: ['accounts'],
},
],
},
output: {
file: './src/generated/calls.ts',
},
});In this config, GraphQL calls land at the top level of the tree and ts-rest calls land under accounts.*. Without the prefix, both sources would generate paths based on their own names, which could collide or feel flat.
Use pathPrefix when you want generated sources to land in a specific part of the tree, or just skip it when the generated names already reflect the structure you need.
The call model is the same regardless of source
Once calls are in the tree, everything works the same way. The source a call was built from does not change how families, defaults, or invalidation behave.
A mutation backed by a ts-rest contract can invalidate a family that contains GraphQL queries:
export const calls = defineCalls({
films: {
featured: query(FeaturedFilmsDocument, {
family: ['films', 'list'],
}),
byId: query(FilmByIdDocument, {
family: ['films', 'detail'],
}),
},
rest: {
films: {
update: tsRestMutation(contract.films.update, {
invalidates: [['films', 'list']],
}),
},
},
});When rest.films.update completes, it invalidates the ['films', 'list'] family. That refreshes films.featured even though the mutation and the query come from different typed sources.
Shared defaults like staleTime, and retry also work identically across source types. Read more in Using Callsheet with React Query.
Related Docs
- Using Callsheet with GraphQL for GraphQL source setup
- Using Callsheet with ts-rest for ts-rest source setup
- Manual Setup for hand-authored calls
- Calls for the shared call model
- Call Family and Identity for families, keys, and invalidation
- Using Callsheet with React Query for shared defaults and overrides