Skip to main content

Migration Guide

This guide walks you through migrating an existing TanStack Query codebase to Query Cache Flow.

Before You Start​

Assess Your Current Setup​

  1. How many query keys do you have? Check for patterns like:

    queryKey: ['accounts']
    queryKey: ['accounts', accountId]
    queryKey: ['transactions', { accountId, page }]
  2. How is invalidation handled? Look for:

    queryClient.invalidateQueries({ queryKey: ['accounts'] })
  3. Are you using KUBB? If not, Query Cache Flow still helps, but you'll write hooks manually.

Migration Steps​

Phase 1: Add Query Cache Flow Core​

Step 1: Create the Core File​

// src/queries/index.ts
import { InvalidateQueryFilters } from '@tanstack/react-query';
import queryClient from './client';

export type QueryKey<T> = {
entity: string;
method?: 'list' | 'detail' | 'create' | 'update' | 'remove' | string;
id?: T;
};

export interface QueryGroup<T> {
queryKey: QueryKey<T>;
invalidates?: QueryKey<T>;
type?: 'query' | 'mutation';
normalize?: (data: any) => void;
}

export interface QueryGroupResolved<T> {
queryKey: (...args: T[]) => QueryKey<T>;
invalidates?: (...args: T[]) => QueryKey<T>;
type?: 'query' | 'mutation';
normalize?: (data: any) => void;
}

export interface QueryGroupMutationResolved<T> {
invalidates: (...args: T[]) => QueryKey<T>[];
queryKey: (...args: T[]) => QueryKey<T>;
type?: 'query' | 'mutation';
normalize?: (data: any) => void;
}

export interface QueryGroupCRUD<T> {
all: QueryGroup<T>;
list: QueryGroup<T>;
detail: QueryGroupResolved<T>;
create: QueryGroup<T>;
update: QueryGroupMutationResolved<T>;
remove: QueryGroupMutationResolved<T>;
}

// Helper to resolve keys
const resolveKey = <T>(
key: QueryKey<T> | ((...args: T[]) => QueryKey<T>),
...args: T[]
): QueryKey<T> => {
return typeof key === 'function' ? key(...args) : key;
};

export const createQueryGroupCRUD = <T = string>(entityName: string): QueryGroupCRUD<T> => {
const all: QueryGroup<T> = {
queryKey: { entity: entityName },
};

const list: QueryGroup<T> = {
queryKey: { entity: entityName, method: 'list' },
type: 'query',
};

const detail: QueryGroupResolved<T> = {
queryKey: (id: T) => ({ entity: entityName, method: 'detail', id }),
type: 'query',
normalize: (data: any) => {
queryClient.setQueryData([resolveKey(list.queryKey)], (old: any) => {
if (!old) return old;
return old.map((item: any) => (item.id === data.id ? data : item));
});
},
};

const create: QueryGroup<T> = {
queryKey: { entity: entityName, method: 'create' },
invalidates: { entity: entityName, method: 'list' },
type: 'mutation',
normalize: (data: { id: any }) => {
queryClient.setQueryData([resolveKey(list.queryKey)], (old: any) => {
if (!old) return [data];
return [...old, data];
});
queryClient.setQueryData([resolveKey(detail.queryKey, data.id)], data);
},
};

const update: QueryGroupMutationResolved<T> = {
queryKey: (id: T) => ({ entity: entityName, method: 'update', id }),
invalidates: (id: T) => [
{ entity: entityName, id },
{ entity: entityName, method: 'list' },
],
type: 'mutation',
normalize: (data: { id: any }) => {
queryClient.setQueryData([resolveKey(list.queryKey)], (old: any) => {
if (!old) return old;
return old.map((item: any) => (item.id === data.id ? data : item));
});
queryClient.setQueryData([resolveKey(detail.queryKey, data.id)], data);
},
};

const remove: QueryGroupMutationResolved<T> = {
queryKey: (id: T) => ({ entity: entityName, method: 'remove', id }),
invalidates: (id: T) => [
{ entity: entityName, id },
{ entity: entityName, method: 'list' },
],
type: 'mutation',
normalize: (data: { id: any }) => {
queryClient.setQueryData([resolveKey(list.queryKey)], (old: any) => {
if (!old) return old;
return old.filter((item: any) => item.id !== data.id);
});
queryClient.setQueryData([resolveKey(detail.queryKey, data.id)], undefined);
},
};

return { all, list, detail, create, update, remove };
};

export const invalidateQueriesForKeys = (
keys: Array<QueryKey<string>>,
invalidateOptions?: InvalidateQueryFilters
): void => {
keys.filter(Boolean).forEach((key) => {
queryClient.invalidateQueries({ queryKey: [key], ...invalidateOptions });
});
};

export const cancelQueriesForKeys = (keys: Array<QueryKey<string>>): void => {
keys.filter(Boolean).forEach((key) => {
queryClient.cancelQueries({ queryKey: [key] });
});
};

export const inyectKeysToQueries = <T extends Record<string, any>>(
queries: T,
extra: Record<string, any>
): T => {
const process = (obj: any): any => {
if (Array.isArray(obj)) {
return obj.map(process);
} else if (obj && typeof obj === 'object') {
if (Object.prototype.hasOwnProperty.call(obj, 'queryKey')) {
if (typeof obj.queryKey === 'function') {
const originalFn = obj.queryKey;
obj.queryKey = (...args: any[]) => {
const key = originalFn(...args);
if (key && typeof key === 'object' && !Array.isArray(key)) {
return { ...key, ...extra };
}
return key;
};
} else if (obj.queryKey && typeof obj.queryKey === 'object') {
obj.queryKey = { ...obj.queryKey, ...extra };
}
}
for (const prop in obj) {
if (Object.prototype.hasOwnProperty.call(obj, prop)) {
obj[prop] = process(obj[prop]);
}
}
}
return obj;
};
return process(queries);
};

