TECHSCORE BLOG

クラウドCRMを提供するシナジーマーケティングのエンジニアブログです。

Next.js App Router の基礎:フロントエンドエンジニアのための入門ガイド(Server Actionsとフォーム処理編)

1. はじめに

1.1 第3回の位置づけ

第1回では Server Components と Client Components の基本概念を学び、第2回ではルーティングとレイアウトの仕組みを理解しました。今回の第3回では、これらの知識を活かして、Server Actions による新しいフォーム処理手法を習得し、実際の Web アプリケーション開発で必要となる認証・セッション管理まで踏み込んでいきます。

これにより、App Router を使った完全な Web アプリケーション開発のための基盤が完成します。

過去の記事は以下からご覧いただけます。
Next.js App Router の基礎:フロントエンドエンジニアのための入門ガイド(概念とディレクトリ構造編)

Next.js App Router の基礎:フロントエンドエンジニアのための入門ガイド(コンポーネントとデータ取得編)

1.2 この記事で身につくスキル

本記事を通じて、以下のスキルを習得できます。

  • Server Actions の基本概念と実装方法 - API Routes なしでサーバーサイド処理を実現
  • フォームバリデーションとエラーハンドリング - Zod を使った型安全な検証
  • メタデータと SEO 対応 - 動的・静的なメタデータ管理
  • 認証・セッション管理の基礎 - cookie を使った安全な実装パターン

特に重要なのは、React 19 で導入された useActionState フックの活用です。以前の useFormState は非推奨となり、より汎用的な useActionState に置き換わりました。

1.3 従来のフォーム処理との違い

Pages Router では、フォーム処理のために API Routes を作成し、クライアントサイドで fetch を使ってデータを送信する必要がありました。App Router の Server Actions では、この流れが大きく簡略化されています。

Pages Router の場合

// pages/api/posts/create.ts
export default async function handler(req, res) {
  const data = req.body
  // 処理...
}

// pages/posts/new.tsx
const handleSubmit = async (e) => {
  e.preventDefault()
  await fetch('/api/posts/create', {
    method: 'POST',
    body: JSON.stringify(data)
  })
}

App Router の場合

// app/posts/new/page.tsx
async function createPost(formData: FormData) {
  'use server'
  // 直接サーバー処理を記述
}

<form action={createPost}>
  {/* フォーム要素 */}
</form>

この変更により、次のようなメリットが得られます。

  • Progressive Enhancement - JavaScript が無効でも動作
  • コード量の削減 - API Routes が不要
  • 型安全性の向上 - Server と Client で型を共有

Server Actions は、裏側で標準的な HTML の<form>の仕組みを利用しています。JavaScript が有効な環境では fetch による非同期通信しますが、無効な環境でも通常のフォーム送信として機能するため、基本的な操作が保証されます。

ちなみに、このようにサーバーから送られてきた静的な HTML に、ブラウザ側で JavaScript のイベントハンドラなどを関連付けてインタラクティブにするプロセスのことをハイドレーション(Hydration)と呼びます。Server Actions の大きな利点は、このハイドレーションが完了する前でも機能することです。


2. Server Actions の基礎

2.1 Server Actions とは何か

Server Actions は、React Server Components から直接呼び出せるサーバーサイド関数です。"use server" ディレクティブを使用することで、その関数がサーバー上でのみ実行されることを保証します。

// lib/actions.ts
"use server"

import { redirect } from 'next/navigation'
import { db } from '@/lib/db'

export async function createPost(formData: FormData) {
  // FormData から値を取得
  const title = formData.get('title') as string
  const content = formData.get('content') as string

  // データベースへの保存処理
  const post = await db.post.create({
    data: {
      title,
      content,
      publishedAt: new Date()
    }
  })

  // 作成した投稿ページへリダイレクト
  redirect(`/posts/${post.id}`)
}

Server Actions の主な特徴は以下の通りです。

  • サーバーサイドのみで実行 - セキュアな処理が可能
  • 自動的な最適化 - Next.js が自動的にエンドポイントを生成
  • Progressive Enhancement 対応 - JavaScript なしでも動作

2.2 Server Actions の定義方法

Server Actions は複数の方法で定義できます。

1. ファイル単位での定義(推奨)

