iT邦幫忙

2023 iThome 鐵人賽

DAY 1
0
Modern Web

一些讓你看來很強的全端- trcp 伴讀系列 第 24

Day-024. 一些讓你看來很強的全端 TRPC 伴讀 -TRPC with App router

  • 分享至 

  • xImage
  •  

先前的範例都是在 NextJSpage router ,筆者一開始以為沒有支援 app router 的使用,但仔細深入研究後 TRPC 對於 app router 的使用上支援度蠻高的,今天就來簡單 demo 一下內容~

install package

起一個 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"
  }

初始化 db

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

server api

這邊跟之前一樣如果讀者還不理解 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>;

CRUD api

//~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
          }
        })
      }),
})

app router

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

api router

// 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 ,看來是沒問題~

https://ithelp.ithome.com.tw/upload/images/20231008/20145677iXZJjHdDNt.png

TRPC client

透過 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()
      }
    }
  }
});

privider

這邊值得注意的是為了讓 trpcreact-query 成功整合一起,會需要包一個 providerwrapper 告訴 TRPC 要對應到哪個 react-queryclient,那因為 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 中我們是用 createTRPCNextcreateTRPCNextpage 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 只要 onSuccessupdate 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 的所有資料,而是透過 clientfetch ,為了解決這個問題我們需要用到 initialData

initialData

那為了拿到 initialData 這邊會透過 trpccaler ,如果讀者還記得的話前天的 test 就是透過 caller 呼叫,因為 app router 中的 server component 不能使用 useQuery 或是 useStateclient 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


上一篇
Day-023. 一些讓你看來很強的全端 TRPC 伴讀 -trpc test
下一篇
Day-025. 一些讓你看來很強的全端 TRPC 伴讀 -Token authorization ( Init )
系列文
一些讓你看來很強的全端- trcp 伴讀30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言