【Next.js 15 ✕ Hono】 React 19最新Hooksで作るRPCフルスタック開発

# はじめに
この記事では、Next.js 15とReact19およびHonoを活用してRPC機能を搭載したフルスタック開発をする方法について記載します。

特に、Next.jsのRoute HandlersをHonoで置き換えることで、HonoのRPC機能を活用し、APIルートをより堅牢かつ型安全にする実践的な手法を紹介します。これにより、APIエンドポイントのURLや戻り値の型を自動で取得できるため、効率的で信頼性の高いコードを書くことが可能になります。

それでは，開発のスピードと型安全性を両立する方法を学んでいきましょう！

# アプリ概要
今回作成するアプリですが、以下の機能を持つSNS likeなアプリケーションを作っていこうと思います。

- ユーザー新規登録機能
- ログイン機能
    - GitHub OAuth認証
- 投稿機能
- 投稿詳細取得
- 投稿編集機能
- 投稿削除機能
- いいね機能（Optimisitic UI更新）

また、今回のソースコードは以下になります。

https://github.com/sc30gsw/next15-first-sns-app-with

# 技術スタック
- フロントエンドフレームワーク: [Next.js](https://nextjs.org/)
- APIフレームワーク: [Hono](https://hono.dev/)
- DB: [Neon DB](https://neon.tech/docs/introduction)
- ORM: [Drizzle](https://orm.drizzle.team/)
- 認証: [Auth.js](https://authjs.dev/)
- hosting: [Vercel](https://vercel.com/docs)
- スタイリング
    -  CSSフレームワーク: [Tailwind CSS](https://tailwindcss.com/)
    -  コンポーネントライブラリ: [Justd](https://getjustd.com/)
- Linter & Formmater: [biome](https://biomejs.dev/)
- package manager: [Bun](https://bun.sh/docs)
- form管理ライブラリ: [react-hook-form](https://react-hook-form.com/)・[Conform](https://conform.guide/)
- バリデーションライブラリ: [zod](https://zod.dev/)

# 実装
実装ですが、以下の手順で進めようと思います。

1. Drizzle ORMのセットアップ
    1. DrizzleとNeon DBの連携
    2. schema作成
2. Auth.jsによる認証実装
    1. SignUpページ作成
    2. SignInページ作成
3. HonoによるAPI実装
4. 投稿機能・編集・削除機能のServer Actions実装
5. いいね機能のOptimistic UI更新
6. Vercel deploy

## Drizzle ORMのセットアップ
まず、Drizzle ORMのセットアップを行っていきます。
事前にNeon DBのプロジェクトを作成して、DBを作成し、`postgresql://neondb`のような形式から始まる`DATABASE_URL`を控えておいてください。

### Neon DBと連携
以下に従い、ライブラリの導入等を行っていきます。

https://orm.drizzle.team/docs/get-started/neon-new

手順に従っていくと環境変数の設定を行う部分があると思いますが、環境変数についてはtype safeとなるよう扱いたいので、`t3-oss/ts-env`を導入して管理していこうと思います。

以下のコマンドを実行して、必要なライブラリを導入しましょう。
```shell:fish
bun add @t3-oss/env-nextjs zod
```


以下のようにすることで、環境変数をtype safeに扱うことができるようになります。
また、私の場合、あとからAuth.jsを使用するため、ドキュメント記載の環境変数名ではなく、`AUTH_DRIZZLE_URL`としています。

```ts:env.ts
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'

export const env = createEnv({
  server: {
    NODE_ENV: z.enum(['development', 'production']),
    AUTH_DRIZZLE_URL: z.string().url(),
  },
  client: {
    NEXT_PUBLIC_APP_URL: z.string().url(),
  },
  runtimeEnv: {
    NODE_ENV: process.env.NODE_ENV,
    AUTH_DRIZZLE_URL: process.env.AUTH_DRIZZLE_URL,
    NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
  },
})
```

そして、`drizzle`のDB連携の部分ですが、以下のようにします。
schemaについては次項で作成します。
```ts:libs/db/drizzle.ts
import { env } from '@/env'
import * as schema from '@/libs/db/schema'
import { drizzle } from 'drizzle-orm/neon-http'

if (!env.AUTH_DRIZZLE_URL) {
  throw new Error(`AUTH_DRIZZLE_URL is not defined ${env.AUTH_DRIZZLE_URL}`)
}

export const db = drizzle(env.AUTH_DRIZZLE_URL, { schema })
```

### schema作成
以下のようにアプリ要件を満たすため、`users`・`accounts`・`sessions`・`posts`・`likes`の5つのschemaを作成します。

なお、`users`・`accounts`・`sessions`については、Auth.jsのドキュメントに記載があるものを少し編集したものを使用します。
※ `verificationTokens`や`authenticators`は今回は使用しないので、定義からは除外しています
また、`users`については[bcrypt.js](https://github.com/dcodeIO/bcrypt.js)によるhash化したpasswordと各種dateを持つようにしています

https://authjs.dev/getting-started/adapters/drizzle#schemas

そして、それ以外の`posts`や`likes`などはよくある構成になっていると思います。
各関数などについて詳しく知りたい方はdrizzleのドキュメントを参照してみてください。

```ts:libs/db/schema.ts
import { relations } from 'drizzle-orm'
import {
  index,
  integer,
  pgTable,
  primaryKey,
  text,
  timestamp,
} from 'drizzle-orm/pg-core'
import type { AdapterAccountType } from 'next-auth/adapters'

export const users = pgTable('users', {
  id: text('id')
    .primaryKey()
    .$defaultFn(() => crypto.randomUUID()),
  name: text('name'),
  email: text('email').unique(),
  emailVerified: timestamp('emailVerified', { mode: 'date' }),
  hashedPassword: text('hashedPassword'),
  image: text('image'),
  createdAt: timestamp('created_at').notNull().defaultNow(),
  updatedAt: timestamp('updated_at')
    .defaultNow()
    .$onUpdate(() => new Date()),
})

export const accounts = pgTable(
  'account',
  {
    userId: text('userId')
      .notNull()
      .references(() => users.id, { onDelete: 'cascade' }),
    type: text('type').$type<AdapterAccountType>().notNull(),
    provider: text('provider').notNull(),
    providerAccountId: text('providerAccountId').notNull(),
    refresh_token: text('refresh_token'),
    access_token: text('access_token'),
    expires_at: integer('expires_at'),
    token_type: text('token_type'),
    scope: text('scope'),
    id_token: text('id_token'),
    session_state: text('session_state'),
  },
  (account) => ({
    compoundKey: primaryKey({
      columns: [account.provider, account.providerAccountId],
    }),
  }),
)

export const sessions = pgTable('sessions', {
  sessionToken: text('session_token').primaryKey(),
  userId: text('user_id')
    .notNull()
    .references(() => users.id, { onDelete: 'cascade' }),
  expires: timestamp('expires', { mode: 'date' }).notNull(),
  createdAt: timestamp('created_at').notNull().defaultNow(),
  updatedAt: timestamp('updated_at')
    .defaultNow()
    .$onUpdate(() => new Date()),
})

export const userRelations = relations(users, ({ many }) => ({
  posts: many(posts),
  likes: many(likes),
}))

export const posts = pgTable(
  'posts',
  {
    id: text('id')
      .primaryKey()
      .$defaultFn(() => crypto.randomUUID()),
    content: text('content').notNull(),
    authorId: text('author_id')
      .notNull()
      .references(() => users.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
    createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(),
    updatedAt: timestamp('updated_at', { mode: 'date' })
      .defaultNow()
      .notNull()
      .$onUpdate(() => new Date()),
  },
  (posts) => ({
    idIdx: index('posts_id_idx').on(posts.id),
    authorIdx: index('posts_author_idx').on(posts.authorId),
    createdAtIdx: index('posts_created_at_idx').on(posts.createdAt),
  }),
)

export const postRelations = relations(posts, ({ one, many }) => ({
  author: one(users, {
    fields: [posts.authorId],
    references: [users.id],
  }),
  likes: many(likes),
}))

export const likes = pgTable(
  'likes',
  {
    postId: text('post_id')
      .notNull()
      .references(() => posts.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
    userId: text('user_id')
      .notNull()
      .references(() => users.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
    createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(),
    updatedAt: timestamp('updated_at', { mode: 'date' }).defaultNow().notNull(),
  },
  (like) => ({
    postUserUnique: index('likes_post_user_unique').on(
      like.postId,
      like.userId,
    ),
    postIdx: index('likes_post_idx').on(like.postId),
    userIdx: index('likes_user_idx').on(like.userId),
    createdAtIdx: index('likes_created_at_idx').on(like.createdAt),
  }),
)

export const likeRelations = relations(likes, ({ one }) => ({
  post: one(posts, {
    fields: [likes.postId],
    references: [posts.id],
  }),
  user: one(users, {
    fields: [likes.userId],
    references: [users.id],
  }),
}))
```

ここまででschemaが作成できたので、Neon DBに反映していきましょう。
以下のコマンドを実行します。

```shell:fish
bunx drizzle-kit generate
bunx drizzle-kit migrate
```

ここでエラー等なく成功したら、Neon DBのconsoleにアクセスしてTableを確認してみてください。
スキーマ通りにTableが作らているはずです。

## Auth.jsによる認証実装
以下のNext.js用の`installation`に従い、進めていきます。

https://authjs.dev/getting-started/installation?framework=Next.js

### ライブラリ導入
まず、必要なライブラリを導入していきます。

```shell:fish
bun add next-auth@beta
```

Drizzleを使用するのでadapterも導入しましょう。

```shell:fish
bun add @auth/drizzle-adapter
```

続いて環境変数を以下のコマンドで作成していきます。

```shell:fish
bunx auth secret
```

### Auth.jsのconfig作成
まずは、Auth.jsの認証処理の設定を行っていきます。

ドキュメントによるとプロジェクトのルートディレクトリに`auth.ts`を作成するようになっていますが、`src/`がある場合は、`src/`に作成しても問題ないです。

```ts:auth.ts
import { config } from '@/libs/auth/config'
import NextAuth from 'next-auth'

export const { handlers, auth, signIn, signOut } = NextAuth(config)
```

そして、本題のconfigですが、以下のようにします。

`DrizzleAdapter`の部分はドキュメントに従い、必要なテーブルスキーマのみを使用するように、オプションを設定します。

https://authjs.dev/getting-started/adapters/drizzle#adapter-setup

```ts:libs/auth/config.ts
import { env } from '@/env'
import { db } from '@/libs/db/drizzle'
import { accounts, sessions, users } from '@/libs/db/schema'
import { DrizzleAdapter } from '@auth/drizzle-adapter'
import bcrypt from 'bcryptjs'
import { eq } from 'drizzle-orm'
import type { NextAuthConfig } from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import GitHub from 'next-auth/providers/github'

export const config = {
  adapter: DrizzleAdapter(db, {
    usersTable: users,
    accountsTable: accounts,
    sessionsTable: sessions,
  }),
  callbacks: {
    async signIn({ user, account }) {
      if (account?.provider === 'github') {
        return true
      }

      if (account?.provider !== 'credentials') {
        return false
      }

      if (!user.id) {
        return false
      }

      const existingUser = await db.query.users.findFirst({
        where: eq(users.id, user.id),
      })

      if (!existingUser) {
        return false
      }

      return true
    },

    session({ session, token }) {
      if (token.sub) {
        session.user.id = token.sub
      }

      return session
    },

    async jwt({ token }) {
      if (!token.sub) {
        return token
      }

      const existingUser = await db.query.users.findFirst({
        where: eq(users.id, token.sub),
      })

      if (!existingUser) {
        return token
      }

      token.name = existingUser.name
      token.email = existingUser.email
      token.image = existingUser.image

      return token
    },
  },
  providers: [
    GitHub({
      clientId: env.AUTH_GITHUB_ID,
      clientSecret: env.AUTH_GITHUB_SECRET,
    }),
    Credentials({
      credentials: {
        email: { label: 'Email', type: 'email' },
        password: { label: 'Password', type: 'password' },
      },
      authorize: async (credentials) => {
        if (
          !(
            typeof credentials?.email === 'string' &&
            typeof credentials?.password === 'string'
          )
        ) {
          throw new Error('Invalid credentials.')
        }

        const user = await db.query.users.findFirst({
          where: eq(users.email, credentials.email),
        })

        if (!user?.hashedPassword) {
          throw new Error('User has no password')
        }

        const isCorrectPassword = await bcrypt.compare(
          credentials.password,
          user.hashedPassword,
        )

        if (!isCorrectPassword) {
          throw new Error('Incorrect password')
        }

        return {
          id: user.id,
          name: user.name,
          email: user.email,
        }
      },
    }),
  ],
  pages: {
    signIn: '/sign-in',
  },
  trustHost: true,
  debug: process.env.NODE_ENV === 'development',
  session: { strategy: 'jwt' },
  secret: env.AUTH_SECRET,
} as const satisfies NextAuthConfig
```

細かい部分の説明ですが、ざっくりと以下のようになります。

- `callbacks`
    - `signIn`: サインイン時に実行されるコールバック（`Providers`の認証後に行われる処理、GitHubならGitHub認証後、email・passwordなら`Credentials`の`authorize`の後に実行される）
    - `session`: tokenから取得したユーザーIDをsessionに設定
    - `jwt`: tokenのカスタマイズ
- `providers`
    - `GitHub`: [GitHub 設定ページ](https://github.com/settings/developers)にて作成したapplicationの情報を渡す
    - `Credetials`
        - `credentials`: 何を使って認証を行うかを設定（今回はemail・passwordを使用）
        - `authorize`: サインイン時に行われる処理（認証成功時にはユーザー情報を、失敗時にはエラーを返却）
- `pages`
    - `signIn`: カスタムログインページのパスを指定
- `trustHost`: ホストを信頼するかを決める（local build時に、この設定がないとエラーとなる）
- `debug`: 開発環境でdebugログを出す
- `session`: `jwt`を採用
- `secret`: JWTの署名を行う秘密鍵

また、GitHubのOAuth機能を実現するため、以下の記事を参照し、GitHub上の設定を行ってください。
Googleについても行いたい方は、Google Cloud Consoleから各種IDやSECRETを取得していただければと思います。

https://zenn.dev/pino0701/articles/nextjs_oauth

configの作成ができたので、認証のAPI Route handlerを作成していきます。
`catch-all-segments`によるAPI Routesとすることに注意してください。

https://nextjs.org/docs/app/building-your-application/routing/dynamic-routes#catch-all-segments

```ts:app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'

export const { GET, POST } = handlers
```

最後に`middleware`に以下を追加します。
これにより、セッションを維持できるようになります。

```ts:src/middleware.ts
export { auth as middleware } from "@/auth"
```

### SignUpページの作成
以下のようにCardコンポーネントを使用して、formを作成していきます。

```tsx:page.tsx
import { Card } from '@/components/ui'
import { SignUpForm } from '@/features/auth/components/sign-up-form'

const SignUpPage = () => {
  return (
    <Card className="max-w-md w-full mx-auto">
      <Card.Header>
        <Card.Title>Sign Up</Card.Title>
        <Card.Description>
          Let's get you started by creating a new account.
        </Card.Description>
      </Card.Header>
      <SignUpForm />
    </Card>
  )
}

export default SignUpPage
```

```tsx:sign-up-form.tsx
'use client'

import '@/utils/zod-error-map-utils'
import { Button, Card, Form, Loader, TextField } from '@/components/ui'
import { FieldError, Input, Label } from '@/components/ui/field'
import { signUp } from '@/features/auth/actions/sign-up'
import {
  type SignUpSchema,
  signUpSchema,
} from '@/features/auth/types/schemas/sign-up-schema'
import { useSafeForm } from '@/hooks/use-safe-form'
import {} from '@conform-to/zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { IconBrandGithub } from 'justd-icons'
import { signIn } from 'next-auth/react'
import Link from 'next/link'
import { useTransition } from 'react'
import { Controller } from 'react-hook-form'
import { toast } from 'sonner'

export const SignUpForm = () => {
  const { control, setError, handleSubmit } = useSafeForm<SignUpSchema>({
    resolver: zodResolver(signUpSchema),
    defaultValues: {
      name: '',
      email: '',
      password: '',
    },
  })

  const [isPending, startTransition] = useTransition()

  const onSubmit = (data: SignUpSchema) => {
    startTransition(async () => {
      const result = await signUp(data)

      if (!result.isSuccess) {
        result.error?.message === 'User already exists'
          ? setError('email', { message: 'User already exists' })
          : toast.error(result.error?.message)

        return
      }

      toast.success('User created successfully')

      await signIn('credentials', {
        email: data.email,
        password: data.password,
        callbackUrl: '/',
      })
    })
  }

  return (
    <Form onSubmit={handleSubmit(onSubmit)}>
      <Card.Content className="space-y-6">
        <Controller
          name="name"
          control={control}
          render={({
            field: { name, value, onChange, onBlur, ref },
            fieldState: { invalid, error },
          }) => (
            <TextField
              label="Name"
              name={name}
              value={value}
              onChange={onChange}
              onBlur={onBlur}
              isRequired={true}
              validationBehavior="aria"
              isInvalid={invalid}
              errorMessage={error?.message}
            >
              <Label>Name</Label>
              <Input ref={ref} />
            </TextField>
          )}
        />

        <Controller
          name="email"
          control={control}
          render={({
            field: { name, value, onChange, onBlur, ref },
            fieldState: { invalid, error },
          }) => (
            <TextField
              label="Email"
              type="email"
              name={name}
              value={value}
              onChange={onChange}
              onBlur={onBlur}
              isRequired={true}
              validationBehavior="aria"
              isInvalid={invalid}
              errorMessage={error?.message}
            >
              <Label>Email</Label>
              <Input ref={ref} />
              <FieldError>{error?.message}</FieldError>
            </TextField>
          )}
        />
        <Controller
          name="password"
          control={control}
          render={({
            field: { name, value, onChange, onBlur, ref },
            fieldState: { invalid, error },
          }) => (
            <TextField
              label="Password"
              type="password"
              name={name}
              value={value}
              onChange={onChange}
              onBlur={onBlur}
              isRequired={true}
              validationBehavior="aria"
              isInvalid={invalid}
              isRevealable={true}
              errorMessage={error?.message}
            >
              <Label>Password</Label>
              <Input ref={ref} />
              <FieldError>{error?.message}</FieldError>
            </TextField>
          )}
        />

        <div className="flex items-center gap-1">
          Already have an account?{' '}
          <Link href="/sign-in" className="text-blue-500 hover:underline">
            Login
          </Link>
        </div>
      </Card.Content>
      <Card.Footer className="flex flex-col gap-2">
        <Button type="submit" className="w-full" isDisabled={isPending}>
          {isPending && <Loader />}
          Sign Up
        </Button>
        <Button
          type="button"
          intent="secondary"
          className="w-full"
          isDisabled={isPending}
          onPress={() => {
            startTransition(async () => {
              await signIn('github', { callbackUrl: '/' })
            })
          }}
        >
          <IconBrandGithub />
          GitHub
        </Button>
      </Card.Footer>
    </Form>
  )
}
```

画面上は以下のようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/879778/815f4621-f1cb-b101-ba21-881eccc0d7d7.png)

またsignUp時のserver actionsは以下のようにしています。
```ts:sign-up.ts
'use server'

import {
  type SignUpSchema,
  signUpSchema,
} from '@/features/auth/types/schemas/sign-up-schema'
import { db } from '@/libs/db/drizzle'
import { users } from '@/libs/db/schema'
import bcrypt from 'bcryptjs'
import { eq } from 'drizzle-orm'

export const signUp = async (data: SignUpSchema) => {
  const submission = signUpSchema.safeParse(data)

  if (!submission.success) {
    return {
      isSuccess: false,
      error: { message: submission.error.message },
    }
  }

  const existingUser = await db.query.users.findFirst({
    where: eq(users.email, submission.data.email),
  })

  if (existingUser) {
    return {
      isSuccess: false,
      error: { message: 'User already exists' },
    }
  }

  const hashedPassword = await bcrypt.hash(submission.data.password, 12)

  const [newUser] = await db
    .insert(users)
    .values({
      name: submission.data.name,
      email: submission.data.email,
      hashedPassword,
      image: '',
    })
    .returning()

  return {
    isSuccess: true,
    data: { email: newUser.email, password: submission.data.password },
  }
}
```
`react-hook-form`を使用しているのは、`Auth.js`の`signUp()`を使用するためです。
`Conform`と`useActionState`で行いたかったのですが、どうもうまくできなかったので、`react-hook-form`を使用するようにしました。

やっていることを説明すると、まず、server actionsでバリデーションと、ユーザーが既に存在しているかどうかを判定し、存在しなければ、パスワードをハッシュ化してusers テーブルにinsertしています。

次に、クライアント側で結果を受け取り、成功の場合、Toastの表示と、同時にサインインを行います。
サインインがない場合、userが登録されるだけで、認証されない状態になります。
そのため、登録と同時に認証が済むようにしています。
（この辺りは要件にもよるのですが、認証させずにログイン画面にredirectでも問題はないですが、SNS Likeのアプリなので、そのまま認証まで行います。）

### SignInページの作成
SignInページもほとんど構成は同じです。
違いは、users テーブルにinsertするかしないか程度と思います。

```tsx:page.tsx
import { Card } from '@/components/ui'
import { SignInForm } from '@/features/auth/components/sign-in-form'

const SignInPage = () => {
  return (
    <Card className="max-w-md w-full mx-auto">
      <Card.Header>
        <Card.Title>Log In</Card.Title>
        <Card.Description>
          Enter your email and password to log in.
        </Card.Description>
      </Card.Header>
      <SignInForm />
    </Card>
  )
}

export default SignInPage
```

```tsx:sign-in-form.tsx
'use client'

import '@/utils/zod-error-map-utils'
import { Button, Card, Form, Loader, TextField } from '@/components/ui'
import { FieldError, Input, Label } from '@/components/ui/field'
import {
  type SignInSchema,
  signInSchema,
} from '@/features/auth/types/schemas/sing-in-schema'
import { useSafeForm } from '@/hooks/use-safe-form'
import {} from '@conform-to/zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { IconBrandGithub } from 'justd-icons'
import { signIn } from 'next-auth/react'
import Link from 'next/link'
import { useTransition } from 'react'
import { Controller } from 'react-hook-form'
import { toast } from 'sonner'

export const SignInForm = () => {
  const { control, handleSubmit } = useSafeForm<SignInSchema>({
    resolver: zodResolver(signInSchema),
    defaultValues: {
      email: '',
      password: '',
    },
  })

  const [isPending, startTransition] = useTransition()

  const onSubmit = (data: SignInSchema) => {
    startTransition(async () => {
      await signIn('credentials', {
        email: data.email,
        password: data.password,
        callbackUrl: '/',
      })

      toast.success('User logged in successfully')
    })
  }

  return (
    <Form onSubmit={handleSubmit(onSubmit)}>
      <Card.Content className="space-y-6">
        <Controller
          name="email"
          control={control}
          render={({
            field: { name, value, onChange, onBlur, ref },
            fieldState: { invalid, error },
          }) => (
            <TextField
              label="Email"
              type="email"
              name={name}
              value={value}
              onChange={onChange}
              onBlur={onBlur}
              isRequired={true}
              validationBehavior="aria"
              isInvalid={invalid}
              errorMessage={error?.message}
            >
              <Label>Email</Label>
              <Input ref={ref} />
              <FieldError>{error?.message}</FieldError>
            </TextField>
          )}
        />
        <Controller
          name="password"
          control={control}
          render={({
            field: { name, value, onChange, onBlur, ref },
            fieldState: { invalid, error },
          }) => (
            <TextField
              label="Password"
              type="password"
              name={name}
              value={value}
              onChange={onChange}
              onBlur={onBlur}
              isRequired={true}
              validationBehavior="aria"
              isInvalid={invalid}
              isRevealable={true}
              errorMessage={error?.message}
            >
              <Label>Password</Label>
              <Input ref={ref} />
              <FieldError>{error?.message}</FieldError>
            </TextField>
          )}
        />

        <div className="flex items-center gap-1">
          Don&apos;t have an account?{' '}
          <Link href="/sign-up" className="text-blue-500 hover:underline">
            Sign Up
          </Link>
        </div>
      </Card.Content>
      <Card.Footer className="flex flex-col gap-2">
        <Button type="submit" className="w-full" isDisabled={isPending}>
          {isPending && <Loader />}
          Log In
        </Button>
        <Button
          type="button"
          intent="secondary"
          className="w-full"
          isDisabled={isPending}
          onPress={() => {
            startTransition(async () => {
              await signIn('github', { callbackUrl: '/' })
            })
          }}
        >
          <IconBrandGithub />
          GitHub
        </Button>
      </Card.Footer>
    </Form>
  )
}
```

画面上は以下のようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/879778/c6697987-1248-1535-df84-331830b6b8e3.png)

ここまでで認証の処理は完了です。
続いて、APIを作成していきます。

## HonoによるAPIの実装
以下を参照し、API RoutesをHonoで置き換えます。
HonoはREST APIを作成するだけで、RPC機能を実装できます。
そのため、API Routesを置き換えるだけで、型安全な開発が可能となります。

https://hono.dev/docs/getting-started/vercel

ということでセットアップしていきましょう。

### HonoをAPI Routesに導入
まずは、Honoを導入します。
```shell:fish
bun add hono
```

そして、[catch-all-segments](https://nextjs.org/docs/app/building-your-application/routing/dynamic-routes#catch-all-segments)を使用して、API RoutesをHonoでハックしていきます。

```ts:api/[[...route]]/route.ts
import posts from '@/features/posts/server/route'
import { Hono } from 'hono'
import { handle } from 'hono/vercel'

export const runtime = 'edge'

const app = new Hono().basePath('/api')

const routes = app.route('/posts', posts)

export const GET = handle(app)
export const POST = handle(app)
export const PATCH = handle(app)
export const DELETE = handle(app)

export type AppType = typeof routes
```

これでセットアップは終わりました。
次に`/posts`エンドポイントのAPIを作ります。

### APIの作成
今回はGETのみ実装します。
（残りはserver actionsで実装するからです）

なので、投稿一覧の取得と、投稿詳細の取得のAPIを作って完了です。
```ts:features/posts/server/route.ts
import { db } from '@/libs/db/drizzle'
import { posts } from '@/libs/db/schema'
import { desc, eq } from 'drizzle-orm'
import { Hono } from 'hono'

const app = new Hono()
  .get('/', async (c) => {
    const postList = await db.query.posts.findMany({
      with: {
        author: true,
        likes: true,
      },
      orderBy: [desc(posts.createdAt)],
    })

    return c.json({ posts: postList }, 200)
  })
  .get('/:postId', async (c) => {
    const { postId } = c.req.param()

    const post = await db.query.posts.findFirst({
      with: {
        author: true,
        likes: true,
      },
      where: eq(posts.id, postId),
    })

    return c.json({ post }, 200)
  })

export default app
```

### fetchのヘルパー関数作成
以下の記事を参照し、fetcher関数を作成します。

https://zenn.dev/chot/articles/e109287414eb8c

デフォルトのfetchだと、jsonはanyになるので、以下のようにジェネリクスを渡して、型を付けていきます。
これで、型の恩恵を受けられるようになります。

```ts:libs/fetcher.ts
import { redirect } from 'next/navigation'

type FetchArgs = Parameters<typeof fetch>

export async function fetcher<T>(url: FetchArgs[0], args: FetchArgs[1]) {
  const response = await fetch(url, args)

  if (!response.ok) {
    redirect('/')
  }

  const json: T = await response.json()

  return json
}
```

### 投稿一覧UIの作成
以下のようにAPIを呼び出していきます。
post投稿時にserver actionsでcache purgeできるようにするため、cacheにタグを付けてfetchします。

```tsx:post-card-content.tsx
import { PostCard } from '@/features/posts/components/post-card'
import { fetcher } from '@/libs/fetcher'
import { client } from '@/libs/rpc'
import type { InferResponseType } from 'hono'

const url = client.api.posts.$url()
type ResType = InferResponseType<typeof client.api.posts.$get>

export const PostsCardContent = async () => {
  const res = await fetcher<ResType>(url, {
    next: { tags: ['posts'] },
  })

  return res.posts.map((post) => <PostCard post={post} key={post.id} />)
}
```

実際に`res`部分をフォーカスすると型が取れているのがわかります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/879778/cbbb8dae-9513-3112-4cbc-9576eccd0b30.png)

そして投稿一覧ですが、画面上では、以下のようになっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/879778/85504b76-2ba8-9c43-6f46-43a9ade8e37b.png)

### 投稿詳細UI作成
こちらも同じように、HonoのAPI clientから型を取得して、fetchしていきます。

```tsx:[postId]/page.tsx
import { PostCard } from '@/features/posts/components/post-card'
import { getSession } from '@/libs/auth/session'

import { fetcher } from '@/libs/fetcher'
import { client } from '@/libs/rpc'
import type { InferResponseType } from 'hono'
import { notFound } from 'next/navigation'

const fetchPost = async (postId: string) => {
  const session = await getSession()

  if (!session?.user) {
    notFound()
  }

  type ResType = InferResponseType<(typeof client.api.posts)[':postId']['$get']>
  const url = client.api.posts[':postId'].$url({
    param: { postId },
  })

  const res = await fetcher<ResType>(url, {
    next: { tags: [`posts/${postId}`] },
  })

  if (!res.post) {
    notFound()
  }

  return res.post
}

type PostIdPageParams = {
  params: Promise<{
    postId: string
  }>
}

const PostIdPage = async ({ params }: PostIdPageParams) => {
  const postId = (await params).postId

  const post = await fetchPost(postId)

  return <PostCard post={post} />
}

export default PostIdPage
```

```tsx:post-card.tsx
import { Avatar, Card } from '@/components/ui'
import { LikeButton } from '@/features/likes/components/like-button'
import { PostMenu } from '@/features/posts/components/post-menu'
import type { client } from '@/libs/rpc'
import { formatTimeAgo } from '@/utils/format-time'
import type { InferResponseType } from 'hono'
import { IconClock, IconForward, IconMessage } from 'justd-icons'
import Link from 'next/link'

type ResType = InferResponseType<(typeof client.api.posts)[':postId']['$get']>

type PostCardProps = {
  post: NonNullable<ResType['post']>
}

export const PostCard = ({ post }: PostCardProps) => {
  return (
    <Card>
      <Card.Header>
        <div className="flex justify-between items-center">
          <div className="flex w-full gap-4 items-center">
            <Link href={'#'}>
              <Avatar
                src={post.author.image ? post.author.image : '/placeholder.png'}
                alt="post avatar"
                initials="PA"
              />
            </Link>
            <div className="flex gap-0 sm:gap-2 flex-col sm:flex-row">
              <h5 className="text-base font-semibold">
                {post.author.name ?? 'John Doe'}
              </h5>
              <p className="text-neutral-400">
                @{post.author.id.substring(0, 12)}
              </p>
            </div>
          </div>
          <PostMenu post={post} />
        </div>
      </Card.Header>
      <Card.Content className="space-y-4">
        <Link href={`/${post.id}`}>
          <p className="break-words text-lg">{post.content}</p>
        </Link>
        <div className="flex justify-between items-center w-full">
          <div className="flex gap-6">
            <LikeButton
              postId={post.id}
              initialLikes={post.likes.map((like) => like.userId)}
            />
            <button type="button" className="flex items-center gap-2">
              <IconMessage className="size-5" />
              <span>{Math.floor(Math.random() * 100)}</span>
            </button>
            <button type="button">
              <IconForward className="size-5" />
            </button>
          </div>
          <div className="flex items-center gap-2">
            <IconClock className="size-4" />
            <span className="text-sm text-neutral-400">
              {formatTimeAgo(new Date(post.createdAt))} ago
            </span>
          </div>
        </div>
      </Card.Content>
    </Card>
  )
}
```

画面上は以下のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/879778/54b9cd2b-879a-06ff-ac30-23bcda3bdbe4.png)

以上でAPI部分の実装は完了です。
続いて、投稿機能・編集・削除機能について見ていきましょう。

## 投稿機能・編集・削除機能のServer Actions実装
それでは、順番に見ていきましょう。
なお、formバリデーションライブラリには`Confom`、server actionsの管理にはReact19で搭載された`useActionState`を使用していきます。

以下の記事にかなり詳細に記載しておりますので、詳しくはそちらをご参照ください。
（今回は、そこまで詳しい説明は行いません）

https://qiita.com/curry__30/items/24e7c144123a9fc15fa3

### 投稿機能
#### フォームスキーマ定義
まずは、フォームのスキーマ定義ですが、以下のように`content`のみとします。

```ts:post-form-schema.ts
import { z } from 'zod'

export const postFormSchema = z.object({
  content: z
    // biome-ignore lint/style/useNamingConvention: This needs a snake_case name
    .string({ required_error: 'Content is required' })
    .min(1)
    .max(300),
})

export type PostFormSchemaType = z.infer<typeof postFormSchema>
```

#### UI作成
次にform部分ですが、以下のようにします。
```tsx:post-form.tsx
'use client'

import '@/utils/zod-error-map-utils'
import { Button, Form, Loader, TextField } from '@/components/ui'
import { addPost } from '@/features/posts/actions/add-post-action'
import { postFormSchema } from '@/features/posts/types/schema/post-form-schema'
import { getFormProps, getInputProps, useForm } from '@conform-to/react'
import { getZodConstraint, parseWithZod } from '@conform-to/zod'
import { IconSend } from 'justd-icons'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { useActionState } from 'react'

export const PostForm = () => {
  const { data: session } = useSession()
  const router = useRouter()

  const [lastResult, action, isPending] = useActionState(addPost, null)

  const [form, fields] = useForm({
    constraint: getZodConstraint(postFormSchema),
    lastResult,
    onValidate({ formData }) {
      return parseWithZod(formData, { schema: postFormSchema })
    },
    defaultValue: {
      content: '',
    },
  })

  return (
    <Form
      {...getFormProps(form)}
      action={action}
      className="w-full flex justify-between gap-4"
    >
      <div className="w-full flex flex-col gap-1">
        <TextField
          {...getInputProps(fields.content, { type: 'text' })}
          placeholder="What's on your mind?"
          isDisabled={isPending}
          className="w-full"
          errorMessage={''}
          onFocus={() => {
            if (!session) {
              router.push('/sign-in')
            }
          }}
        />
        <span id={fields.content.errorId} className="mt-1 text-sm text-red-500">
          {fields.content.errors}
        </span>
      </div>
      <Button
        type="submit"
        size="square-petite"
        appearance="outline"
        className={'h-10'}
        isDisabled={isPending || fields.content.value === undefined}
      >
        {isPending ? <Loader /> : <IconSend />}
      </Button>
    </Form>
  )
}
```

画面上では以下のようになります。
![Create_Next_App.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/879778/017178aa-3c67-55ea-a338-dd664cce5d39.png)


#### server actions
やっていることは、server 側でもバリデーションをかけて、成功の場合、postsテーブルへinsertし、投稿一覧で取得したcacheをpurgeして、新しいcacheにするということをしています。

このcache purgeの処理がないと、画面リフレッシュしない限り、古いcacheを参照するため、新しいデータがUIに表示されないので、忘れずに行いましょう。

```ts:add-post-action.ts
'use server'

import { postFormSchema } from '@/features/posts/types/schema/post-form-schema'
import { getSession } from '@/libs/auth/session'
import { db } from '@/libs/db/drizzle'
import { posts } from '@/libs/db/schema'
import { parseWithZod } from '@conform-to/zod'
import { revalidateTag } from 'next/cache'

export const addPost = async (_: unknown, formData: FormData) => {
  const submission = parseWithZod(formData, { schema: postFormSchema })

  if (submission.status !== 'success') {
    return submission.reply()
  }

  const session = await getSession()

  if (!session?.user?.id) {
    return submission.reply()
  }

  await db.insert(posts).values({
    authorId: session.user.id,
    content: submission.value.content,
  })

  revalidateTag('posts')
}
```


### 投稿編集
こちらも、新規投稿時と同様に`content`のみなので、フォームスキーマは共通のものを使用します。

#### UI作成
こちらは、JSX部分は、Modalを使用するということ以外は、ほとんど新規投稿のformと同じ構造です。
ただ、編集時にはIDなどの識別子が必要なので、それだけ親から受取、formのaction属性で、formDataとしてappendする必要があります。

あるいは、form スキーマに定義して、TextFieldをhiddenにして渡す方法でも可能です。

そして上記のIDの処理を含めて、`useActionState`のClient Actionで、action成功時の挙動等を実装しています。

フォーム送信前後に確認モーダルを出したり、成功時にtoastを表示するなどは、よくありますが、現状だと、server actions側の実装ではできないので、Client Action側で行うときれいになります。
これをしないと、`useEffect`で処理を記述する必要がありますが、`useEffect`はパフォーマンス劣化につながったり、公式も使用する場合は、代替の方法を検討しきってからというニュアンスを推奨しているので、可能な限り使うべきではないです。

https://zenn.dev/daijinload/articles/7fbe73e040c0a2

 https://zenn.dev/tsuboi/articles/0fc94356667284#%E3%81%8A%E3%81%BE%E3%81%91%EF%BC%9A%E3%82%B5%E3%83%BC%E3%83%90%E3%83%BC%E3%81%AE%E7%B5%90%E6%9E%9C%E3%82%92%E3%83%88%E3%83%BC%E3%82%B9%E3%83%88%E3%81%A7%E8%A1%A8%E7%A4%BA%E3%81%99%E3%82%8B
  
また、Modalを更新時に閉じないので、formの値を前のserver actionsの結果にする必要があるので、TextFieldのdefaultValue属性を設定しています。

Modalを閉じたい場合は、Client Actionsに`onClose`を記述するだけで対応可能です。

```tsx:post-edit-modal.tsx
import { Button, Form, Loader, Modal, TextField } from '@/components/ui'
import { editPost } from '@/features/posts/actions/edit-post-action'
import { postFormSchema } from '@/features/posts/types/schema/post-form-schema'
import {
  type SubmissionResult,
  getFormProps,
  getInputProps,
  useForm,
} from '@conform-to/react'
import { getZodConstraint, parseWithZod } from '@conform-to/zod'
import { IconSend } from 'justd-icons'
import { useRouter } from 'next/navigation'
import { useActionState } from 'react'
import { toast } from 'sonner'

type PostEditModalProps = {
  postId?: string
  content?: string
  isOpen: boolean
  onClose: () => void
}

export const PostEditModal = ({
  postId,
  content,
  isOpen,
  onClose,
}: PostEditModalProps) => {
  const router = useRouter()

  const [lastResult, action, isPending] = useActionState<
    Awaited<ReturnType<typeof editPost>> | null | undefined,
    FormData
  >(async (prev, formData) => {
    if (!postId) {
      return
    }

    formData.append('postId', postId)

    const result = await editPost(prev, formData)

    if (result.status === 'success') {
      toast('Successfully updated on your post!', {
        action: {
          label: 'View',
          onClick: () => {
            router.push(`/${postId}`)
          },
        },
      })

      return result
    }

    toast.error('Failed to update post')
    return result
  }, null)

  const [form, fields] = useForm({
    constraint: getZodConstraint(postFormSchema),
    lastResult,
    onValidate({ formData }) {
      return parseWithZod(formData, { schema: postFormSchema })
    },

    defaultValue: {
      content,
    },
  })

  return (
    <Modal isOpen={isOpen} onOpenChange={isPending ? undefined : onClose}>
      <Modal.Content>
        <Modal.Header>
          <Modal.Title>Edit Post</Modal.Title>
        </Modal.Header>
        <Form {...getFormProps(form)} action={action}>
          <TextField
            {...getInputProps(fields.content, { type: 'text' })}
            placeholder="What's on your mind?"
            isDisabled={isPending}
            defaultValue={
              lastResult?.initialValue?.content.toString() ?? content
            }
            className="w-full"
            errorMessage={''}
          />
          <span
            id={fields.content.errorId}
            className="mt-1 text-sm text-red-500"
          >
            {fields.content.errors}
          </span>
          <Modal.Footer>
            <Modal.Close isDisabled={isPending}>Cancel</Modal.Close>
            <Button
              type="submit"
              size="square-petite"
              appearance="outline"
              className={'h-10'}
              isDisabled={isPending || fields.content.value === undefined}
            >
              {isPending ? <Loader /> : <IconSend />}
            </Button>
          </Modal.Footer>
        </Form>
      </Modal.Content>
    </Modal>
  )
}

```

画面上では、以下のように表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/879778/5c35f9c6-42d4-8825-e9eb-bb2442f3f6b1.png)

#### server actions
こちらも新規投稿とほとんど同じで、該当のデータに`update`を行うようにするだけで実装できます。
submissionを返却する理由は、useEffectの処理や、defaultValue属性の値を設定するためです。

```ts:edit-post-action.ts
'use server'

import { postFormSchema } from '@/features/posts/types/schema/post-form-schema'
import { getSession } from '@/libs/auth/session'
import { db } from '@/libs/db/drizzle'
import { posts } from '@/libs/db/schema'
import { parseWithZod } from '@conform-to/zod'
import { eq } from 'drizzle-orm'
import { revalidateTag } from 'next/cache'

export const editPost = async (_: unknown, formData: FormData) => {
  const submission = parseWithZod(formData, { schema: postFormSchema })

  if (submission.status !== 'success') {
    return submission.reply()
  }

  const session = await getSession()

  if (!session?.user?.id) {
    return submission.reply()
  }

  await db
    .update(posts)
    .set({
      content: submission.value.content,
      updatedAt: new Date(),
    })
    .where(eq(posts.id, formData.get('postId') as string))

  revalidateTag('posts')

  return submission.reply()
}
```

### 投稿削除
最後に、削除機能についてです。

#### UI作成
UIは以下のようにMenuとして作成します。
実際の削除処理は`handleDelete`で行っています。

```tsx:post-menu.tsx
'use client'

import { Menu } from '@/components/ui'
import { deletePost } from '@/features/posts/actions/delete-post-action'
import { PostEditModal } from '@/features/posts/components/post-edit-modal'
import type { client } from '@/libs/rpc'
import type { InferResponseType } from 'hono'
import {
  IconDotsHorizontal,
  IconPencilBox,
  IconResizeOutIn,
  IconTrashEmpty,
} from 'justd-icons'
import { useSession } from 'next-auth/react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useState, useTransition } from 'react'
import { toast } from 'sonner'

type ResType = InferResponseType<(typeof client.api.posts)[':postId']['$get']>

type PostMenuProps = {
  post: NonNullable<ResType['post']>
}

export const PostMenu = ({ post }: PostMenuProps) => {
  const { data: session } = useSession()
  const pathname = usePathname()

  const [isPending, startTransition] = useTransition()

  const [isOpen, setIsOpen] = useState(false)

  const handleDelete = () => {
    startTransition(async () => {
      const result = await deletePost(post.id)

      if (result.isSuccess) {
        toast.success('Post deleted successfully')
      } else {
        toast.error('Failed to delete post')
      }
    })
  }

  return (
    <>
      <Menu>
        {pathname === '/' || post.authorId === session?.user?.id ? (
          <Menu.Trigger>
            <IconDotsHorizontal />
          </Menu.Trigger>
        ) : null}
        <Menu.Content className="min-w-48" placement="bottom">
          {pathname === '/' && (
            <Menu.Item isDisabled={isPending}>
              <Link href={`/${post.id}`} className="flex items-center gap-1">
                <IconResizeOutIn />
                View
              </Link>
            </Menu.Item>
          )}
          {post.authorId === session?.user?.id && (
            <>
              <Menu.Item
                isDisabled={isPending}
                onAction={() => setIsOpen(true)}
              >
                <IconPencilBox />
                Edit
              </Menu.Item>

              <Menu.Separator />
              <Menu.Item
                isDanger={true}
                onAction={handleDelete}
                isDisabled={isPending}
              >
                <IconTrashEmpty />
                Delete
              </Menu.Item>
            </>
          )}
        </Menu.Content>
      </Menu>
      <PostEditModal
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
        postId={post?.id}
        content={post?.content}
      />
    </>
  )
}
```

画面上では、以下のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/879778/ff819e39-3183-6497-2a74-009db0ea4dc9.png)

#### server actions
削除処理はformを介さないのでserver actionsの結果を返却するようにして、toastを出し分けられるようにします。

テーブルの処理としては、SQLのdeleteを行うようにするだけです。
```ts:delete-post-action.ts
'use server'

import { getSession } from '@/libs/auth/session'
import { db } from '@/libs/db/drizzle'
import { posts } from '@/libs/db/schema'
import { eq } from 'drizzle-orm'
import { revalidateTag } from 'next/cache'

export const deletePost = async (postId: string) => {
  const session = await getSession()

  if (!session?.user) {
    return { isSuccess: false, error: { message: 'Unauthorized' } }
  }

  try {
    await db.delete(posts).where(eq(posts.id, postId))

    revalidateTag('posts')
    return { isSuccess: true }
  } catch (err) {
    return { isSuccess: false, error: { message: 'Failed to delete post' } }
  }
}
```
ここまでで一通りCRUDの実装が完了しました。
最後にReact19で搭載された、楽観的UI更新を簡単にする[useOptimistic](https://ja.react.dev/reference/react/useOptimistic)をいいね機能で使ってみたいと思います。
X（旧Twitter）などSNSのいいね機能などは基本楽観的UI更新をしているので、その挙動を作ってみようと思います。

## いいね機能 Optimistic UI
まずは、コンポーネント部分からです。

### UI作成
以下の`PostCard`で使用している`LikeButton`で楽観的UI更新を行っていきます。

```tsx:post-card.tsx
import { Avatar, Card } from '@/components/ui'
import { LikeButton } from '@/features/likes/components/like-button'
import { PostMenu } from '@/features/posts/components/post-menu'
import type { client } from '@/libs/rpc'
import { formatTimeAgo } from '@/utils/format-time'
import type { InferResponseType } from 'hono'
import { IconClock, IconForward, IconMessage } from 'justd-icons'
import Link from 'next/link'

type ResType = InferResponseType<(typeof client.api.posts)[':postId']['$get']>

type PostCardProps = {
  post: NonNullable<ResType['post']>
}

export const PostCard = ({ post }: PostCardProps) => {
  return (
    <Card>
      <Card.Header>
        <div className="flex justify-between items-center">
          <div className="flex w-full gap-4 items-center">
            <Link href={'#'}>
              <Avatar
                src={post.author.image ? post.author.image : '/placeholder.png'}
                alt="post avatar"
                initials="PA"
              />
            </Link>
            <div className="flex gap-0 sm:gap-2 flex-col sm:flex-row">
              <h5 className="text-base font-semibold">
                {post.author.name ?? 'John Doe'}
              </h5>
              <p className="text-neutral-400">
                @{post.author.id.substring(0, 12)}
              </p>
            </div>
          </div>
          <PostMenu post={post} />
        </div>
      </Card.Header>
      <Card.Content className="space-y-4">
        <Link href={`/${post.id}`}>
          <p className="break-words text-lg">{post.content}</p>
        </Link>
        <div className="flex justify-between items-center w-full">
          <div className="flex gap-6">
            <LikeButton
              postId={post.id}
              initialLikes={post.likes.map((like) => like.userId)}
            />
            <button type="button" className="flex items-center gap-2">
              <IconMessage className="size-5" />
              <span>{Math.floor(Math.random() * 100)}</span>
            </button>
            <button type="button">
              <IconForward className="size-5" />
            </button>
          </div>
          <div className="flex items-center gap-2">
            <IconClock className="size-4" />
            <span className="text-sm text-neutral-400">
              {formatTimeAgo(new Date(post.createdAt))} ago
            </span>
          </div>
        </div>
      </Card.Content>
    </Card>
  )
}
```

そして、実際にいいね機能を実装する`LikeButton`は以下のようにしています。

```tsx:like-button.tsx
'use client'

import { Form, TextField } from '@/components/ui'
import { likeAction } from '@/features/likes/actions/like-action'
import { likeFormSchema } from '@/features/likes/types/schema/like-form-schema'
import { getFormProps, getInputProps, useForm } from '@conform-to/react'
import { getZodConstraint, parseWithZod } from '@conform-to/zod'
import { IconHeart, IconHeartFill } from 'justd-icons'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { useActionState, useOptimistic } from 'react'

type LikeButtonProps = {
  postId: string
  initialLikes: string[]
}

export const LikeButton = ({ postId, initialLikes }: LikeButtonProps) => {
  const { data: session } = useSession()
  const router = useRouter()

  const [optimisticLike, setOptimisticLike] = useOptimistic<
    { likeCount: number; isLiked: boolean },
    void
  >(
    {
      likeCount: initialLikes.length || 0,
      isLiked: session?.user?.id
        ? initialLikes?.includes(session.user.id)
        : false,
    },
    (currentState) => ({
      likeCount: currentState.isLiked
        ? currentState.likeCount - 1
        : currentState.likeCount + 1,
      isLiked: !currentState.isLiked,
    }),
  )

  const [lastResult, action] = useActionState(likeAction, null)

  const [form, fields] = useForm({
    constraint: getZodConstraint(likeFormSchema),
    lastResult,
    onValidate({ formData }) {
      return parseWithZod(formData, { schema: likeFormSchema })
    },
    onSubmit() {
      if (!session) {
        router.push('/sign-in')
        return
      }

      setOptimisticLike()
    },
    defaultValue: {
      postId,
    },
  })

  return (
    <Form
      {...getFormProps(form)}
      action={action}
      className="flex items-center gap-1"
    >
      <TextField {...getInputProps(fields.postId, { type: 'hidden' })} />
      <button type="submit" className="flex items-center gap-2">
        {optimisticLike.isLiked ? (
          <IconHeartFill className="size-5 text-red-500" />
        ) : (
          <IconHeart className="size-5" />
        )}
      </button>
      <span className={optimisticLike.isLiked ? 'text-red-500' : ''}>
        {optimisticLike.likeCount}
      </span>
    </Form>
  )
}
```

少し整理してみましょう。
まず、`PostCard`の該当箇所を切り出すのと、postの型を見てみましょう。

postの型は以下のとおりです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/879778/682c0ca1-5845-d5ab-a81e-46d6a9e858eb.png)

そして、`LikeButton`のpropsの型は以下となっています。
```ts
type LikeButtonProps = {
  postId: string
  initialLikes: string[]
}
```

つまり、propsには、どの投稿なのかを判別する識別子と、postが持っているlikes配列をuserIdの配列としてpropsを渡していることになります。

例えば、以下のような値が渡ります。
- postId: '670867bf-00b7-4c7a-8ac3-eedb9802f752'
- initialLikes: ['a04f6e1f-e42b-4ce8-8f29-492fd460d186', '4c509be0-be67-4864-b836-0a5ef1087b91',・・・]

```tsx
<LikeButton
  postId={post.id}
  initialLikes={post.likes.map((like) => like.userId)}
/>
```

### いいね楽観的UI更新のロジック
ここで`LikeButton`に話を戻します。
まず、`useOptimistic`ですが、UI上でどの要素を更新するのかという観点で定義が決まります。

今回のいいね機能の場合、**いいね数**と**ログインユーザーがいいねしたかどうか**というbool値がUIの更新には必要になります。

そのため、第一引数には、初期値として、いいね数（likes配列が持つuserIdの数）とログインユーザーのidがuserIdの配列に含まれているかどうかという値を設定します。

そして、第二引数には、更新後の状態を返却するよう処理を記載します。

この第二引数については`useActionState`も同じことが言え、`useReducer`のように今のstateを受けとって、新しいstateを返却するというルールを理解するとわかりやすいと思います。

```ts
const [optimisticLike, setOptimisticLike] = useOptimistic<
  { likeCount: number; isLiked: boolean },
  void
>(
  {
    likeCount: initialLikes.length || 0,
    isLiked: session?.user?.id
      ? initialLikes?.includes(session.user.id)
      : false,
  },
  (currentState) => ({
    likeCount: currentState.isLiked
      ? currentState.likeCount - 1
      : currentState.likeCount + 1,
    isLiked: !currentState.isLiked,
  }),
)
```

あとは上記をJSXに適用するだけで、クライアント側の楽観的UI更新の処理は実装完了です。

ログインユーザーがいいねしていたら、`IconHeartFill`が表示され、いいねしていなければ`IconHeart`が表示されます。
そして、いいね数も表示します。

```tsx
<Form
  {...getFormProps(form)}
  action={action}
  className="flex items-center gap-1"
>
  <TextField {...getInputProps(fields.postId, { type: 'hidden' })} />
  <button type="submit" className="flex items-center gap-2">
    {optimisticLike.isLiked ? (
      <IconHeartFill className="size-5 text-red-500" />
    ) : (
      <IconHeart className="size-5" />
    )}
  </button>
  <span className={optimisticLike.isLiked ? 'text-red-500' : ''}>
    {optimisticLike.likeCount}
  </span>
</Form>
```

画面上は以下のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/879778/dbcf1aaa-03fd-6f0b-3481-cb31c169dbc4.png)


#### server action
そして、server actionですが、やっていることはシンプルで、当該postにおいて、ログインユーザーが既にいいねを押していたら、`exsitedLike`が取得できるため、その場合は、いいねを削除し、そうでなければ新規登録します。

そしてcacheに更新をかけるということをしています。


```ts:like-action.ts
'use server'

import { likeFormSchema } from '@/features/likes/types/schema/like-form-schema'
import { getSession } from '@/libs/auth/session'
import { db } from '@/libs/db/drizzle'
import { likes } from '@/libs/db/schema'
import { parseWithZod } from '@conform-to/zod'
import { and, eq } from 'drizzle-orm'
import { revalidateTag } from 'next/cache'

export const likeAction = async (_: unknown, formData: FormData) => {
  const session = await getSession()

  if (!session?.user?.id) {
    throw new Error('Unauthorized')
  }

  const submission = parseWithZod(formData, {
    schema: likeFormSchema,
  })

  if (submission.status !== 'success') {
    return submission.reply()
  }

  const userId = session.user.id

  const existedLike = await db.query.likes.findFirst({
    where: and(
      eq(likes.postId, submission.value.postId),
      eq(likes.userId, userId),
    ),
  })

  if (existedLike) {
    await db
      .delete(likes)
      .where(
        and(
          eq(likes.postId, existedLike.postId),
          eq(likes.userId, existedLike.userId),
        ),
      )
  } else {
    await db.insert(likes).values({
      postId: submission.value.postId,
      userId,
    })
  }

  revalidateTag(`likes/${submission.value.postId}`)
}
```

動作確認してみましょう。
しっかり高速に更新できているようです。

注意点として、DBに保存されていいない状態（server actonsが完了していない状態）で画面リフレッシュをすると当然データはDBにないので、UIのみ更新されるという状態になります。

以下のgifの通り、少し待って更新するとサーバー側との同期が取れているので、しっかりと更新後のデータとなっていることがわかると思います。

![like button gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/879778/0a002eaf-619f-4214-d925-24421f925073.gif)

話はそれますが、Neon DBのリージョンは最も近いもので、シンガポールなので、日本リージョンが使用できるPlanet Scaleなどだと、サーバーとの同期もより高速になります。
Neon DBも日本リージョンを出すことを検討しているようですが、まだのようです。

https://community.neon.tech/t/hosting-in-other-aws-regions/81/12

また、Planet Scaleは2024/2に無料プランがなくなり、値上げ（改悪）となったので、悲報以外の何物でもありません。

## Vercel deploy
最後にVercel へ deployしてみてください。
前提としてGitHubでソース管理していることが条件です。

以下からVercelにログインし、プロジェクトをhostingしていきます。
環境変数を設定するだけでdeploy作業は完了です。

https://vercel.com/login

最後に、GitHubのOAuth実装時に設定したGitHubのapplicationsのコールバックURLなどをVercelのデプロイ先のURLに変更しましょう。

以上で、実装は完了です。

# おわりに
いかがでしたでしょうか？
HonoのRPCがかなり強力かつ、React19が便利すぎるということが伝わったのではないでしょうか。

React19では特に`useActionState`は高頻度で使うので、これはフロントエンドやっていくうえでは必修科目です。なので、今のうちからしっかりキャッチアップしておきましょう！


# 参考文献
https://hono.dev/docs/getting-started/vercel#node-js

https://zenn.dev/yusukebe/articles/a00721f8b3b92e

https://zenn.dev/chot/articles/e109287414eb8c

https://neon.tech/docs/introduction

https://orm.drizzle.team/

https://zenn.dev/farstep/books/nextjs-authjs-drizzle

https://authjs.dev/

https://getjustd.com/docs/getting-started/installation

https://biomejs.dev/guides/getting-started/

https://nextjs.org/docs/app/building-your-application/routing/dynamic-routes#catch-all-segments

https://zenn.dev/pino0701/articles/nextjs_oauth

https://qiita.com/curry__30/items/24e7c144123a9fc15fa3