この方法が推奨されるのは、サーバーロジックをコンポーネントから分離できるためです。関心の分離が促進され、コードの再利用性や保守性が向上します。

// app/actions/post-actions.ts
"use server"

export async function createPost(formData: FormData) {
  // すべての export された関数が Server Action になります。
}

export async function updatePost(id: string, formData: FormData) {
  // これも Server Action になります。
}

2. インライン定義(Server Component 内のみ)

コンポーネント内でのみ使用される、再利用の必要がない単純な Action を定義する場合に一時的に利用できます。ロジックがコンポーネントに密結合するため、小規模なプロトタイピングや、ごく単純なケースを除いては推奨されません。コンポーネントとサーバーロジックが分離できないため、テストがしにくく、再利用もできないというデメリットがあります。

// app/posts/new/page.tsx
export default function NewPostPage() {
  async function createPost(formData: FormData) {
    "use server"
    // この関数のみが Server Action になります。
  }

  return <form action={createPost}>...</form>
}

3. bind を使った引数の事前バインド

既存の Server Action に、フォームデータ以外の追加の引数を渡したい場合に利用します。この例では、updatePost Action に投稿 ID を事前に bind することで、どの投稿を更新するかを特定しています。これにより、再利用可能な Action を特定のコンテキストで簡単に利用できます。

// app/posts/[id]/edit/page.tsx
import { updatePost } from '@/app/actions'

export default async function EditPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const updatePostWithId = updatePost.bind(null, id)

  return <form action={updatePostWithId}>...</form>
}

2.3 FormData の取得と処理

Server Actions は、標準の Web API である FormData オブジェクトを引数として自動的に受け取ります。これにより、HTML のフォーム要素と直接的に連携できます。

FormData オブジェクトからデータを取り出すための一般的な方法をいくつか紹介します。

"use server"

export async function createPost(formData: FormData) {
  // get(name): 特定のnameを持つ最初の値を取得
  const title = formData.get('title') as string
  const content = formData.get('content') as string

  // getAll(name): 同じnameを持つ全ての値(チェックボックスなど)を配列として取得
  const tags = formData.getAll('tags') as string[]

  // ファイルアップロード: <input type="file"> からはFileオブジェクトを取得
  const image = formData.get('image') as File

  // オブジェクトへの変換: Object.fromEntries() を使ってプレーンなオブジェクトに変換
  // 注意: この方法は同じnameのキーが複数ある場合、最後の値のみが採用されるため、
  // 複数選択フィールドなどでは getAll() を使う方が安全
  const rawData = Object.fromEntries(formData)

  // この後、Zodなどのライブラリを使ってバリデーションを行うのが一般的(後述)
  // 処理を続行...
}

このように、FormData API を利用することで、テキストデータだけでなく、ファイルなども含めた複雑なフォームデータを柔軟に扱うことができます。

2.4 Server Actions の制約と注意点

Server Actions を使用する際の重要な制約と注意点を理解しておきましょう。

1. Serializable なデータのみ扱える

Server Actions はクライアントとサーバー間でデータを通信するため、引数と返り値はシリアライズ可能(文字列や JSON 形式に変換可能)なデータ型でなければなりません。関数、Date オブジェクト、Map、Set といった複雑なオブジェクトは直接渡すことができません。プレーンなオブジェクトやプリミティブ値を使いましょう。

"use server"

// ❌ エラー: 関数はシリアライズできないため送信できない
export async function badAction(callback: Function) { }

// ✅ OK: プレーンなオブジェクトはシリアライズ可能
export async function goodAction(data: { name: string; age: number }) { }

2. Client Component からの呼び出し

Client Component からでも Server Action を呼び出すことは可能です。ただし、その場合でも Action のコード自体はサーバー上でのみ実行されます。Client Component 内に Server Action を直接定義することはできず、"use server" ディレクティブを持つ別ファイルからインポートして使用するのが一般的です。

'use client'

import { createPost } from '@/app/actions' // "use server" ファイルからインポート

export function ClientForm() {
  // Client Component から Server Action を呼び出し可能
  // createPost はサーバーで実行される
  return <form action={createPost}>...</form>
}

3. エラーハンドリングの重要性

