diff --git a/package-lock.json b/package-lock.json
index 30cfbb5..a20891a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,16 +1,17 @@
{
- "name": "@ciscode/reactts-developerkit",
- "version": "1.0.0",
+ "name": "@ciscode/query-kit",
+ "version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "@ciscode/reactts-developerkit",
- "version": "1.0.0",
+ "name": "@ciscode/query-kit",
+ "version": "0.0.0",
"license": "MIT",
"devDependencies": {
"@changesets/cli": "^2.27.8",
"@eslint/js": "^9.39.2",
+ "@tanstack/react-query": "^5.96.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
@@ -37,6 +38,7 @@
"node": ">=20"
},
"peerDependencies": {
+ "@tanstack/react-query": ">=5",
"react": ">=18",
"react-dom": ">=18"
}
@@ -69,33 +71,6 @@
"node": ">=6.0.0"
}
},
- "node_modules/@asamuzakjp/css-color": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz",
- "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@csstools/css-calc": "^3.1.1",
- "@csstools/css-color-parser": "^4.0.2",
- "@csstools/css-parser-algorithms": "^4.0.0",
- "@csstools/css-tokenizer": "^4.0.0",
- "lru-cache": "^11.2.6"
- },
- "engines": {
- "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
- }
- },
- "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
- "version": "11.2.6",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
- "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "engines": {
- "node": "20 || >=22"
- }
- },
"node_modules/@asamuzakjp/dom-selector": {
"version": "6.8.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
@@ -2214,6 +2189,34 @@
"win32"
]
},
+ "node_modules/@tanstack/query-core": {
+ "version": "5.96.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.96.2.tgz",
+ "integrity": "sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.96.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.96.2.tgz",
+ "integrity": "sha512-sYyzzJT4G0g02azzJ8o55VFFV31XvFpdUpG+unxS0vSaYsJnSPKGoI6WdPwUucJL1wpgGfwfmntNX/Ub1uOViA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.96.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -3482,32 +3485,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/cssstyle": {
- "version": "6.2.0",
- "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz",
- "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@asamuzakjp/css-color": "^5.0.1",
- "@csstools/css-syntax-patches-for-csstree": "^1.0.28",
- "css-tree": "^3.1.0",
- "lru-cache": "^11.2.6"
- },
- "engines": {
- "node": ">=20"
- }
- },
- "node_modules/cssstyle/node_modules/lru-cache": {
- "version": "11.2.6",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
- "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "engines": {
- "node": "20 || >=22"
- }
- },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -5873,6 +5850,46 @@
}
}
},
+ "node_modules/jsdom/node_modules/@asamuzakjp/css-color": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz",
+ "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^3.0.0",
+ "@csstools/css-color-parser": "^4.0.1",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0",
+ "lru-cache": "^11.2.5"
+ }
+ },
+ "node_modules/jsdom/node_modules/cssstyle": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.0.1.tgz",
+ "integrity": "sha512-IoJs7La+oFp/AB033wBStxNOJt4+9hHMxsXUPANcoXL2b3W4DZKghlJ2cI/eyeRZIQ9ysvYEorVhjrcYctWbog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^4.1.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.0.26",
+ "css-tree": "^3.1.0",
+ "lru-cache": "^11.2.5"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/jsdom/node_modules/lru-cache": {
+ "version": "11.3.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.0.tgz",
+ "integrity": "sha512-sr8xPKE25m6vJVcrdn6NxtC0fVfuPowbscLypegRgOm0yXSqr5JNHCAY3hnusdJ7HRBW04j6Ip4khvHU778DuQ==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
diff --git a/package.json b/package.json
index 643db40..30533bd 100644
--- a/package.json
+++ b/package.json
@@ -27,12 +27,14 @@
"access": "public"
},
"peerDependencies": {
+ "@tanstack/react-query": ">=5",
"react": ">=18",
"react-dom": ">=18"
},
"devDependencies": {
"@changesets/cli": "^2.27.8",
"@eslint/js": "^9.39.2",
+ "@tanstack/react-query": "^5.96.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
@@ -84,5 +86,8 @@
},
"engines": {
"node": ">=20"
+ },
+ "overrides": {
+ "cssstyle": "6.0.1"
}
}
diff --git a/src/index.ts b/src/index.ts
index c55977d..3609053 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,3 +1,4 @@
export * from './components';
export * from './hooks';
export * from './utils';
+export * from './query';
diff --git a/src/query/createQuery.test.tsx b/src/query/createQuery.test.tsx
new file mode 100644
index 0000000..4a862a8
--- /dev/null
+++ b/src/query/createQuery.test.tsx
@@ -0,0 +1,128 @@
+import { describe, it, expect, vi } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import React from 'react';
+import { createQuery } from './createQuery';
+
+function makeWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+}
+
+describe('createQuery', () => {
+ it('returns a QueryDefinition with queryKey, queryFn, and useQuery', () => {
+ const def = createQuery(
+ (id: string) => ['user', id] as const,
+ async (id: string) => ({ id, name: 'Alice' }),
+ );
+
+ expect(typeof def.queryKey).toBe('function');
+ expect(typeof def.queryFn).toBe('function');
+ expect(typeof def.useQuery).toBe('function');
+ });
+
+ it('queryKey returns the correct readonly tuple given params', () => {
+ const def = createQuery(
+ (id: string) => ['user', id] as const,
+ async (id: string) => ({ id }),
+ );
+
+ expect(def.queryKey('123')).toEqual(['user', '123']);
+ });
+
+ it('queryKey produces a stable reference for the same params', () => {
+ const def = createQuery(
+ (id: number) => ['item', id] as const,
+ async (id: number) => id,
+ );
+
+ const first = def.queryKey(1);
+ const second = def.queryKey(1);
+ expect(first).toEqual(second);
+ });
+
+ it('queryFn calls the fetcher with the given params', async () => {
+ const fetcher = vi.fn(async (id: string) => ({ id, name: 'Bob' }));
+ const def = createQuery((id: string) => ['user', id] as const, fetcher);
+
+ const result = await def.queryFn('42');
+
+ expect(fetcher).toHaveBeenCalledWith('42');
+ expect(result).toEqual({ id: '42', name: 'Bob' });
+ });
+
+ it('queryFn return type is inferred from the fetcher (TData inference)', async () => {
+ const def = createQuery(
+ (n: number) => ['count', n] as const,
+ async (n: number) => ({ value: n * 2 }),
+ );
+
+ const result = await def.queryFn(5);
+ // TypeScript infers result as { value: number }
+ expect(result.value).toBe(10);
+ });
+
+ it('useQuery hook resolves data from the fetcher', async () => {
+ const fetcher = vi.fn(async (id: string) => ({ id, name: 'Carol' }));
+ const def = createQuery((id: string) => ['user', id] as const, fetcher);
+
+ const { result } = renderHook(() => def.useQuery('1'), {
+ wrapper: makeWrapper(),
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data).toEqual({ id: '1', name: 'Carol' });
+ expect(fetcher).toHaveBeenCalledWith('1');
+ });
+
+ it('useQuery hook shows loading state before data resolves', () => {
+ let resolve: (v: { value: number }) => void;
+ const fetcher = vi.fn(
+ () =>
+ new Promise<{ value: number }>((res) => {
+ resolve = res;
+ }),
+ );
+ const def = createQuery((n: number) => ['lazy', n] as const, fetcher);
+
+ const { result } = renderHook(() => def.useQuery(99), {
+ wrapper: makeWrapper(),
+ });
+
+ expect(result.current.isLoading).toBe(true);
+ // Resolve to avoid open handles
+ resolve!({ value: 99 });
+ });
+
+ it('useQuery respects enabled: false and does not call fetcher', () => {
+ const fetcher = vi.fn(async (id: string) => ({ id }));
+ const def = createQuery((id: string) => ['item', id] as const, fetcher);
+
+ const { result } = renderHook(() => def.useQuery('x', { enabled: false }), {
+ wrapper: makeWrapper(),
+ });
+
+ expect(fetcher).not.toHaveBeenCalled();
+ expect(result.current.fetchStatus).toBe('idle');
+ });
+
+ it('useQuery uses the key from keyFn — different params produce different keys', async () => {
+ const fetcher = vi.fn(async (id: string) => ({ id }));
+ const def = createQuery((id: string) => ['entity', id] as const, fetcher);
+
+ const wrapper = makeWrapper();
+
+ const { result: r1 } = renderHook(() => def.useQuery('a'), { wrapper });
+ const { result: r2 } = renderHook(() => def.useQuery('b'), { wrapper });
+
+ await waitFor(() => expect(r1.current.isSuccess).toBe(true));
+ await waitFor(() => expect(r2.current.isSuccess).toBe(true));
+
+ expect(r1.current.data).toEqual({ id: 'a' });
+ expect(r2.current.data).toEqual({ id: 'b' });
+ });
+});
diff --git a/src/query/createQuery.ts b/src/query/createQuery.ts
new file mode 100644
index 0000000..ec99881
--- /dev/null
+++ b/src/query/createQuery.ts
@@ -0,0 +1,33 @@
+import { useQuery as useTanstackQuery } from '@tanstack/react-query';
+import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
+
+export type QueryKeyFn = (params: TParams) => readonly unknown[];
+export type QueryFetcher = (params: TParams) => Promise;
+
+type UseQueryShorthandOptions = Omit, 'queryKey' | 'queryFn'>;
+
+export interface QueryDefinition {
+ queryKey: QueryKeyFn;
+ queryFn: QueryFetcher;
+ useQuery: (
+ params: TParams,
+ options?: UseQueryShorthandOptions,
+ ) => UseQueryResult;
+}
+
+export function createQuery(
+ keyFn: QueryKeyFn,
+ fetcher: QueryFetcher,
+): QueryDefinition {
+ return {
+ queryKey: keyFn,
+ queryFn: fetcher,
+ useQuery(params, options) {
+ return useTanstackQuery({
+ queryKey: keyFn(params),
+ queryFn: () => fetcher(params),
+ ...options,
+ });
+ },
+ };
+}
diff --git a/src/query/index.ts b/src/query/index.ts
new file mode 100644
index 0000000..a4ed052
--- /dev/null
+++ b/src/query/index.ts
@@ -0,0 +1 @@
+export * from './createQuery';
diff --git a/vitest.config.ts b/vitest.config.ts
index 2c7babe..4cc6302 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,5 +1,4 @@
import { defineConfig } from 'vitest/config';
-import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {