Typed abstractions over TanStack Query v5. Define queries and mutations once — get typed keys, typed fetchers, and typed cache helpers everywhere.
npm install @ciscode/query-kitnpm install @tanstack/react-query@^5 react@^18 react-dom@^18Requires
@tanstack/react-query >= 5,react >= 18,react-dom >= 18.
Wrap your app in QueryClientProvider from @tanstack/react-query as usual,
then use @ciscode/query-kit to define and consume queries.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const client = new QueryClient();
export function App() {
return (
<QueryClientProvider client={client}>
<YourApp />
</QueryClientProvider>
);
}Define a query once and get a fully typed definition that carries its key
builder, fetcher, and a useQuery shorthand together.
// queries/userQuery.ts
import { createQuery } from '@ciscode/query-kit';
interface User {
id: number;
name: string;
email: string;
}
export const userQuery = createQuery(
(params: { id: number }) => ['users', params.id] as const,
async (params) => {
const res = await fetch(`/api/users/${params.id}`);
if (!res.ok) throw new Error('Failed to fetch user');
return res.json() as Promise<User>;
},
);// components/UserProfile.tsx
import { userQuery } from '../queries/userQuery';
export function UserProfile({ userId }: { userId: number }) {
const { data, isLoading, isError, error } = userQuery.useQuery({ id: userId });
if (isLoading) return <p>Loading…</p>;
if (isError) return <p>Error: {error.message}</p>;
return (
<div>
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}// Access the key builder (e.g. for manual cache operations)
const key = userQuery.queryKey({ id: 42 }); // ['users', 42]
// Call the fetcher directly (e.g. in a server-side loader)
const user = await userQuery.queryFn({ id: 42 });Wraps either useQuery (offset mode) or useInfiniteQuery (cursor mode)
behind a unified API. Both modes return a flat data array.
import { usePaginatedQuery } from '@ciscode/query-kit';
import { postsQuery } from '../queries/postsQuery';
// postsQuery is a createQuery definition whose fetcher accepts { page, pageSize }
export function PostsList() {
const { data, isLoading, page, pageSize, nextPage, prevPage } = usePaginatedQuery(
postsQuery,
{ page: 1, pageSize: 10 },
{ mode: 'offset', pageSize: 10, initialPage: 1 },
);
if (isLoading) return <p>Loading…</p>;
return (
<div>
{data.map((post) => (
<article key={post.id}>{post.title}</article>
))}
<button onClick={prevPage} disabled={page === 1}>
Previous
</button>
<span>Page {page}</span>
<button onClick={nextPage}>Next</button>
</div>
);
}Offset result shape
| Property | Type | Description |
|---|---|---|
data |
T[] |
Flat array of current page items |
page |
number |
Current page (starts at 1) |
pageSize |
number |
Items per page (default 20) |
totalPages |
number | undefined |
Total pages if known |
nextPage |
() => void |
Increment page |
prevPage |
() => void |
Decrement page (floor at 1) |
isLoading |
boolean |
|
isFetching |
boolean |
|
isError |
boolean |
|
error |
Error | null |
import { usePaginatedQuery } from '@ciscode/query-kit';
import { feedQuery } from '../queries/feedQuery';
// feedQuery fetcher accepts { cursor?: string | number | null | undefined }
export function Feed() {
const { data, isLoading, fetchNextPage, hasNextPage, isFetching } = usePaginatedQuery(
feedQuery,
{ cursor: undefined },
{
mode: 'cursor',
getCursor: (page) => (page.length > 0 ? page[page.length - 1].id : undefined),
},
);
if (isLoading) return <p>Loading…</p>;
return (
<div>
{data.map((item) => (
<article key={item.id}>{item.title}</article>
))}
{hasNextPage && (
<button onClick={fetchNextPage} disabled={isFetching}>
{isFetching ? 'Loading…' : 'Load more'}
</button>
)}
</div>
);
}Cursor result shape
| Property | Type | Description |
|---|---|---|
data |
T[] |
Flat array of all loaded items |
fetchNextPage |
() => void |
Fetch the next page |
hasNextPage |
boolean |
true when getCursor returns a value |
nextCursor |
string | number | null | undefined |
Cursor for the next page |
isLoading |
boolean |
|
isFetching |
boolean |
|
isError |
boolean |
|
error |
Error | null |
Define a mutation once and use it anywhere with full type safety.
// mutations/updateUser.ts
import { createMutation } from '@ciscode/query-kit';
interface UpdateUserInput {
id: number;
name: string;
}
interface User {
id: number;
name: string;
email: string;
}
export const updateUserMutation = createMutation(async (input: UpdateUserInput): Promise<User> => {
const res = await fetch(`/api/users/${input.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: input.name }),
});
if (!res.ok) throw new Error('Failed to update user');
return res.json();
});// components/EditUserForm.tsx
import { useQueryClient } from '@tanstack/react-query';
import { updateUserMutation } from '../mutations/updateUser';
import { userQuery } from '../queries/userQuery';
import { invalidateQueries } from '@ciscode/query-kit';
export function EditUserForm({ userId }: { userId: number }) {
const queryClient = useQueryClient();
const { mutate, isPending, isError, error } = updateUserMutation.useMutation();
function handleSubmit(name: string) {
mutate(
{ id: userId, name },
{
onSuccess: () => {
// Invalidate the user query so it re-fetches fresh data.
// No raw string keys — the key comes from userQuery.
invalidateQueries(queryClient, userQuery, { id: userId });
},
},
);
}
return (
<form
onSubmit={(e) => {
e.preventDefault();
const name = new FormData(e.currentTarget).get('name') as string;
handleSubmit(name);
}}
>
<input name="name" required />
<button type="submit" disabled={isPending}>
{isPending ? 'Saving…' : 'Save'}
</button>
{isError && <p role="alert">Error: {error.message}</p>}
</form>
);
}Marks all matching queries as stale so they re-fetch. Uses the query definition's key builder — no raw strings.
import { invalidateQueries } from '@ciscode/query-kit';
// Invalidate a specific user
await invalidateQueries(queryClient, userQuery, { id: 42 });
// Invalidate all queries matching the userQuery key prefix
await invalidateQueries(queryClient, userQuery);Write directly into the cache without a network request. Passing the wrong
TData shape is a TypeScript compile error.
import { setQueryData } from '@ciscode/query-kit';
// Direct replacement
setQueryData(
queryClient,
userQuery,
{ id: 42 },
{ id: 42, name: 'Alice', email: 'alice@example.com' },
);
// Updater function — receives the old value
setQueryData(queryClient, userQuery, { id: 42 }, (prev) => ({
...prev!,
name: 'Alice Updated',
}));define once (createQuery / createMutation)
↓
use in component (queryDef.useQuery / mutationDef.useMutation)
↓
on success → invalidateQueries / setQueryData (no raw strings)
| Export | Kind | Description |
|---|---|---|
createQuery |
function |
Creates a QueryDefinition (key + fetcher + hook) |
usePaginatedQuery |
function |
Offset or cursor pagination hook |
createMutation |
function |
Creates a MutationDefinition (fn + hook) |
invalidateQueries |
function |
Type-safe query invalidation via QueryDefinition |
setQueryData |
function |
Type-safe cache write via QueryDefinition |
QueryDefinition |
type |
Shape returned by createQuery |
MutationDefinition |
type |
Shape returned by createMutation |
OffsetPaginationOptions |
type |
Options for usePaginatedQuery offset mode |
CursorPaginationOptions |
type |
Options for usePaginatedQuery cursor mode |
npm run build # build to dist/ (tsup — ESM + CJS + types)
npm test # run tests (vitest)
npm run typecheck # TypeScript typecheck
npm run lint # ESLint
npm run format # Prettier check
npx changeset # create a changeset- Work on a
feat/*branch fromdevelop - Merge to
developvia PR - Add a changeset:
npx changeset - Promote
develop→mastervia PR - Tag
vX.Y.Zto publish (npm OIDC)