Server Action はサーバーサイドで実行されるため、データベース接続エラーや外部 API の呼び出し失敗など、様々なエラーが発生する可能性があります。try...catch ブロックでエラーを捕捉し、シリアライズ可能なエラーオブジェクトを返すことで、クライアント側でエラー状態を検知し、ユーザーに適切なフィードバック(「投稿に失敗しました」など)を表示できます。エラーを放置すると、予期せぬクラッシュや無反応な UI につながる可能性があります。

"use server"

export async function createPost(formData: FormData) {
  try {
    // 失敗する可能性のある処理...
    const post = await db.post.create({ data: { ... } })
    return { success: true, postId: post.id }
  } catch (error) {
    // エラーは Client に返せる形式にする
    console.error(error); // サーバー側でログを確認
    return {
      error: '投稿の作成に失敗しました。'
    }
  }
}

3. フォームの実装と処理

3.1 基本的なフォーム実装

Next.js 15 では、HTML フォームと Server Actions を組み合わせることで、Progressive Enhancement を実現できます。

次のような実装例を見てみましょう。

// app/posts/new/page.tsx
import { createPost } from '@/lib/actions'

export default function NewPostPage() {
  return (
    <form action={createPost} className="max-w-md mx-auto">
      <div className="mb-4">
        <label htmlFor="title" className="block mb-2">
          タイトル
        </label>
        <input
          id="title"
          name="title"
          type="text"
          required
          className="w-full px-3 py-2 border rounded"
        />
      </div>

      <div className="mb-4">
        <label htmlFor="content" className="block mb-2">
          内容
        </label>
        <textarea
          id="content"
          name="content"
          required
          rows={5}
          className="w-full px-3 py-2 border rounded"
        />
      </div>

      <button
        type="submit"
        className="bg-blue-500 text-white px-4 py-2 rounded"
      >
        投稿する
      </button>
    </form>
  )
}

このコードのポイント

  1. シンプルな HTML 構造: 特別なライブラリを使わず、標準的な HTML 要素のみで構成
  2. action 属性の活用: form 要素の action に Server Action を直接指定することで、フォーム送信時に自動的にサーバー側で処理
  3. name 属性の重要性: 各 input 要素の name 属性が FormData のキーとなり、Server Action 側で値を取得可能
  4. HTML5 バリデーション: required 属性により、ブラウザレベルでの基本的なバリデーションを実装

このアプローチにより、JavaScript が無効な環境でも動作し、かつモダンなユーザー体験を提供できます。Server Action 側では、受け取った FormData を使ってデータベースへの保存やリダイレクトなどの処理を行います。

3.2 フォームバリデーション

実際の Web アプリケーションでは、ユーザーが入力したデータの検証が必要不可欠です。Next.js App Router では、Zod ライブラリを活用することで、型安全で保守性の高いバリデーション機能を構築できます。

まず、バリデーションスキーマの定義から始めます。Zod を使用することで、TypeScript の型定義とランタイムのバリデーションを統一でき、開発効率と型安全性の向上を両立できます。

// lib/validations.ts
import { z } from 'zod'

export const postSchema = z.object({
  title: z
    .string()
    .min(1, 'タイトルは必須です')
    .max(100, 'タイトルは100文字以内で入力してください'),
  content: z
    .string()
    .min(1, '内容は必須です')
    .max(5000, '内容は5000文字以内で入力してください'),
  tags: z
    .array(z.string())
    .max(5, 'タグは5個まで設定できます')
    .optional(),
})

export type PostInput = z.infer<typeof postSchema>

このバリデーションスキーマのポイント

  1. 文字列の長さ制限: min()max() でユーザビリティとデータベース制約に基づいた入力制限を設定
  2. 配列の制限: タグ機能では配列の長さを制限してスパム防止
  3. 型の自動推論: z.infer<> により TypeScript の型を自動生成
  4. カスタムエラーメッセージ: 日本語でユーザーフレンドリーなエラー表示

次に、Server Action 側でこのスキーマを使ったバリデーション処理を実装します。

// lib/actions.ts
"use server"

import { postSchema } from '@/lib/validations'
import { redirect } from 'next/navigation'

