0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

簡単なCRUDアプリでNext.jsつかってみたメモ

Last updated at Posted at 2025-08-17

概要

技術記事の投稿アプリを通してNext.jsの学習を行なったメモ
学習内容は目次のとおり
Next.jsとは関係ない基礎的な内容もメモしてたりします

axiosとfetchの違い

目的

  • どちらも非同期に外部リソースからデータを取得するため
  • どちらもPromiseオブジェクトを返却

提供

  • fetch : ブラウザ標準で組み込まれているAPI、ライブラリなどインストール不要
  • axios : サードパーティ製ライブラリ、インストール必要

fetchの特徴

  • 標準で組み込まれているのでバンドルサイズが軽量ですむ

  • レスポンスをそのままjsonとして扱えない response.jso()を呼び出して解析

  • ステータスコード400番台、500番台はエラーを投げない、ステータスコードを確認してエラー処理を行う必要あり

    try {
      const response = await fetch(`${API_BASE_URL}/api/articles/${id}`);
      if (!response.ok) {
        throw new Error("Failed to fetch article");
      } 
      const article = await response.json();
      return article;
    } catch (error) {
      console.error("Error fetching article:", error);
      return null;
    }
    
  • ブラウザのみ動作

  • Service Workerとの統合

    • Service Workerとは、ブラウザがWebページとは別にバックグラウンドで実行するスクリプト
    • 主にオフライン環境での動作、プッシュ通知、バックグラウンドでのデータ同期など、ネットワークリクエストの制御に使われる
    • fetch時の役割は、Service Workerでリクエスト/レスポンスの横取りができる
      • リクエスト前にリクエストの追加、オフラインの場合はキャッシュから返したり
      • レスポンスデータの加工
    • わざわざ、インターセプトするのはなぜ?
      • 共通の処理をService Workerにまとめることができる
      • いろんなファイルにヘッダーに認証情報を追加する処理を書かなくて済む
  • AbortControlerによるキャンセル

    • fetch中の待ち時間にキャンセルができる
    • キャンセルしたい時ってどんな時?
      • タイムアウト:レスポンスまで一定の時間を決めておき、すぎたら切る
      • ページ遷移:何かリクエストの途中にページ遷移をするとなった時に、現在のページで行なっているリクエストをキャンセル、無駄な通信を抑えたり
    .js
    const controller = new AbortController();
    const signal = controller.signal;
    
    try {
      const response = await fetch('https://api.example.com/data', { signal });
      const data = await response.json();
      console.log('データ取得完了:', data);
    } catch (error) {
      // キャンセルされた場合、AbortErrorがスローされる
      if (error.name === 'AbortError') {
        console.log('リクエストがキャンセルされました。');
      } else {
        console.error('その他のエラー:', error);
      }
    }
    
    // 例えば、ボタンクリックでリクエストをキャンセルする場合
    const cancelButton = document.getElementById('cancel-button');
    cancelButton.addEventListener('click', () => {
      controller.abort();
      console.log('キャンセルを指示しました。');
    });
    

axiosの特徴

  • 外部からnpmyarnでインストールする必要あり
  • レスポンスをそのままjsonで扱える
  • ステータスコード400番台、500番台はエラーを自動的にエラーとして扱う
    try {
      const response = await axios.get(`${API_BASE_URL}/api/articles/${id}`);
      reutrn response.data;
    } catch (error) {
      console.error("Error fetching article:", error);
      return null;
    }
    
  • ブラウザとNode.jsの両方で動作
  • リクエストとレスポンスに対するインターセプトを標準で備えている
    • fetchと違いService Workerの知識入らない?
      • axiosで実装するために同じような原理は必要だと思うが
  • リクエストのキャンセルができる処理を標準で備えている

所感

  • 設計段階でインターセプト、キャンセル、2つの機能を使いたいとなったら
  • 自分で作るとバグの温床になったりするのでaxiosを使った方がいいかも

react-hook-formとzodを使ったformバリデーション

目的

  • formのコンポーネントはcomponentのフォルダに管理したい
  • 上記を踏まえてバリデーションの管理もしたい

実現手法

  • contextとproviderを用いてフォームで使う諸々を共有する