Phase 2: Migrate One Feature​

Choose a simple feature (e.g., accounts) to migrate first.

Before: Manual Keys​

// Old: src/features/accounts/hooks.ts
export const useAccounts = () =>
useQuery({
queryKey: ['accounts'],
queryFn: fetchAccounts,
});

export const useAccount = (id: string) =>
useQuery({
queryKey: ['accounts', id],
queryFn: () => fetchAccount(id),
});

export const useCreateAccount = () =>
useMutation({
mutationFn: createAccount,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['accounts'] });
},
});

After: Query Cache Flow​

// New: src/features/accounts/queries/index.ts
import { createQueryGroupCRUD, inyectKeysToQueries } from 'src/queries';

let accountsQueryGroupCRUD = createQueryGroupCRUD<string>('accounts');
accountsQueryGroupCRUD = inyectKeysToQueries(accountsQueryGroupCRUD, { auth: true });

export const accountsQueryGroup = accountsQueryGroupCRUD;

// New: src/features/accounts/queries/useAccounts.ts
export const useAccounts = () =>
useQuery({
queryKey: [accountsQueryGroup.list.queryKey],
queryFn: fetchAccounts,
});

// New: src/features/accounts/queries/useAccount.ts
export const useAccount = (id: string) =>
useQuery({
queryKey: [accountsQueryGroup.detail.queryKey(id)],
queryFn: () => fetchAccount(id),
});

// New: src/features/accounts/queries/useAccountCreate.ts
export const useAccountCreate = () =>
useMutation({
mutationFn: createAccount,
onSuccess: (data) => {
accountsQueryGroup.create.normalize?.(data);
invalidateQueriesForKeys([accountsQueryGroup.create.invalidates!]);
},
});

Phase 3: Update Imports​

Replace old imports with new ones:

// Before
import { useAccounts, useAccount, useCreateAccount } from 'src/features/accounts/hooks';

// After
import { useAccounts } from 'src/features/accounts/queries/useAccounts';
import { useAccount } from 'src/features/accounts/queries/useAccount';
import { useAccountCreate } from 'src/features/accounts/queries/useAccountCreate';

Phase 4: Migrate Remaining Features​

Repeat Phase 2-3 for each feature:

  1. Create query group in queries/index.ts
  2. Create wrapper hooks
  3. Update imports in components
  4. Test the feature

Phase 5: Remove Old Code​

Once all features are migrated:

  1. Delete old hook files
  2. Search for any remaining raw queryKey: ['...'] patterns
  3. Remove unused imports

Key Mapping Reference​

Old PatternNew Pattern
['entity']entityQueryGroup.all.queryKey
['entity', 'list']entityQueryGroup.list.queryKey
['entity', id]entityQueryGroup.detail.queryKey(id)
['entity', { ...params }]entityQueryGroup.list.queryKey(params)

Handling Edge Cases​

Custom Query Keys​

If you have custom keys that don't fit CRUD:

export const accountsQueryGroup = {
...accountsQueryGroupCRUD,
// Add custom queries
associates: {
queryKey: { entity: 'accounts', scope: 'associates', method: 'list' },
},
statistics: {
queryKey: (accountId: string) => ({
entity: 'accounts',
scope: 'statistics',
id: accountId,
}),
},
};

Complex Invalidation​

If mutations affect multiple entities:

export const accountsQueryGroup = {
...accountsQueryGroupCRUD,
remove: {
...accountsQueryGroupCRUD.remove,
invalidates: (id: string) => [
...accountsQueryGroupCRUD.remove.invalidates(id),
transactionsQueryGroup.all.queryKey,
movementsQueryGroup.all.queryKey,
],
},
};

Paginated Queries​

export const remindersQueryGroup = {
...remindersQueryGroupCRUD,
list: {
queryKey: (params?: QueryParams) => ({
...remindersQueryGroupCRUD.list.queryKey,
query: params,
}),
},
};

Verification Checklist​

After migration, verify:

  • All queries use Query Cache Flow keys
  • All mutations invalidate properly
  • No raw queryKey: ['...'] patterns remain
  • TypeScript compiles without errors
  • All tests pass
  • Manual testing confirms cache behavior

Common Pitfalls​

1. Forgetting to Update Invalidation​

// Wrong - old pattern
queryClient.invalidateQueries({ queryKey: ['accounts'] });

// Right - new pattern
invalidateQueriesForKeys([accountsQueryGroup.list.queryKey]);

2. Using Wrong Key Level​

// Wrong - too broad (invalidates everything)
invalidateQueriesForKeys([accountsQueryGroup.all.queryKey]);

// Right - specific to list
invalidateQueriesForKeys([accountsQueryGroup.list.queryKey]);

3. Missing Key Wrapper​

// Wrong - missing array wrapper
queryKey: accountsQueryGroup.list.queryKey

// Right - wrapped in array
queryKey: [accountsQueryGroup.list.queryKey]

Rollback Plan​

If issues arise:

  1. Keep old code commented until migration is verified
  2. Use feature flags to toggle between old/new implementations
  3. Deploy incrementally, one feature at a time

Summary​

Migration to Query Cache Flow involves:

  1. Adding core Query Cache Flow utilities
  2. Creating query groups for each feature
  3. Wrapping existing hooks
  4. Updating imports
  5. Removing old code

Take it one feature at a time, test thoroughly, and enjoy zero-thought cache management!