export async function createPost(prevState: any, formData: FormData) {
  // FormData をオブジェクトに変換
  const rawData = {
    title: formData.get('title'),
    content: formData.get('content'),
    tags: formData.getAll('tags'),
  }

  // バリデーション実行
  const validatedFields = postSchema.safeParse(rawData)

  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      values: rawData,
    }
  }

  // データベースへの保存
  const post = await db.post.create({
    data: validatedFields.data,
  })

  redirect(`/posts/${post.id}`)
}

Server Action 内のバリデーション処理の流れ

  1. データ変換: FormData からプレーンなオブジェクトに変換
  2. 安全なパース: safeParse() を使用してエラー時にもクラッシュを回避
  3. エラーレスポンス: バリデーション失敗時は flatten() でフィールド別エラーを整理
  4. 値の保持: エラー時にユーザーの入力値を保持して UX 向上
  5. 成功時の処理: バリデーション済みデータで DB への保存とリダイレクト

重要なのは、prevState パラメータを使用することで、バリデーションエラーやフォームの状態をクライアント側に返せる点です。これにより、ユーザーは再入力の手間が省けます。

3.3 エラーハンドリングとユーザーフィードバック

バリデーションエラーを含む様々なエラー状況をユーザーに分かりやすく表示することは、優れた UX の基本です。React 19 の useActionState フックを活用して、Server Action から返されたエラー情報を効果的に表示しましょう。

ここでは、Client Component としてフォームを実装し、エラー状態の管理と表示をします。

// app/posts/new/form.tsx
'use client'

import { useActionState } from 'react'
import { createPost } from '@/lib/actions'

const initialState = {
  errors: {},
  values: {
    title: '',
    content: '',
  },
}

export function PostForm() {
  const [state, formAction, pending] = useActionState(createPost, initialState)

  return (
    <form action={formAction}>
      <div className="mb-4">
        <label htmlFor="title">タイトル</label>
        <input
          id="title"
          name="title"
          defaultValue={state.values?.title}
          className={state.errors?.title ? 'border-red-500' : ''}
        />
        {state.errors?.title && (
          <p className="text-red-500 text-sm mt-1">
            {state.errors.title[0]}
          </p>
        )}
      </div>

      <div className="mb-4">
        <label htmlFor="content">内容</label>
        <textarea
          id="content"
          name="content"
          defaultValue={state.values?.content}
          className={state.errors?.content ? 'border-red-500' : ''}
        />
        {state.errors?.content && (
          <p className="text-red-500 text-sm mt-1">
            {state.errors.content[0]}
          </p>
        )}
      </div>

      <button
        type="submit"
        disabled={pending}
        className="bg-blue-500 text-white px-4 py-2 rounded disabled:opacity-50"
      >
        {pending ? '投稿中...' : '投稿する'}
      </button>
    </form>
  )
}

エラーハンドリングとフィードバックの特徴

  1. useActionState フック: Server Action の結果を状態として管理
  2. フィールド別エラー表示: 各入力フィールドに対応するエラーメッセージを表示
  3. 視覚的フィードバック: エラー時はボーダーを赤色に変更して直感的に問題箇所を示す
  4. 入力値の保持: エラー後も defaultValue でユーザーの入力値を維持
  5. 送信中の状態管理: pending フラグでボタンの無効化と視覚的フィードバック

この実装により、ユーザーは何が問題なのかを即座に理解でき、修正も簡単に行えます。

3.4 useActionState と useFormStatus

React 19 で導入された新しいフックにより、フォームの状態管理がより強力になりました。特に useActionStateuseFormStatus の組み合わせで、プロフェッショナルなフォーム体験を提供できます。

useFormStatus は、フォーム内のコンポーネントから送信状態を取得できる便利なフックです。これにより、送信ボタンを別コンポーネントとして分離しながら、適切な状態管理が可能になります。

'use client'

import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { createPost } from '@/lib/actions'

// 送信ボタンコンポーネント
function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button
      type="submit"
      disabled={pending}
      className="bg-blue-500 text-white px-4 py-2 rounded disabled:opacity-50"
    >
      {pending ? (
        <span className="flex items-center">
          <svg className="animate-spin h-4 w-4 mr-2" viewBox="0 0 24 24">
            {/* スピナーアイコン */}
          </svg>
          投稿中...
        </span>
      ) : (
        '投稿する'
      )}
    </button>
  )
}

