Migrating from Query Key Factory to Query Factory in TanStack Query
If you've been using @lukemorales/query-key-factory to manage your TanStack Query keys, you may have already hit the wall: the library doesn't support TanStack Query v5, and there's no fix in sight. Issue #73 remains unresolved, and the support PR was closed without being merged.
If you've been using @lukemorales/query-key-factory to manage your TanStack Query keys, you may have already hit the wall: the library doesn't support TanStack Query v5, and there's no fix in sight. Issue #73 remains unresolved, and the support PR was closed without being merged. That's not a criticism of the library—it served the community well—but it is a clear signal that it's time to move on.
The good news? TanStack Query v5 ships with a queryOptions() utility that makes the external library entirely redundant. You can preserve the same ergonomic call-site pattern your team already knows (...eventQueries.getEventDetails(id)), gain stronger TypeScript inference across getQueryData, setQueryData, and invalidateQueries—all without adding a single dependency. This post walks you through the exact migration, including concrete before/after code, type safety mechanics, and where to place your query factories in a feature-sliced or layered architecture.
Why @lukemorales/query-key-factory Is No Longer the Right Tool
Before diving into code, it's worth understanding why the migration is worth the effort rather than waiting for a compatibility fix.
The @lukemorales/query-key-factory library introduced the createQueryKeys and mergeQueryKeys APIs to enforce a consistent, hierarchical key structure. The internal _def property was how the library tracked key ancestry to enable partial-match invalidation. It was clever, but it was also an abstraction layer sitting between you and TanStack Query's own APIs.
When TanStack Query v5 landed, it introduced breaking changes to QueryFunction generics, the meta type, and several internal interfaces. The query-key-factory library would need a substantial rewrite to keep up. The community attempted it—the PR was submitted—but it was ultimately closed. If you try to install @lukemorales/query-key-factory with @tanstack/react-query v5 today, you'll encounter type errors immediately and runtime instability depending on your configuration.
Here's what that incompatibility looks like in practice:
// ❌ This breaks with @tanstack/react-query v5
import { createQueryKeys } from "@lukemorales/query-key-factory";
export const eventKeys = createQueryKeys("events", {
all: null,
detail: (id: string) => ({ id }),
});
The createQueryKeys return type no longer aligns with v5's QueryKey expectations, and the _def traversal that powered invalidateQueries partial matching becomes unreliable. The migration isn't optional if you're on v5—it's a prerequisite.
queryOptions(): The Built-In Replacement You Already Have
TanStack Query v5 introduced queryOptions() as a first-class utility for co-locating query keys, fetch functions, and configuration in one typed object. It's not just a convenience wrapper; it's the idiomatic v5 pattern.
Here's the key insight: queryOptions() returns an object that TanStack Query understands natively, including the queryKey as a typed tuple. This means you can pass its result directly to useQuery, getQueryData, setQueryData, and queryClient.invalidateQueries—and TypeScript will correctly infer the data type at every call site.
// ✅ queryOptions() in TanStack Query v5
import { queryOptions } from "@tanstack/react-query";
const eventDetailOptions = queryOptions({
queryKey: ["events", "detail", id] as const,
queryFn: () => fetchEventById(id),
});
// TypeScript knows the return type is `Event` everywhere
queryClient.getQueryData(eventDetailOptions.queryKey); // typed as Event | undefined
queryClient.setQueryData(eventDetailOptions.queryKey, updater); // updater typed as Event
queryClient.invalidateQueries({ queryKey: eventDetailOptions.queryKey });
The as const assertion tells TypeScript to treat the array as a literal tuple rather than a mutable string[]. Combined with queryOptions(), this gives you compile-time certainty that the key you use to read cached data is the same shape as the key used to write it—no runtime key mismatches, no manual type casting.
The Before/After Migration: Minimal Call-Site Disruption
The most common fear with this migration is that it will require rewriting every useQuery call across the codebase. It doesn't. Here's why: the spread pattern that made query-key-factory ergonomic translates directly to the queryOptions() factory pattern.
Before: @lukemorales/query-key-factory
// queryKeys/events.ts
import { createQueryKeys } from "@lukemorales/query-key-factory";
export const eventKeys = createQueryKeys("events", {
all: null,
lists: null,
list: (filters: EventFilters) => ({ filters }),
details: null,
detail: (id: string) => ({ id }),
});
// queries/events.ts
export const eventQueries = {
getEvents: (filters: EventFilters) => ({
queryKey: eventKeys.list(filters),
queryFn: () => fetchEvents(filters),
}),
getEventDetails: (id: string) => ({
queryKey: eventKeys.detail(id),
queryFn: () => fetchEventById(id),
}),
};
// components/EventDetail.tsx
function EventDetail({ id }: { id: string }) {
const { data } = useQuery({
...eventQueries.getEventDetails(id),
});
}
After: Pure Object + queryOptions()
// api/eventQueries.ts
import { queryOptions } from "@tanstack/react-query";
// Hierarchical key structure without _def
const eventBaseKey = ["events"] as const;
export const eventQueries = {
all: () =>
queryOptions({
queryKey: [...eventBaseKey] as const,
queryFn: fetchAllEvents,
}),
lists: () =>
queryOptions({
queryKey: [...eventBaseKey, "list"] as const,
queryFn: fetchEventList,
}),
list: (filters: EventFilters) =>
queryOptions({
queryKey: [...eventBaseKey, "list", filters] as const,
queryFn: () => fetchEvents(filters),
}),
detail: (id: string) =>
queryOptions({
queryKey: [...eventBaseKey, "detail", id] as const,
queryFn: () => fetchEventById(id),
}),
};
// components/EventDetail.tsx — UNCHANGED
function EventDetail({ id }: { id: string }) {
const { data } = useQuery({
...eventQueries.detail(id),
});
}
The component didn't change. The ...eventQueries.detail(id) spread still works because queryOptions() returns a plain object with queryKey and queryFn properties. Your call sites are untouched. The only work happens inside the factory file itself, and that's a one-time migration per feature.
Reproducing Hierarchical Invalidation Without _def
One of the headline features of query-key-factory was hierarchical invalidation: invalidate eventKeys.lists and every list variant gets wiped. Without _def, you need to reproduce this deliberately—but it's simpler than it looks.
The trick is that TanStack Query's invalidateQueries already supports partial key matching. If you pass { queryKey: ["events"] }, it invalidates every cached query whose key starts with "events". You don't need _def for this; you need a consistent key prefix convention.
// Invalidate everything under "events"
queryClient.invalidateQueries({ queryKey: eventBaseKey });
// Invalidate only list queries
queryClient.invalidateQueries({
queryKey: [...eventBaseKey, "list"],
});
// Invalidate a specific detail
queryClient.invalidateQueries({
queryKey: eventQueries.detail(id).queryKey,
});
Because queryOptions() captures the key as a typed tuple, passing .queryKey directly to invalidateQueries is fully type-safe. The compiler will catch a typo in "detail" or a missing id argument—something that wasn't guaranteed with string-based key construction.
Type Safety Across the Full Data Lifecycle
Here's the end-to-end picture without any external library:
// TypeScript infers Event from the queryFn return type
const options = eventQueries.detail("evt-123");
// options.queryKey is typed as readonly ["events", "detail", string]
// options.queryFn return type is Promise<Event>
// getQueryData is typed as Event | undefined — no cast needed
const cached = queryClient.getQueryData(options.queryKey);
// setQueryData updater is typed as (old: Event | undefined) => Event
queryClient.setQueryData(options.queryKey, (old) => ({
...old!,
title: "Updated Title",
}));
// invalidateQueries accepts the exact key shape
queryClient.invalidateQueries({ queryKey: options.queryKey });
This is what makes the native approach superior for TypeScript-first teams: the type flows from the queryFn return through every query client method automatically. There's no separate type annotation to keep in sync, and there's no _def property to navigate.
Where to Put Your Query Factories: Architecture Guidance
Once you've moved to the queryOptions() factory pattern, the next question is organizational: where do these files live? The answer depends on your architecture, and getting it right prevents the pattern from becoming a maintenance burden as the codebase scales.
Feature-Sliced Design (FSD)
In a Feature-Sliced Design project, the right placement depends on how broadly the query is reused:
| Scope | Placement | Rationale |
|---|---|---|
| Used by a single feature | features/event-booking/api/eventQueries.ts | Colocated with the feature that owns the data |
| Used across multiple features | entities/event/api/eventQueries.ts | Belongs to the entity layer, consumed by features |
| Truly global/shared | shared/api/eventQueries.ts | Only for infrastructure-level queries (e.g., current user, feature flags) |
The key principle: follow the data ownership, not the UI structure. If the Event entity is referenced by three different feature slices, the query factory lives at entities/event/api, not inside any one feature. Features import from entities; entities don't import from features.
Layered / Domain-Driven Architecture
In a more traditional layered structure, the pattern translates as follows:
src/
api/
events/
eventQueries.ts ← query factories
eventMutations.ts ← mutation factories (useMutation wrappers)
eventApi.ts ← raw fetch functions
features/
EventDetail/
EventDetail.tsx ← consumes eventQueries.detail(id)
The raw fetch functions (fetchEventById) live in eventApi.ts. The query factories in eventQueries.ts import from eventApi.ts and wrap calls in queryOptions(). Components import from eventQueries.ts. This keeps the data-fetching logic testable in isolation and prevents fetch logic from leaking into components.
Naming and Export Conventions
A few conventions that pay off at scale:
- Name the factory after the entity, not the operation:
eventQueriesrather thangetEventQueries. The methods on the object carry the operation name (eventQueries.detail,eventQueries.list). - Export the base key separately:
export const eventBaseKey = ["events"] as const. This lets other modules (e.g., a mutation that needs to invalidate after success) reference the key prefix without importing the full factory. - Keep mutation options separate:
queryOptions()is for reads. Mutations should be in a paralleleventMutations.tsfile using a similar factory pattern withuseMutationconfig objects.
Conclusion: Migrate Once, Benefit Permanently
The path from @lukemorales/query-key-factory to the queryOptions() factory pattern is one of the highest-leverage migrations you can make in a TanStack Query v5 codebase. You eliminate a dependency that's incompatible with v5, gain end-to-end TypeScript inference without manual type annotations, and preserve the ergonomic call-site pattern your team already relies on.
The migration is low-risk because the ...spread pattern at the component level is unchanged. The effort is contained to your API layer, one feature factory at a time. Start with the most actively developed feature in your codebase, validate the pattern with your team, then roll it out incrementally.
Your next step: Pick one createQueryKeys file in your project. Replace it with a plain object of queryOptions() calls using a shared baseKey. Run tsc --noEmit and watch the type errors resolve rather than multiply. Once you see the inference working end-to-end, the rest of the migration will feel mechanical—because it is.
Comments
Loading comments…
