diff --git a/boilerplates/express/files/server/entry.ts b/boilerplates/express/files/server/entry.ts index 81ea66c7f..8a21c27a9 100644 --- a/boilerplates/express/files/server/entry.ts +++ b/boilerplates/express/files/server/entry.ts @@ -6,6 +6,7 @@ import { createTodoHandler } from "@batijs/shared-server/server/create-todo-hand import { telefuncHandler } from "@batijs/telefunc/server/telefunc-handler"; import { trpcHandler } from "@batijs/trpc/server/trpc-handler"; import { tsRestHandler } from "@batijs/ts-rest/server/ts-rest-handler"; +import { getTodosHandler } from "@batijs/react-query/server/todo-handlers"; import { apply, serve } from "@photonjs/express"; import express from "express"; @@ -35,6 +36,8 @@ function startApp() { //# BATI.has("ts-rest") // ts-rest route. See https://ts-rest.com tsRestHandler, + //# BATI.has("react-query") + getTodosHandler, //# !BATI.has("telefunc") && !BATI.has("trpc") && !BATI.has("ts-rest") createTodoHandler, ]); diff --git a/boilerplates/fastify/files/server/entry.ts b/boilerplates/fastify/files/server/entry.ts index ac2bd03dd..0ae94f60c 100644 --- a/boilerplates/fastify/files/server/entry.ts +++ b/boilerplates/fastify/files/server/entry.ts @@ -6,6 +6,7 @@ import { createTodoHandler } from "@batijs/shared-server/server/create-todo-hand import { telefuncHandler } from "@batijs/telefunc/server/telefunc-handler"; import { trpcHandler } from "@batijs/trpc/server/trpc-handler"; import { tsRestHandler } from "@batijs/ts-rest/server/ts-rest-handler"; +import { getTodosHandler } from "@batijs/react-query/server/todo-handlers"; import { apply, serve } from "@photonjs/fastify"; import fastify from "fastify"; import rawBody from "fastify-raw-body"; @@ -42,6 +43,8 @@ async function startApp() { //# BATI.has("ts-rest") // ts-rest route. See https://ts-rest.com tsRestHandler, + //# BATI.has("react-query") + getTodosHandler, //# !BATI.has("telefunc") && !BATI.has("trpc") && !BATI.has("ts-rest") createTodoHandler, ]); diff --git a/boilerplates/h3/files/server/entry.ts b/boilerplates/h3/files/server/entry.ts index 80000c296..1197e4d09 100644 --- a/boilerplates/h3/files/server/entry.ts +++ b/boilerplates/h3/files/server/entry.ts @@ -6,6 +6,7 @@ import { createTodoHandler } from "@batijs/shared-server/server/create-todo-hand import { telefuncHandler } from "@batijs/telefunc/server/telefunc-handler"; import { trpcHandler } from "@batijs/trpc/server/trpc-handler"; import { tsRestHandler } from "@batijs/ts-rest/server/ts-rest-handler"; +import { getTodosHandler } from "@batijs/react-query/server/todo-handlers"; import { apply, serve } from "@photonjs/h3"; import { createApp } from "h3"; @@ -35,6 +36,8 @@ function startApp() { //# BATI.has("ts-rest") // ts-rest route. See https://ts-rest.com tsRestHandler, + //# BATI.has("react-query") + getTodosHandler, //# !BATI.has("telefunc") && !BATI.has("trpc") && !BATI.has("ts-rest") createTodoHandler, ]); diff --git a/boilerplates/hono/files/server/entry.ts b/boilerplates/hono/files/server/entry.ts index 2bc9a652b..9c7e876cf 100644 --- a/boilerplates/hono/files/server/entry.ts +++ b/boilerplates/hono/files/server/entry.ts @@ -6,6 +6,7 @@ import { createTodoHandler } from "@batijs/shared-server/server/create-todo-hand import { telefuncHandler } from "@batijs/telefunc/server/telefunc-handler"; import { trpcHandler } from "@batijs/trpc/server/trpc-handler"; import { tsRestHandler } from "@batijs/ts-rest/server/ts-rest-handler"; +import { getTodosHandler } from "@batijs/react-query/server/todo-handlers"; import { apply, serve } from "@photonjs/hono"; import { Hono } from "hono"; import { getMiddlewares } from "vike-photon/universal-middlewares"; @@ -43,6 +44,8 @@ function startApp() { //# BATI.has("ts-rest") // ts-rest route. See https://ts-rest.com tsRestHandler, + //# BATI.has("react-query") + getTodosHandler, //# !BATI.has("telefunc") && !BATI.has("trpc") && !BATI.has("ts-rest") createTodoHandler, ]); diff --git a/boilerplates/react-query/bati.config.ts b/boilerplates/react-query/bati.config.ts new file mode 100644 index 000000000..685c8e659 --- /dev/null +++ b/boilerplates/react-query/bati.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "@batijs/core/config"; + +export default defineConfig({ + if(meta) { + return meta.BATI.has("react-query"); + }, +}); diff --git a/boilerplates/react-query/files/$package.json.ts b/boilerplates/react-query/files/$package.json.ts new file mode 100644 index 000000000..239da3e14 --- /dev/null +++ b/boilerplates/react-query/files/$package.json.ts @@ -0,0 +1,7 @@ +import { loadPackageJson, type TransformerProps } from "@batijs/core"; + +export default async function getPackageJson(props: TransformerProps) { + const packageJson = await loadPackageJson(props, await import("../package.json").then((x) => x.default)); + + return packageJson.addDependencies(["@tanstack/react-query", "vike-react-query"]); +} diff --git a/boilerplates/react-query/files/pages/+config.ts b/boilerplates/react-query/files/pages/+config.ts new file mode 100644 index 000000000..61be76e12 --- /dev/null +++ b/boilerplates/react-query/files/pages/+config.ts @@ -0,0 +1,13 @@ +import type { Config } from "vike/types"; +import vikePhoton from "vike-photon/config"; +import vikeReact from "vike-react/config"; +import vikeReactQuery from "vike-react-query/config"; + +const config: Config = { + extends: [vikeReact, vikeReactQuery, vikePhoton], + photon: { + server: "../server/entry.ts", + }, +} satisfies Config; + +export default config; diff --git a/boilerplates/react-query/files/pages/todo/!+data.ts b/boilerplates/react-query/files/pages/todo/!+data.ts new file mode 100644 index 000000000..e69de29bb diff --git a/boilerplates/react-query/files/pages/todo/TodoList.tsx b/boilerplates/react-query/files/pages/todo/TodoList.tsx new file mode 100644 index 000000000..c17075de7 --- /dev/null +++ b/boilerplates/react-query/files/pages/todo/TodoList.tsx @@ -0,0 +1,73 @@ +import { useMutation, useSuspenseQuery, useQueryClient } from "@tanstack/react-query"; +import { type ChangeEvent, useState } from "react"; +import { createTodo, fetchTodos, type TodoItem } from "../../services/api"; + +export function TodoList() { + const queryClient = useQueryClient(); + const [newTodo, setNewTodo] = useState(""); + + const { data: todoItems = [] } = useSuspenseQuery({ + queryKey: ["todos"], + queryFn: fetchTodos, + }); + + const createMutation = useMutation({ + mutationFn: createTodo, + onMutate: async (text) => { + await queryClient.cancelQueries({ queryKey: ["todos"] }); + const previousTodos = queryClient.getQueryData(["todos"]); + queryClient.setQueryData(["todos"], (old = []) => [...old, { text }]); + return { previousTodos }; + }, + onError: (_err, _text, context) => { + if (context?.previousTodos) { + queryClient.setQueryData(["todos"], context.previousTodos); + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["todos"] }); + }, + }); + + return ( + <> + +
+
{ + ev.preventDefault(); + if (newTodo.trim()) { + createMutation.mutate(newTodo); + setNewTodo(""); + } + }} + > + ) => setNewTodo(ev.currentTarget.value)} + value={newTodo} + //# BATI.has("tailwindcss") + className={ + "bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 w-full sm:w-auto p-2 mr-1 mb-1" + } + /> + +
+
+ + ); +} diff --git a/boilerplates/react-query/files/server/todo-handlers.ts b/boilerplates/react-query/files/server/todo-handlers.ts new file mode 100644 index 000000000..7a4a6a272 --- /dev/null +++ b/boilerplates/react-query/files/server/todo-handlers.ts @@ -0,0 +1,50 @@ +import * as d1Queries from "@batijs/d1-sqlite/database/d1/queries/todos"; +import type { dbD1, dbSqlite } from "@batijs/drizzle/database/drizzle/db"; +import * as drizzleQueries from "@batijs/drizzle/database/drizzle/queries/todos"; +import type { dbKysely, dbKyselyD1 } from "@batijs/kysely/database/kysely/db"; +import * as kyselyQueries from "@batijs/kysely/database/kysely/queries/todos"; +import type { db as sqliteDb } from "@batijs/sqlite/database/sqlite/db"; +import * as sqliteQueries from "@batijs/sqlite/database/sqlite/queries/todos"; +import { enhance, type UniversalHandler } from "@universal-middleware/core"; + +export const getTodosHandler: UniversalHandler< + Universal.Context & + BATI.If<{ + 'BATI.has("sqlite") && !BATI.hasD1': { db: ReturnType }; + 'BATI.has("drizzle") && !BATI.hasD1': { db: ReturnType }; + 'BATI.has("drizzle")': { db: ReturnType }; + 'BATI.has("kysely") && !BATI.hasD1': { db: ReturnType }; + 'BATI.has("kysely")': { db: ReturnType }; + "BATI.hasD1": { db: D1Database }; + _: object; + }> +> = enhance( + async (_request, _context, _runtime) => { + let todoItems: { text: string }[]; + + if (BATI.has("drizzle")) { + todoItems = await drizzleQueries.getAllTodos(_context.db); + } else if (BATI.has("sqlite") && !BATI.hasD1) { + todoItems = sqliteQueries.getAllTodos(_context.db); + } else if (BATI.has("kysely")) { + todoItems = await kyselyQueries.getAllTodos(_context.db); + } else if (BATI.hasD1) { + todoItems = await d1Queries.getAllTodos(_context.db); + } else { + todoItems = [{ text: "Buy milk" }, { text: "Buy strawberries" }]; + } + + return new Response(JSON.stringify(todoItems), { + status: 200, + headers: { + "content-type": "application/json", + }, + }); + }, + { + name: "my-app:get-todos-handler", + path: `/api/todo`, + method: ["GET"], + immutable: false, + }, +); diff --git a/boilerplates/react-query/files/services/api.ts b/boilerplates/react-query/files/services/api.ts new file mode 100644 index 000000000..8f886dc2a --- /dev/null +++ b/boilerplates/react-query/files/services/api.ts @@ -0,0 +1,32 @@ +export type TodoItem = { text: string }; + +function getBaseUrl(): string { + if (typeof window !== "undefined") { + return ""; + } + const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000; + return `http://localhost:${port}`; +} + +export async function fetchTodos(): Promise { + const baseUrl = getBaseUrl(); + const response = await fetch(`${baseUrl}/api/todo`); + if (!response.ok) { + throw new Error("Failed to fetch todos"); + } + return response.json(); +} + +export async function createTodo(text: string): Promise { + const baseUrl = getBaseUrl(); + const response = await fetch(`${baseUrl}/api/todo/create`, { + method: "POST", + body: JSON.stringify({ text }), + headers: { + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + throw new Error("Failed to create todo"); + } +} diff --git a/boilerplates/react-query/package.json b/boilerplates/react-query/package.json new file mode 100644 index 000000000..8f3cae5ee --- /dev/null +++ b/boilerplates/react-query/package.json @@ -0,0 +1,68 @@ +{ + "name": "@batijs/react-query", + "private": true, + "version": "0.0.1", + "description": "", + "type": "module", + "scripts": { + "check-types": "tsc --noEmit", + "build": "bati-compile-boilerplate" + }, + "keywords": [], + "author": "", + "license": "MIT", + "devDependencies": { + "@batijs/compile": "workspace:*", + "@tanstack/react-query": "^5.90.16", + "@types/node": "^20.19.25", + "@types/react": "^19.2.7", + "@universal-middleware/core": "^0.4.14", + "react": "^19.2.1", + "vike": "^0.4.250", + "vike-react": "^0.6.18", + "vike-react-query": "^0.1.12", + "vite": "^7.3.0" + }, + "dependencies": { + "@batijs/core": "workspace:*" + }, + "files": [ + "dist/" + ], + "exports": { + "./pages/+config": { + "types": "./dist/types/pages/+config.d.ts" + }, + "./pages/todo/!+data": { + "types": "./dist/types/pages/todo/!+data.d.ts" + }, + "./services/api": { + "types": "./dist/types/services/api.d.ts" + }, + "./pages/todo/TodoList": { + "types": "./dist/types/pages/todo/TodoList.d.ts" + }, + "./server/todo-handlers": { + "types": "./dist/types/server/todo-handlers.d.ts" + } + }, + "typesVersions": { + "*": { + "pages/+config": [ + "./dist/types/pages/+config.d.ts" + ], + "pages/todo/!+data": [ + "./dist/types/pages/todo/!+data.d.ts" + ], + "services/api": [ + "./dist/types/services/api.d.ts" + ], + "pages/todo/TodoList": [ + "./dist/types/pages/todo/TodoList.d.ts" + ], + "server/todo-handlers": [ + "./dist/types/server/todo-handlers.d.ts" + ] + } + } +} diff --git a/boilerplates/react-query/tsconfig.json b/boilerplates/react-query/tsconfig.json new file mode 100644 index 000000000..17c3990c4 --- /dev/null +++ b/boilerplates/react-query/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": ["../tsconfig.base.json"], + "compilerOptions": { + "types": ["vite/client", "@types/node", "vike-react", "@batijs/core/types"], + "jsx": "react-jsx", + "jsxImportSource": "react", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "baseUrl": "." + } +} diff --git a/packages/cli/rules.ts b/packages/cli/rules.ts index 66c067ed5..3ad268bdd 100644 --- a/packages/cli/rules.ts +++ b/packages/cli/rules.ts @@ -59,6 +59,9 @@ Choose one of them or remove selected Server`, [RulesMessage.ERROR_SHADCN_R_REACT]: error( `${inverse(bold("shadcn/ui"))} is only compatible with ${inverse(bold("React"))}`, ), + [RulesMessage.ERROR_VIKE_REACT_QUERY_R_REACT]: error( + `${inverse(bold("React"))} is required when using ${inverse(bold("React Query"))}`, + ), [RulesMessage.WARN_SHADCN_R_TAILWINDCSS]: warning( `${inverse(bold("shadcn/ui"))} integration is tied to ${inverse(bold("TailwindCSS"))}. Using another CSS library with it may have unpredictable behaviour.`, ), diff --git a/packages/features/src/features.ts b/packages/features/src/features.ts index a07ed4b53..2183fb0dd 100644 --- a/packages/features/src/features.ts +++ b/packages/features/src/features.ts @@ -282,6 +282,26 @@ export const features = [ }, ], }, + { + category: "Data fetching", + label: "React Query", + flag: "react-query", + dependsOn: ["react"], + image: "https://tanstack.com/images/logos/logo-color-100.png", + url: "https://tanstack.com/query", + tagline: "Powerful asynchronous state management, server-state utilities and data fetching", + repo: "tanstack/query", + links: [ + { + label: "Getting started", + href: "https://tanstack.com/query/latest/docs/react/overview", + }, + { + label: "vike-react-query", + href: "https://github.com/vikejs/vike-react-query", + }, + ], + }, // Server { diff --git a/packages/features/src/rules/enum.ts b/packages/features/src/rules/enum.ts index c6745c5b8..526685b87 100644 --- a/packages/features/src/rules/enum.ts +++ b/packages/features/src/rules/enum.ts @@ -18,6 +18,8 @@ export enum RulesMessage { ERROR_MANTINE_R_REACT, // shadcn/ui is only compatible with React ERROR_SHADCN_R_REACT, + // React is required when using React Query + ERROR_VIKE_REACT_QUERY_R_REACT, // --- WARNING // shadcn/ui integration is tailored for tailwind diff --git a/packages/features/src/rules/rules.ts b/packages/features/src/rules/rules.ts index d50c95d78..7945e866c 100644 --- a/packages/features/src/rules/rules.ts +++ b/packages/features/src/rules/rules.ts @@ -49,6 +49,13 @@ export default [ return false; }), + filter(RulesMessage.ERROR_VIKE_REACT_QUERY_R_REACT, (fts) => { + if (fts.has("react-query")) { + return !fts.has("react"); + } + + return false; + }), filter(RulesMessage.WARN_SHADCN_R_TAILWINDCSS, (fts) => { if (fts.has("shadcn-ui")) { return fts.has("daisyui") || fts.has("compiled-css"); diff --git a/website/components/RulesMessages.tsx b/website/components/RulesMessages.tsx index 23f1fea64..dd943636d 100644 --- a/website/components/RulesMessages.tsx +++ b/website/components/RulesMessages.tsx @@ -177,6 +177,18 @@ export const rulesMessages = { ); }), + [RulesMessage.ERROR_VIKE_REACT_QUERY_R_REACT]: error(() => { + return ( + + React is required when using React Query. +
    +
  • + Either pick React or unselect React Query +
  • +
+
+ ); + }), [RulesMessage.WARN_SHADCN_R_TAILWINDCSS]: warning(() => { return (