先前的範例都是在 NextJS
的 page router
,筆者一開始以為沒有支援 app router
的使用,但仔細深入研究後 TRPC
對於 app router
的使用上支援度蠻高的,今天就來簡單 demo
一下內容~
起一個 next
專案
> npx create-next-app@latest trpc_server_action
安裝 package
// package.json
"dependencies": {
"@prisma/client": "^5.3.1",
"@tanstack/react-query": "^4.35.3",
"@tanstack/react-query-devtools": "^4.35.3",
"@trpc/client": "^10.38.3",
"@trpc/react-query": "^10.38.3",
"@trpc/server": "^10.38.3",
"@types/node": "20.6.3",
"@types/react": "18.2.22",
"@types/react-dom": "18.2.7",
"autoprefixer": "10.4.16",
"eslint": "8.49.0",
"eslint-config-next": "13.5.2",
"next": "13.5.2",
"postcss": "8.4.30",
"react": "18.2.0",
"react-dom": "18.2.0",
"tailwindcss": "3.3.3",
"typescript": "5.2.2",
"zod": "^3.22.2"
}
init prisma
> npx prisma init --datasource-provider sqlite
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
checked Boolean @default(false)
}
migrate schema
> npx prisma migrate dev "message"
生成 prisma client
> npx prisma generate
初始化 db instance
// ~src?db/index,ts
import { PrismaClient } from "@prisma/client";
import { DefaultArgs } from "@prisma/client/runtime/library";
declare global {
var prisma:
| PrismaClient
| undefined
}
export const prisma = global.prisma || new PrismaClient(),
})
if (process.env.NODE_ENV !== 'production') global.prisma = prisma
這邊跟之前一樣如果讀者還不理解 trpc server
內容可以看之前的內容~ Day 6
//~src/server/trpc.ts
import { inferRouterInputs, inferRouterOutputs, initTRPC } from '@trpc/server'
import { prisma } from '@/db'
import { AppRouter } from '.'
export const createTRPCContext = async () => {
return {
prisma
}
}
const t = initTRPC.context<typeof createTRPCContext>().create()
export const router = t.router
export const publicProcedure = t.procedure
export type RouterInput = inferRouterInputs<AppRouter>;
export type RouterOutput = inferRouterOutputs<AppRouter>;
//~src/server/post.ts
import { z } from "zod";
import { router, publicProcedure, createTRPCContext } from "./trpc";
import { TRPCError } from "@trpc/server";
export const postsRouter = router({
getPosts:
publicProcedure
.query(async ({ ctx }) => {
const { prisma } = ctx
const posts = await prisma.post.findMany({})
return posts
}),
createPosts:
publicProcedure
.input(z.object({
title: z.string(),
}))
.mutation(async ({ ctx, input }) => {
const { title } = input
const { prisma } = ctx
if (!title) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'title is require' })
}
const newTitle = await prisma.post.create({
data: {
title
}
})
}),
deletePosts:
publicProcedure
.input(z.object({
id: z.number(),
}))
.mutation(async ({ ctx, input }) => {
const { id } = input
const { prisma } = ctx
await prisma.post.delete({
where: {
id
}
})
}),
togglePost:
publicProcedure
.input(z.object({
id: z.number(),
checked: z.boolean()
}))
.mutation(async ({ ctx, input }) => {
const { id, checked } = input
const { prisma } = ctx
await prisma.post.update({
where: {
id,
},
data: {
checked
}
})
}),
})
import { postsRouter } from "./posts";
import { router, publicProcedure, createTRPCContext } from "./trpc";
export const appRouter = router({
posts: postsRouter
})
export const appCaller = appRouter.createCaller(await createTRPCContext())
export type AppRouter = typeof appRouter
// app/api/trpc/[trpc]/route.ts
import { appRouter } from '@/server';
import { createTRPCContext } from '@/server/trpc';
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: createTRPCContext
});
export { handler as GET, handler as POST };
這邊可以順便比較一下 page
寫法跟 app
寫法有什麼差異。
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { createContext } from '../../../server/trpc/context';
import { appRouter } from '../../../server/trpc/router/_app';
// @see https://nextjs.org/docs/api-routes/introduction
export default createNextApiHandler({
router: appRouter,
createContext,
});
簡單測試一下 api
,看來是沒問題~
透過 createTRPCReact
create TRPC client
實例 ,在 app router
中我們是透過 createTRPCReact
起一個 trpc instance
。
// ~src/app/client
import { AppRouter, appCaller } from "@/server";
import { createTRPCReact } from "@trpc/react-query";
export const trpc = createTRPCReact<AppRouter>({
overrides: {
useMutation: {
async onSuccess(opt) {
// Calls the `onSuccess` defined in the `useMutation()`-options:
await opt.originalFn()
// Invalidate all queries in the react-query cache:
opt.queryClient.invalidateQueries()
}
}
}
});
這邊值得注意的是為了讓 trpc
跟 react-query
成功整合一起,會需要包一個 provider
的 wrapper
告訴 TRPC
要對應到哪個 react-query
的 client
,那因為 app router
預設是 server component
,所以記得在最上面加 'use client'
。
// ~src/app/provider.tsx
'use client'
import { trpc } from '@/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client'
import React, { PropsWithChildren, useState } from 'react'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
export const queryClient = new QueryClient()
function Provider({ children }: PropsWithChildren) {
const [trpcClient, setTrpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: 'http://localhost:3000/api/trpc',
})
]
})
)
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen />
</QueryClientProvider>
</trpc.Provider>
);
}
export default Provider
之後把 provider
包到 layout
中。
//~src/app/layout.tsx
import Provider from './provider'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>
<Provider>
{children}
</Provider>
</body>
</html>
)
}
讀者可能還記得之前用 page router
時其實沒有加 trpc.Provide
,那是因為在 page router
中我們是用 createTRPCNext
,createTRPCNext
是 page only
用法。
queryClient
共享只需要套到 createTRPCNext instance
中。
import { queryClient } from '@/components/Provider';
export const api = createTRPCNext<AppRouter>({
config(opts) {
return {
links: [
httpBatchLink({
url: `http://localhost:3000/api/trpc`,
// You can pass any HTTP headers you wish here
async headers() {
return {
};
},
}),
],
queryClient,
};
},
/**
* @link https://trpc.io/docs/ssr
**/
ssr: false,
});
透過 api.withTRPC
綁定到 page
中,
import { api } from '@/utils/api'
import type { AppProps } from 'next/app'
import "@/styles/globals.css";
import { Provider } from '@/components/Provider';
function MyApp({ Component, pageProps }: AppProps) {
return (
<Provider>
<Component {...pageProps} />
</Provider>
)
}
export default api.withTRPC(MyApp);
Provider
部分也只需要跟 react query
平常寫法就 ``
export const Provider = ({ children }: PropsWithChildren) => {
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
接著我們來接 api
吧~ 先在 page.tsx
中添加 TodoList components
// ~src/app/page.tsx
import TodoList from '@/components/TodoList'
export default async function Home() {
return (
<div>
<h1>Post Lists</h1>
<TodoList initialData={initialData} />
</div>
)
}
整個 TodoList
大致長這樣
// TodoList.tsx
'use client'
import { trpc } from '@/client'
import { Post } from '@prisma/client'
import React, { useState } from 'react'
interface TodoList {
}
function TodoList({ }: TodoList) {
const [title, setTitle] = useState<string>('')
const { data: todos, refetch } = trpc.posts.getPosts.useQuery()
const { mutateAsync: createTodo } = trpc.posts.createPosts.useMutation()
const { mutateAsync: deletePost } = trpc.posts.deletePosts.useMutation()
const { mutateAsync: togglePost } = trpc.posts.togglePost.useMutation()
return (
<div>
<input type="text" value={title} onChange={e => setTitle(e.target.value)} className='text-black' />
<button onClick={() => {
createTodo({ title })
}}>add post</button>
<div className='w-[200px] flex flex-col gap-[1rem]'>
{todos?.map(todo => (
<div className='flex justify-between items-center ' key={todo.id}>
<input
type="checkbox"
checked={!!todo.checked}
onChange={e => {
togglePost({ id: todo.id, checked: e.target.checked })
}}
/>
<p>{todo.title}</p>
<button
className='cursor-pointer bg-red-500 p-2'
onClick={() => {
deletePost({ id: todo.id })
}}
>delete</button>
</div>
))}
</div>
</div>
)
}
export default TodoList
那為了確保每次的 crud
頁面都是及時的需要加上 invalidate
機制,概念就是說,所有的 useMutate
只要 onSuccess
就 update client
專的所有 data cache
。
abortOnUnmount
則是只要 component unmount
就取消 fetch
。
export const trpc = createTRPCReact<AppRouter>({
abortOnUnmount: true,
overrides: {
useMutation: {
async onSuccess(opt) {
// Calls the `onSuccess` defined in the `useMutation()`-options:
await opt.originalFn()
// Invalidate all queries in the react-query cache:
opt.queryClient.invalidateQueries()
}
}
}
});
如此畫面就會絲滑很多~
但這時會出現一個問題就是只要重新整理頁面,畫面就會一閃一閃的,這也是因為 ssr
的關係,因為我們的 inital html
不能有包含 post
的所有資料,而是透過 client
去 fetch
,為了解決這個問題我們需要用到 initialData
。
那為了拿到 initialData
這邊會透過 trpc
的 caler
,如果讀者還記得的話前天的 test
就是透過 caller
呼叫,因為 app router
中的 server component
不能使用 useQuery
或是 useState
的 client component
用法,所以只能用 caller
的方式。
使用方式在為讀者複習一下只需要把 appRouter
呼叫 createCaller
,然後把 context
帶進去就可以了~
// ~src/server/index.ts
export const appCaller = appRouter.createCaller(await createTRPCContext())
然後再到 home page
call getPosts
傳到 TodoList props
中
// ~src/app/page.tsx
export default async function Home() {
const initialData = await appCaller.posts.getPosts()
return (
<div>
<h1>Post Lists</h1>
<TodoList initialData={initialData} />
</div>
)
}
接著把 initialData
放到 useQuery
的第二個參數。
interface TodoList {
initialData: Post[]
}
function TodoList({ initialData }: TodoList) {
const [title, setTitle] = useState<string>('')
const utils = trpc.useContext()
const { data: todos, refetch } = trpc.posts.getPosts.useQuery(undefined, {
initialData
})
//..
如此一來重新整理後畫面就不會一直閃了,透過 initialdata
方式提前把 data
放到 initial html
中,來優化白頁問題。
https://github.com/Danny101201/next13_trpc/tree/main