11
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

クラベスAdvent Calendar 2024

Day 20

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

Last updated at Posted at 2024-12-19

はじめに

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

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

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

アプリ概要

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

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

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

技術スタック

実装

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

  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と連携

以下に従い、ライブラリの導入等を行っていきます。

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

以下のコマンドを実行して、必要なライブラリを導入しましょう。

fish
bun add @t3-oss/env-nextjs zod

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

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

そして、drizzleのDB連携の部分ですが、以下のようにします。
schemaについては次項で作成します。

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作成

以下のようにアプリ要件を満たすため、usersaccountssessionspostslikesの5つのschemaを作成します。

なお、usersaccountssessionsについては、Auth.jsのドキュメントに記載があるものを少し編集したものを使用します。
verificationTokensauthenticatorsは今回は使用しないので、定義からは除外しています
また、usersについてはbcrypt.jsによるhash化したpasswordと各種dateを持つようにしています

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

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に反映していきましょう。
以下のコマンドを実行します。

fish
bunx drizzle-kit generate
bunx drizzle-kit migrate

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

Auth.jsによる認証実装

以下のNext.js用のinstallationに従い、進めていきます。

ライブラリ導入

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

fish
bun add next-auth@beta

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

fish
bun add @auth/drizzle-adapter

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

fish
bunx auth secret

Auth.jsのconfig作成

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

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

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

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

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

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

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ならCredentialsauthorizeの後に実行される)
    • session: tokenから取得したユーザーIDをsessionに設定
    • jwt: tokenのカスタマイズ
  • providers
    • GitHub: GitHub 設定ページにて作成した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を取得していただければと思います。

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

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

export const { GET, POST } = handlers

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

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

SignUpページの作成

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

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
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

またsignUp時のserver actionsは以下のようにしています。

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.jssignUp()を使用するためです。
ConformuseActionStateで行いたかったのですが、どうもうまくできなかったので、react-hook-formを使用するようにしました。

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

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

SignInページの作成

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

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
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

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

HonoによるAPIの実装

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

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

HonoをAPI Routesに導入

まずは、Honoを導入します。

fish
bun add hono

そして、catch-all-segmentsを使用して、API RoutesをHonoでハックしていきます。

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を作って完了です。

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関数を作成します。

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

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します。

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

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

image.png

投稿詳細UI作成

こちらも同じように、HonoのAPI clientから型を取得して、fetchしていきます。

[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
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

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

投稿機能・編集・削除機能のServer Actions実装

それでは、順番に見ていきましょう。
なお、formバリデーションライブラリにはConfom、server actionsの管理にはReact19で搭載されたuseActionStateを使用していきます。

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

投稿機能

フォームスキーマ定義

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

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部分ですが、以下のようにします。

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

server actions

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

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

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にして渡す方法でも可能です。

そして、useEffectでは、server actionsの成功時にtoastが出るようにしています。
また、Modalを更新時に閉じないので、formの値を前のserver actionsの結果にする必要があるので、TextFieldのdefaultValue属性を設定しています。

Modalを閉じたい場合は、useEffectonCloseを記述するだけで対応可能です。

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 { 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, useEffect } 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 [lastResult, action, isPending] = useActionState(editPost, null)

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

    defaultValue: {
      content,
    },
  })

  const router = useRouter()

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

  return (
    <Modal isOpen={isOpen} onOpenChange={isPending ? undefined : onClose}>
      <Modal.Content>
        <Modal.Header>
          <Modal.Title>Edit Post</Modal.Title>
        </Modal.Header>
        <Form
          {...getFormProps(form)}
          action={(formData) => {
            if (!postId) {
              return
            }

            formData.append('postId', postId)
            action(formData)
          }}
        >
          <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

server actions

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

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で行っています。

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

server actions

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

テーブルの処理としては、SQLのdeleteを行うようにするだけです。

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をいいね機能で使ってみたいと思います。
X(旧Twitter)などSNSのいいね機能などは基本楽観的UI更新をしているので、その挙動を作ってみようと思います。

いいね機能 Optimistic UI

まずは、コンポーネント部分からです。

UI作成

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

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は以下のようにしています。

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

そして、LikeButtonのpropsの型は以下となっています。

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',・・・]
<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を返却するというルールを理解するとわかりやすいと思います。

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が表示されます。
そして、いいね数も表示します。

<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

server action

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

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

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

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

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

Vercel deploy

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

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

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

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

おわりに

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

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

参考文献

11
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?