// メインフォームコンポーネント
export default function EnhancedPostForm() {
  const [state, formAction, pending] = useActionState(createPost, {
    errors: {},
    message: null,
  })

  return (
    <form action={formAction}>
      {state.message && (
        <div className="mb-4 p-3 bg-green-100 text-green-700 rounded">
          {state.message}
        </div>
      )}

      {/* フォーム要素 */}

      <SubmitButton />
    </form>
  )
}

useActionState と useFormStatus の活用ポイント

  1. コンポーネント分離: SubmitButton を独立したコンポーネントとして実装
  2. 状態の共有: useFormStatus により、フォーム全体の送信状態を子コンポーネントで取得
  3. 視覚的フィードバック: スピナーアニメーションでローディング状態を明示
  4. アクセシビリティ: disabled 属性で重複送信を防止
  5. 柔軟な状態管理: useActionState の第三引数 pending で全体的な状態も管理可能

このパターンにより、フォームコンポーネントの責務を明確に分離しつつ、一貫した状態管理を実現できます。

⚠️ 重要な変更点:

  • React 19 では useFormState が非推奨となり、既存コードは useActionState への移行が必要
  • 第3引数で pending 状態を取得でき、より直感的な API 設計に改善
  • 汎用性が向上し、フォーム以外の非同期アクションでも使用可能
  • 型安全性が強化され、TypeScript での型推論がより正確に

4. 実践的な活用例

4.1 メタデータの設定

Web サイトの SEO 対策において、title・description・Open Graph タグなどのメタデータ設定は必須要素です。Next.js App Router では、新しい Metadata API により、型安全で保守性の高いメタデータ管理が可能になりました。

まず、アプリケーション全体の基本的なメタデータを設定します。これらの設定は全ページで共有され、SEO 効果とソーシャルメディアでの表示品質向上に貢献します。

// app/layout.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
  title: {
    template: '%s | My Blog',
    default: 'My Blog',
  },
  description: 'Next.js 15 App Router を使用したブログサイト',
  openGraph: {
    title: 'My Blog',
    description: 'Next.js 15 App Router を使用したブログサイト',
    url: 'https://example.com',
    siteName: 'My Blog',
    locale: 'ja_JP',
    type: 'website',
  },
}

基本メタデータ設定のポイント

  1. タイトルテンプレート: template を使用してページタイトルの統一感を演出
  2. Open Graph タグ: SNS でのシェア時にタイトル・説明・画像が正しく表示されるよう必要な情報を設定
  3. ロケール指定: ja_JP で日本語サイトであることを明示
  4. デフォルト値: 個別ページで設定されていない場合の fallback 値を定義

次に、動的ルートでページ固有のメタデータを設定する方法を見てみましょう。ブログ記事などのコンテンツでは、個別の情報に基づいたメタデータが重要です。

// app/posts/[slug]/page.tsx
export async function generateMetadata({
  params
}: PageProps): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)

  if (!post) return { title: 'Not Found' }

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: 'article',
    },
  }
}

動的メタデータ生成の特徴

  1. generateMetadata 関数: 各ページで個別にメタデータを生成
  2. パラメータの活用: URL パラメータからコンテンツ ID を取得
  3. 条件分岐: 存在しないコンテンツに対して 404 ページを返すエラーハンドリング
  4. 記事タイプ指定: Open Graph の typearticle に設定して SEO 効果を向上
  5. 動的な内容反映: データベースから取得した実際のコンテンツ情報を使用

この実装により、各ページが検索エンジンとソーシャルメディアで適切に表示されます。

4.2 セッション管理の基礎

Web アプリケーションにおいて、ユーザーの認証状態を管理するセッション機能は不可欠です。Next.js 15 では、cookies API が非同期化され、より安全で効率的なセッション管理が可能になりました。

ここでは、Server Component と Server Action を活用したシンプルなセッション管理の実装例を紹介します。実際のプロダクションでは、JWT や専用のセッション管理ライブラリの使用も検討してください。

// lib/session.ts
import { cookies } from 'next/headers'

export async function getSession() {
  const cookieStore = await cookies() // Next.js 15 では非同期
  const sessionToken = cookieStore.get('session-token')

  if (!sessionToken) return null

  // セッション検証ロジック
  return { userId: 'user123', email: 'user@example.com' }
}