% tree
// 別のフォルダでpage.tsxを管理
.
├── ArticleFormContext.tsx
├── MarkdownEditor.tsx
├── PublishSettingsModal.tsx
├── TagSelector.tsx
└── TitleInput.tsx

手順

手順1 createContextでどんな値が入るかを定義するための型を作る例zodバリデーション

ArticleFormContext.tsx
"use client";

import { createContext, useContext, ReactNode } from "react";
import { useForm, UseFormReturn } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

// スキーマ定義
export const articleSchema = z.object({
  title: z
    .string({ message: "型が違います" })
    .min(1, { message: "タイトルは必須です" })
    .max(50, { message: "タイトルは50文字以内にしてください" }),
  tags: z
    .array(z.string({ message: "型が違います" }))
    .min(1, { message: "タグは必須です" })
    .max(5, "タグは5つまでつけられます"),
  body: z
    .string()
    .min(1, { message: "本文は必須です" })
    .max(10000, { message: "本文は10000文字以内にしてください" }),
  publishDate: z.string({ message: "公開日は必須です" }),
  publishTime: z.string().min(1, { message: "公開時間は必須です" }),
  publishStatus: z.enum(["public", "private"], {
    message: "公開ステータスを選択してください",
  }),
});

interface ArticleFormContextType {
  form: UseFormReturn<ArticleFormData>;
  onSubmit: (data: ArticleFormData) => void;
}
  • form : ページ全体のバリデーションを1つのコンテキストで共有するため
    • registerとかtriggerの話
  • onSubmit:関数、formの送信をしている子コンポーネントが送信用の関数をうってたり

手順2 context生成

ArticleFormContext.tsx
const ArticleFormContext = createContext<ArticleFormContextType | null>(null);

手順3 providerと紐付け

