概要
技術記事の投稿アプリを通して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中の待ち時間にキャンセルができる
- キャンセルしたい時ってどんな時?
- タイムアウト:レスポンスまで一定の時間を決めておき、すぎたら切る
- ページ遷移:何かリクエストの途中にページ遷移をするとなった時に、現在のページで行なっているリクエストをキャンセル、無駄な通信を抑えたり
.jsconst 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の特徴
- 外部から
npm
、yarn
でインストールする必要あり - レスポンスをそのまま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で実装するために同じような原理は必要だと思うが
- fetchと違いService Workerの知識入らない?
- リクエストのキャンセルができる処理を標準で備えている
所感
- 設計段階でインターセプト、キャンセル、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>
);
}