// app/dashboard/page.tsx
import { getSession } from '@/lib/session'
import { redirect } from 'next/navigation'

export default async function DashboardPage() {
  const session = await getSession()

  if (!session) {
    redirect('/login')
  }

  return <div>ようこそ、{session.email}さん</div>
}

セッション管理実装のポイント

  1. 非同期 cookies API: Next.js 15 では await cookies() として非同期で呼び出し
  2. Server Component 活用: セッション確認をサーバーサイドで実行してセキュリティ向上
  3. 自動リダイレクト: 未認証ユーザーを適切にログインページへ誘導
  4. cookie ベース認証: セッショントークンを HTTPOnly cookie で安全に管理
  5. 型安全性: セッション情報の型定義により開発効率とバグ防止を両立

このパターンにより、ページアクセス時に自動的な認証チェックとリダイレクトが行われ、認証が必要なページへの不正アクセスを防げます。

4.3 キャッシュとリバリデーション

パフォーマンス向上のため、Next.js は強力なキャッシュ機能を提供していますが、データ更新時にはキャッシュの適切な無効化が重要です。Server Actions と組み合わせることで、データの一貫性を保ちながら最適なパフォーマンスを実現できます。

ここでは、記事データのキャッシュ管理を例に、revalidatePathrevalidateTag の使い分けと、React の cache 関数の活用方法を紹介します。

// lib/actions/posts.ts
"use server"

import { revalidatePath, revalidateTag } from 'next/cache'
import { cache } from 'react'

// キャッシュされた関数
export const getCachedPost = cache(async (id: string) => {
  const post = await db.post.findUnique({
    where: { id },
    include: { author: true },
  })
  return post
})

export async function updatePost(id: string, formData: FormData) {
  // 更新処理
  await db.post.update({
    where: { id },
    data: {
      title: formData.get('title') as string,
      content: formData.get('content') as string,
    },
  })

  // キャッシュの無効化
  revalidatePath(`/posts/${id}`)
  revalidateTag(`post-${id}`)
}

キャッシュとリバリデーションの戦略

  1. React の cache 関数: リクエスト単位での重複データ取得を防止
  2. revalidatePath: 特定パスのキャッシュを無効化してページ全体を更新
  3. revalidateTag: タグベースでより細かいキャッシュ制御が可能
  4. データ整合性: 更新操作後に関連するキャッシュを確実に無効化
  5. パフォーマンス最適化: 必要最小限のキャッシュ無効化でレスポンス速度維持

この組み合わせにより、データの新鮮さとアプリケーションのパフォーマンスを両立できます。特に、複数のページで同じデータを表示する場合に効果的です。


5. まとめ

5.1 3記事シリーズの総括

全3回のシリーズを通じて、Next.js App Router による モダンな Web アプリケーション開発に必要なスキルを習得しました。第1回(概念とディレクトリ構造)、第2回(コンポーネントとデータ取得)、そして今回第3回(Server Actions とフォーム処理)で、実務レベルのアプリケーション開発基盤が整いました。

5.2 この記事で学んだこと

この記事では、Next.js 15 App Router における Server Actions を中心に、以下の内容を解説しました。

Server Actions の基礎

  • "use server" ディレクティブによるサーバーサイド関数の定義
  • FormData を使用したデータの送受信
  • Progressive Enhancement の実現

フォーム処理の実装

  • HTML 標準フォームと Server Actions の統合
  • Zod を使用した型安全なバリデーション
  • useActionState と useFormStatus による UI 状態管理

実践的な活用例

  • メタデータの動的生成
  • セッション管理の基礎
  • キャッシュとリバリデーション

5.3 次のステップ

3記事シリーズで習得した基礎を活かし、さらに高度な機能の学習を推奨します。

  1. Streaming と Suspense - より高度な UX の実現
  2. Parallel Routes - 複雑な UI パターンの実装
  3. Partial Prerendering - パフォーマンスの最適化

5.4 参考資料


💡 最後に

この記事で学んだ基礎知識を活用して、より良い Web アプリケーションを構築していきましょう。

Happy Coding! 🚀


hiraokuのプロフィール画像
hiraoku

暇があったらクライミングしているフロントエンドエンジニアです。

シナジーマーケティング株式会社では一緒に働く仲間を募集しています。