ArticleFormContext.tsx
<ArticleFormContext.Provider value={ /* 共有したいデータ */ >      
  {children}    
</ArticleFormContext.Provider>
  • 共有したいデータを渡すことで、そのProviderでラップされた全ての子孫コンポーネントにデータが提供される

手順4 Providerのexportと子コンポーネントからcontextを呼ぶための関数を作る

ArticleFormContext.tsx
export function ArticleFormProvider({
  children,
  onSubmit,
}: ArticleFormProviderProps) {
  const form = useForm<ArticleFormData>({
    resolver: zodResolver(articleSchema),
    mode: "onTouched",
    reValidateMode: "onChange",
    defaultValues: {
      title: "",
      tags: [],
      body: "",
      publishDate: new Date().toISOString().split("T")[0],
      publishTime: "",
      publishStatus: "private",
    },
  });
  
// contextと同じファイル
export function useArticleForm() {
  const context = useContext(ArticleFormContext);
  if (!context) {
    throw new Error("useArticleForm must be used within ArticleFormProvider");
  }
  return context;
}

手順5 親階層でProviderのコンポーネントを呼ぶ

page.tsx
"use client";
        
import { useState } from "react";
import RoundButton from "@/components/ui/RoundButton";
import { postArticle } from "@/feature/articles/articles";

// 分割したコンポーネントをインポート
import { ArticleFormProvider, ArticleFormData } from "@/app/components/articles/forms/ArticleFormContext";
import TitleInput from "@/app/components/articles/forms/TitleInput";
import TagSelector from "@/app/components/articles/forms/TagSelector";
import MarkdownEditor from "@/app/components/articles/forms/MarkdownEditor";
import PublishSettingsModal from "@/app/components/articles/forms/PublishSettingsModal";

export default function CreatePage() {
  const [isModalOpen, setIsModalOpen] = useState(false);

  const handleSubmit = (data: ArticleFormData) => {
    console.log("Form submitted with data:", data);

    const publishedAt = new Date(data.publishDate);
    const [hours, minutes] = data.publishTime.split(":");
    publishedAt.setHours(parseInt(hours), parseInt(minutes), 0, 0);

    const articleData = {
      title: data.title,
      body: data.body,
      tagIds: data.tags.map((tag) => parseInt(tag)),
      publishedAt: publishedAt.toISOString(),
      published: data.publishStatus === "public",
      authorId: 1,
      iconId: 1,
    };

    postArticle(articleData);
  };

  return (
    <ArticleFormProvider onSubmit={handleSubmit}>
      <div className="flex h-full flex-col">
        <div className="mb-4 flex justify-between gap-4">
          <div className="text-3xl font-bold">新規作成</div>
          <RoundButton 
            label="投稿設定" 
            color="blue" 
            colorStrong={500} 
            onClick={() => setIsModalOpen(true)} 
          />
        </div>

        <TitleInput />
        <TagSelector />
        <MarkdownEditor />

        <PublishSettingsModal 
          isOpen={isModalOpen} 
          onClose={() => setIsModalOpen(false)} 
        />
      </div>
    </ArticleFormProvider>
  );
}

手順6 子コンポーネントで呼ぶ

PublishSettingsModal.tsx
"use client";

import RoundButton from "@/components/ui/RoundButton";
import { useArticleForm } from "./ArticleFormContext";

interface PublishSettingsModalProps {
  isOpen: boolean;
  onClose: () => void;
}

export default function PublishSettingsModal({
  isOpen,
  onClose,
}: PublishSettingsModalProps) {
  const {
    form: {
      register,
      formState: { errors },
      trigger,
      handleSubmit,
    },
    onSubmit,
  } = useArticleForm();

  if (!isOpen) return null;

  return (
    <div
      className="fixed top-0 left-0 z-30 flex h-full w-full items-center justify-center bg-black/75"
      onClick={onClose}
    >
      <div
        className="items-left relative z-40 flex min-w-[300px] flex-col justify-center rounded-lg bg-white px-10 py-4"
        onClick={(e) => e.stopPropagation()}
      >
        <button className="absolute top-2 right-2" onClick={onClose}>
          <svg
            className="h-6 w-6"
            fill="none"
            stroke="currentColor"
            viewBox="0 0 24 24"
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              strokeWidth={2}
              d="M6 18L18 6M6 6l12 12"
            />
          </svg>
        </button>
        <p className="p-6 text-center text-2xl font-bold">投稿設定</p>

        <div className="p-2">
          <label className="mb-2 block text-sm font-medium text-gray-900 dark:text-white">
            公開日
          </label>
          <input
            {...register("publishDate", { valueAsDate: true })}
            onChange={(e) => {
              register("publishDate", { valueAsDate: true }).onChange(e);
              trigger("publishDate");
            }}
            type="date"
            className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500"
          />
          {errors.publishDate?.message && (
            <p className="text-sm text-red-500">{errors.publishDate.message}</p>
          )}
        </div>

        <div className="p-2">
          <label className="mb-2 block text-sm font-medium text-gray-900 dark:text-white">
            公開時間
          </label>
          // 公開時間のコンポーネントもありますが割愛
        </div>

        <div className="p-2">
          <p className="p-2 text-xl font-bold">公開設定</p>
          <div className="space-y-4">
            <div className="flex items-center">
              <input
                {...register("publishStatus")}
                value="public"
                className="h-5 w-5 border-gray-300 text-blue-600 focus:ring-blue-500"
                id="publish-public"
                type="radio"
              />
              <div className="ml-3 text-sm">
                <label
                  className="font-medium text-gray-800"
                  htmlFor="publish-public"
                >
                  公開
                </label>
                <p className="text-gray-500">記事が閲覧可能な状態です</p>
              </div>
            </div>
            <div className="flex items-center">
              <input
                {...register("publishStatus")}
                value="private"
                className="h-5 w-5 border-gray-300 text-blue-600 focus:ring-blue-500"
                id="publish-private"
                type="radio"
              />
              <div className="ml-3 text-sm">
                <label
                  className="font-medium text-gray-800"
                  htmlFor="publish-private"
                >
                  非公開
                </label>
                <p className="text-gray-500">記事は閲覧されません</p>
              </div>
            </div>
          </div>
        </div>
        {errors.publishStatus?.message && (
          <p className="text-sm text-red-500">{errors.publishStatus.message}</p>
        )}

        <RoundButton
          label="投稿"
          margin="m-4"
          color="blue"
          colorStrong={500}
          onClick={handleSubmit((data) => {
            onSubmit(data);
            onClose();
          })}
        />
      </div>
    </div>
  );
}
0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?