今天要來簡單 demo crud
內容摟~ 所有 demo
都會在底下的 repo
,所以各位小夥伴可以自行取用~
🚩 code
先起一個 nextjs 專案
> npx create-next-app@latest todolist
這是本次會用到的套件,比較特別的是本次會使用 react-hook-form
搭配 zod
提交表單,以及搭配 react-query-devtools
去觀察 api
的結果。
// package.json
"dependencies": {
"@hookform/resolvers": "^3.3.1",
"@prisma/client": "^5.2.0",
"@tailwindcss/forms": "^0.5.6",
"@tanstack/react-query": "^4.33.0",
"@tanstack/react-query-devtools": "^4.33.0",
"@trpc/client": "^10.38.0",
"@trpc/next": "^10.37.1",
"@trpc/react-query": "^10.38.0",
"@trpc/server": "^10.38.0",
"@types/node": "20.5.3",
"@types/react": "18.2.21",
"@types/react-dom": "18.2.7",
"autoprefixer": "10.4.15",
"clsx": "^2.0.0",
"eslint": "8.47.0",
"eslint-config-next": "13.4.19",
"next": "13.4.19",
"postcss": "8.4.28",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.45.4",
"react-icons": "^4.10.1",
"tailwindcss": "3.3.3",
"typescript": "5.1.6",
"zod": "^3.22.2"
},
"devDependencies": {
"prisma": "^5.2.0"
}
src
└── validate
└── api
└── post.ts
這邊的先定義 post
的 crud
的 schema
,zod
除了可以幫你 validate
input
外,還可以透過 z.infer
幫你做型別推斷。
// ~src/validate/api/post.ts
import { z } from "zod";
export const getPostSchema = z.object({
post_id: z.string()
})
export type GetPostSchema = z.infer<typeof getPostSchema>
export const createPostSchema = z.object({
title: z.string().min(1, { message: 'title required' }),
content: z.string().optional(),
published: z.boolean().default(false)
})
export type CreatePostSchema = z.infer<typeof createPostSchema>
export const togglePostPuPublishedSchema = z.object({
id: z.number(),
published: z.boolean()
})
export type TogglePostPuPublishedSchema = z.infer<typeof togglePostPuPublishedSchema>
export const deletePostSchema = z.object({
id: z.number()
})
export type DeletePostSchema = z.infer<typeof deletePostSchema>
首先我們需要先處理 form component
,首先在 src
資料夾中先新增 components
資料夾
src
└── components
├── Button.tsx
├── Input.tsx
└── PostForm.tsx
這邊值得說明的是我是透過 clsx
這個套件幫我整合 className
,主要目的是將有條事件構造 string
字段轉換成有序列的 string
,例如:
import { clsx } from 'clsx';
// Strings (variadic)
clsx('foo', true && 'bar', 'baz');
//=> 'foo bar baz'
這樣的寫法更加直觀與方便管理 className
內容。
import React, { PropsWithChildren } from 'react'
import clsx from "clsx"
interface ButtonProps extends PropsWithChildren {
onClick?: () => void,
type?: "button" | "submit" | "reset" | undefined;
disabled?: boolean
fullWidth?: boolean
secondary?: boolean
danger?: boolean
className?: string
}
export const Button = ({
onClick,
type,
disabled,
fullWidth,
secondary,
danger,
className,
children,
}: ButtonProps) => {
return (
<button
onClick={onClick}
type={type}
disabled={disabled}
className={clsx(`
flex
justify-center
rounded-md
px-3
py-2
text-sm
font-semibold
focus-visible:outline
focus-visible:outline-2
focus-visible:outline-offset-2
`,
disabled && 'opacity-50 cursor-default',
fullWidth && 'w-full',
secondary ? 'text-gray-900' : 'text-white',
danger && 'bg-rose-500 hover:bg-rose-600 focus-visible:outline-rose-600',
!secondary && !danger && 'bg-sky-500 hover:bg-sky-600 focus-visible:outline-sky-600',
className
)}
>
{children}
</button>
)
}
這邊是會在 reactHook form 中使用的 input,比較特別的是,register
、error
這是 react hook form 中 useForm
傳下來的 props 。
import React, { ComponentProps, HTMLInputTypeAttribute, useState } from 'react'
import { FieldError, FieldErrors, FieldValues, Path, UseFormRegister } from 'react-hook-form'
import { AiFillEye, AiFillEyeInvisible } from 'react-icons/ai'
import clsx from "clsx";
interface InputProps<TForm extends FieldValues> extends ComponentProps<'input'> {
label: string,
id: Path<TForm>,
register: UseFormRegister<TForm>,
error: FieldError | undefined,
type?: HTMLInputTypeAttribute
require?: boolean
disable?: boolean
}
export const Input = <TForm extends FieldValues>({
register,
id,
label,
error,
disable,
require,
type = 'text',
...rest }: InputProps<TForm>) => {
return (
<div>
<label
htmlFor={id}
className="
text-sm
font-medium
leading-6
text-gray-900
flex
items-center
"
>
{label}
{require && <span className='text-red-500'>*</span>}
</label>
<div className="mt-2 relative">
<input
type={type}
className={clsx(
`
form-input
block
w-full
rounded-md
border-0
py-1.5
text-gray-900
shadow-sm
ring-1
ring-inset
ring-gray-300
placeholder:text-gray-400
focus:ring-2
focus:ring-inset
sm:text-sm
sm:leading-6`,
disable && 'opacity-50 cursor-default',
error?.message ? ' focus:ring-rose-500' : 'focus:ring-sky-500',
)}
{...register(id)}
{...rest}
/>
{error && <p className='text-red-500'>{error.message}</p>}
</div>
</div>
)
}
首先先加 zod schema
給 form submit
用,之後把 register
, errors
分別給 Input
中使用。
import { RouterInputs, api } from '@/utils/api'
import { createPostSchema ,type CreatePostSchema} from '@/validate/api/post'
import { zodResolver } from '@hookform/resolvers/zod'
import React from 'react'
import { FieldError, FieldErrors, SubmitHandler, useForm } from 'react-hook-form'
import { Input } from './Input'
import { Button } from './Button'
import { queryClient } from './provider'
export const PostForm = () => {
const { register, formState: { errors }, handleSubmit } = useForm<RouterInputs['posts']['addPost']>({
resolver: zodResolver(createPostSchema),
mode: 'onChange',
defaultValues: {
published: false
}
})
const onSubmit: SubmitHandler<CreatePostSchema> = async (data) => {
console.log(data)
}
return (
<div
className="
bg-white
px-4
py-8
shadow
sm:rounded-lg
sm:px-10
"
>
<form
className="space-y-6"
onSubmit={handleSubmit(onSubmit)}
>
<Input
label='Title'
register={register}
id='title'
disable={false}
required
error={errors.title}
/>
<Input
label='content'
register={register}
id='content'
disable={false}
error={errors.content}
/>
<Button type='submit' disabled={false} fullWidth>submit</Button>
</form>
</div>
);
}
之後把 PostForm
放到 src/page/index.tsx
// src/page/index.tsx
import { PostForm } from "@/components/PostForm";
export default function Home() {
return (
<div className="bg-gray-100 min-h-screen overflow-y-auto p-4">
<h2 className="text-center text-3xl">Create posts</h2>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<PostForm />
</div>
</div>
);
}
整個畫面應該長現在樣子
如果 Title
沒有加的話會有 error
,這樣你就成功完成 react-hook-form
搭配 zod
了
這邊會定義 reqct query Provider
提供給 trpc
使用,使先新增 Provider.tsx
src
└── components
├── Button.tsx
├── Input.tsx
├── PostForm.tsx
└── Provider.tsx
生成一個 queryClient
實例,同時添加 ReactQueryDevtools
// ~src/components/Provider.tsx
import React, { PropsWithChildren } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
export const queryClient = new QueryClient()
export const Provider = ({ children }: PropsWithChildren) => {
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
之後把 Provider
包到 _app.tsx
中
// !src/_app.tsx
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);
如果畫面左下角有代表你成功添加 devtool 了。
這邊還差一個設定就是跟 trpc
共享同一個 queryClient
,日後需要做 validate query cache
會用到。
// ~src/utils/api.ts
export const api = createTRPCNext<AppRouter>({
config(opts) {
return {
links: [
httpBatchLink({
/**
* If you want to use SSR, you need to use the server's full URL
* @link https://trpc.io/docs/ssr
**/
url: `${getBaseUrl()}/api/trpc`,
// maxURLLength: 2083, // 限制 413 Payload Too Large、414 URI Too Long和404 Not Found
// You can pass any HTTP headers you wish here
async headers() {
return {
// authorization: getAuthCookie(),
};
},
}),
],
queryClient
};
},
/**
* @link https://trpc.io/docs/ssr
**/
ssr: false,
});
這邊會繼續沿用 day 7的 router,首先我們先把 router
歸類,post
就是我們這次的 todo list
會用到的 route
,root
則是所有 router
的 root
。
src
├── server
├── api
│ ├── post.ts
│ ├── root.ts
│ └── trpc.ts
└── db.ts
讀筆可以仔細看看會發現 trpc
的 router
是可以 nest
使用寫法跟 express
定義 router
非常像。
// ~/src/server/api/root
import * as trpc from '@trpc/server';
import { publicProcedure, router } from './trpc';
import { z } from 'zod';
import { TRPCError, initTRPC } from '@trpc/server';
import { CreateNextContextOptions } from '@trpc/server/adapters/next';
import { postsRouter } from './post';
export const appRouter = router({
greeting: publicProcedure
.input(z.object({
name: z.string()
}))
.query(({ input, ctx }) => `hello ${input.name} `),
posts: postsRouter
});
// Export only the type of a router!
// This prevents us from importing server code on the client.
export type AppRouter = typeof appRouter;
首先先寫出 GET/ post
跟 GET/ posts
,GET/ posts
比較簡單就是直接 findMany
,GET/ post
則是會去檢查是否有 post
,沒有就 throw error
,這邊的 TRPCError
是 trpc
自己封裝的 error response
,可以透過 code
跟 message
定義你的內容。
// ~/src/server/api/post
import { z } from "zod";
import { publicProcedure, router } from "./trpc";
import { TRPCError } from "@trpc/server";
import {
getPostSchema,
createPostSchema,
} from "@/validate/api/post";
export const postsRouter = router({
getPosts: publicProcedure
.query(async ({ ctx }) => {
const { prisma } = ctx
const posts = await prisma.post.findMany({})
return posts
}),
getPost: publicProcedure.input(getPostSchema)
.query(async ({ input, ctx }) => {
const { post_id } = input
const { prisma } = ctx
const post = await prisma.post.findFirst({
where: {
id: Number(post_id)
}
})
if (!post) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'post not found' })
}
return post
})
})
之後我們到 index.page
這邊因為你上面有定義 getPosts
這個 route
,所以 api
這個 trpc
的 instance
,會自動有型別提示有getPosts 內容
,最後根據你的 route
是 query
還是 mutate
決定呼叫什麼,這邊的 getPosts
是 query
,所以是 useQuery
。
useQuery
這邊有幾個主要的 return value
這邊簡單跟讀者介紹:
data
: 在 getPosts
中 return 的 value。isLoading
: 檢查 api
loading 狀態。有了 react query
的搭配整 loading state
寫法更簡潔了~
import { PostForm } from "@/components/PostForm";
import { api } from "@/utils/api";
import { AiFillDelete } from "react-icons/ai";
export default function Home() {
const { data: posts, isLoading } = api.posts.getPosts.useQuery()
if (isLoading) return 'isLoading'
return (
<div className="bg-gray-100 min-h-screen overflow-y-auto p-4">
<h2 className="text-center text-3xl">Create posts</h2>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<PostForm />
<ul className="flex flex-col gap-[1rem] justify-center mt-5">
{posts.map((post, index) => (
<li key={post.id} className="flex items-center justify-between">
<label
htmlFor=""
className={`
text-2xl
${!!post.published && "line-through"}
`}
>{post.title}</label>
<AiFillDelete
color="red"
className="cursor-pointer"
size={20}
/>
</li>
))}
</ul>
</div>
</div>
);
}
但因為現在沒有資料所以底下是空的,所以我們需要先添加一些 post data
~
讀者還記得昨天介紹的 prisma studio
嗎~ prisma studio
除了可以查看資料結果外,你還可以手動添加 record data
或是 delete data
,甚至是 filter
output
都很方便~
這邊補一個 post schema
給讀者對照
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
}
首先先在終端機打一下 prisma studio cli
> npx prisma studio
打開 studio
-> 點選 post
-> add record
因為 title
是必填的所以只需要填寫 title
欄位就好,其餘的欄位可以隨讀者心情看要不要改。
筆者先添加兩筆資料就好
最後看畫面恭喜你成功抓到資料了~
同時查看 react query devtool
,query cache
也有東西了。
今天內容先到這邊,明天我們繼續完成 CRUD
~
✅ 前端社群 :
https://lihi3.cc/kBe0Y