ヒント集

クリア済みの課題(01〜05・10)と、07(実施画面のUI)・08(自動スクロール)は外しました。どの課題も「確認①②…」の順にコードを見て、当てはまる「もし」だけを読めば進めます。当日わかったこと(ボタンはある・CSS はある など)を反映して、既存ファイルを直す・足す前提にしています。

見方

目印なし

React・Next.js・Tailwind・ライブラリの決まった単語。変えると動かない。

例:useState / onClick / className / "use client" / page.tsx / flex / md:

黄色

会社のコードにある名前。当日のコードを見て同じ名前に書き換える。ここが違うと動かない。

例:surveys / questions / title / createdAt / --primary

青い下線

自分で付けた名前。そのままでよい。変えるなら同じ名前の場所を全部そろえる。

例:sorted / keyword / ColorPicker / chart-in

分岐(「もし」)は、当日のコードに当てはまるものだけ読めばよい。上から順に見て、最初に当てはまったものを使う。

コードの見出しの札

  • 新規ファイル新しくファイルを作って、コードを全部貼る。
  • 追記今あるファイルの指定の場所に、コードを足す(今のコードは消さない)。
  • 書き換え今あるコードの、その行(その部分)をこのコードに置き換える。
  • 確認用・後で消す原因を探すために一時的に足すコード(console.log など)。わかったら消す。

当日の探し方

  1. 画面の文字で探すCtrl+Shift+F(Mac は Cmd+Shift+F)で全体検索。「削除」「保存」など画面に出ている文字で探すのが一番早い。
  2. button onclick で出ない理由コードでは <Button(大文字の部品)で、onClick は別の行に書かれていることが多い。複数の単語をまとめて検索しても出ない。1語ずつ(onClick だけ、画面の文字だけ)で探し、見つかったファイルの中で Ctrl+F する。
  3. 名前の正体を見る名前にマウスを乗せると型が出る({ id: number; title: string … } のように項目名がわかる)。F12(または Ctrl+クリック)で定義へ移動。
  4. ファイルをたどるpage.tsx に <SurveyList … /> のような部品しか無いときは、その部品名を Ctrl+クリックして中を開く。本体はだいたい components フォルダにある。
  5. "use client" は新規ファイルの印ではないuseState・useTheme・onClick などを使うファイルの1行目に書く宣言。今あるファイルで使うなら、そのファイルの1行目に無ければ足すだけ。
  6. console.log の出る場所"use client" があるファイル → ブラウザ(F12 の Console)。無いファイル(page.tsx など)→ VS Code のターミナル(npm run dev を動かしている画面)。
  7. 入っているものを確かめるpackage.json の dependencies(next の版、next-themes、recharts、react-hook-form、zustand など)。components/ui フォルダ(shadcn/ui の部品)。
  8. ボタンが反応しないときonClick があって関数も正しそうなら、まず関数の1行目に console.log("押された")。出るか出ないかで原因が半分に絞れる(共通診断)。
  9. 新しいファイルはほとんど作らないボタン・CSS・関数はだいたいすでにある。まず今あるものを探して直す・足す。新しいファイルが必要なのは 14 の page.tsx くらい。

自分で調べる方法(Web 検索はOK・AI はNG)

何を調べるときに、どこを見るか

知りたいこと見る場所検索の言葉
JavaScript の書き方(配列の関数・if・?? など)、ブラウザの機能(classList・localStorage・alert)MDN(日本語あり)「MDN forEach」のように MDN + 名前
React の書き方(useState・useEffect・onClick・key・配列の state)React公式(日本語版)「react useState 配列 更新」
Next.js(フォルダと URL・page.tsx・Link・"use client"・metadata・middleware)Next.js公式(App Router のページを見る)「nextjs app router + やりたいこと」
Tailwind のクラス名(色・余白・dark:・md:)Tailwind公式(右上の検索 Ctrl+K)「tailwind + 英語のやりたいこと」例:tailwind dark mode
ダークモード(next-themes)next-themes(GitHub)(README を下まで読む)「next-themes + 困っていること」
shadcn/ui の部品(Button・DropdownMenu・Chart)shadcn/ui公式「shadcn + 部品名」
フォーム(react-hook-form・zod)react-hook-form公式 / zod公式「react-hook-form useFieldArray」
状態の保存場所(Zustand)Zustand公式「zustand + やりたいこと」
ドラッグで並べ替えdnd kit公式 / Motion公式 / @hello-pangea/dnd(GitHub)「dnd-kit sortable」「framer motion reorder」
グラフRecharts公式 / Chart.js公式「recharts pie chart」
TypeScript の型・赤い波線TypeScript公式エラー文をそのまま検索
エラー文の意味Stack Overflow(英語)・GitHub の Issuesエラー文をそのまま " " で囲んで検索
日本語でやさしく読みたいZenn / Qiita「Next.js ダークモード 実装」など。記事の日付と版を必ず確認

検索のコツ

  1. まず版を確かめるpackage.json で next・react・tailwindcss・next-themes などの版を見る。Next.js 13 以降の App Router と、古い Pages Router(pages フォルダ)は書き方が違う。Tailwind も v3 と v4 で設定が違う。検索に「app router」「tailwind v4」を足すと混ざらない。
  2. 公式を先に見るQiita・Zenn・ブログは読みやすいが、古い書き方も多い。公式の例(Examples)をまず見て、わからなかったら記事で補う。
  3. 英語で検索すると早い「next-themes setTheme not working」のように英語で書くと、Stack Overflow や GitHub の Issues に同じ困りごとが見つかりやすい。英語はブラウザの翻訳で読んでよい。
  4. エラー文は固有の部分を消すファイル名・行番号・自分の変数名を消して、残りを " " で囲んで検索する。例:"Cannot read properties of undefined (reading 'forEach')"。
  5. 記事の日付を見る2022年以前の React / Next.js の記事は、今と書き方が違うことがある(getServerSideProps・pages/・_app.tsx が出てきたら古い Pages Router)。
  6. 検索しても答えが出ないとき自分で console.log を入れて値を見るほうが早いことが多い(共通診断)。「どこで止まっているか」がわかると検索の言葉も決まる。
  7. AI は使わないAI のチャットや、エディタの AI 補完(Copilot など)はルール違反になりうる。検索結果の上に出る AI の要約も頼らず、下の公式ページ・記事を開いて読む。

各課題の最後にも「自分で調べるなら」の表があります。

使っている構文の解説

このヒント集のコードに出てくる書き方。わからない書き方が出たらここに戻る。「調べる」の言葉をそのまま Google に入れると公式の説明が出る。

JavaScript の基本

const / let

名前を付けて値を入れておく箱。const は入れ直さない箱、let は入れ直す箱。React ではほぼ const。

const no = i + 1;

使う課題:09・15・19調べる:MDN const(MDN)

関数(function とアロー関数)

処理をまとめて名前を付けたもの。function 名前(引数) { … } と const 名前 = (引数) => { … } は同じ意味。中身が1つの式なら { } と return を省ける:(a) => a + 1。

const double = (x) => x * 2;

使う課題:全部調べる:MDN アロー関数式(MDN)

分割代入 { } と [ ]

オブジェクトや配列から中身を取り出して名前を付ける。const { a, b } = obj は obj.a obj.b を取り出す。const [x, setX] = useState() は配列の1つ目と2つ目。

const { theme, setTheme } = useTheme();

使う課題:12・18・19調べる:MDN 分割代入(MDN)

スプレッド ...

中身を広げる。[...配列] は配列のコピー、{ ...obj, a: 1 } はコピーして a だけ上書き、{...props} は受け取ったものを全部渡す。

const next = [...questions];

使う課題:15・18・19調べる:MDN スプレッド構文(MDN)

テンプレートリテラル `…${}…`

文字の中に値を入れる。バッククォート(Shift+@)で囲み、値を ${ } に書く。

`Q${no}:質問文が空欄です`

使う課題:09・15調べる:MDN テンプレートリテラル(MDN)

三項演算子 a ? b : c

「a が true なら b、違えば c」を1行で書く。

resolvedTheme === "dark" ? "light" : "dark"

使う課題:12・15調べる:MDN 条件 (三項) 演算子(MDN)

&& と ||

a && b=両方 true(React では「a なら b を表示」にも使う)。a || b=どちらか true/a が空なら b。

if (q.type === "radio" || q.type === "checkbox")

使う課題:09・15調べる:MDN 論理 AND / 論理 OR(MDN)

?? と ?.

a ?? b=a が null / undefined なら b。a?.b=a が無ければエラーにせず undefined。f?.()=f が無ければ呼ばない(何も起きないのにエラーも出ない原因になる)。

(q.choices ?? []).forEach(…)

使う課題:09・18調べる:MDN Null 合体演算子 / MDN オプショナルチェーン(MDN)

=== と !==

型まで同じかを比べる。"3" === 3 は false(文字と数字)。

String(q.id) !== String(id)

使う課題:09・18調べる:MDN 厳密等価(MDN)

if と return(早く抜ける)

条件に合ったら return で関数をそこで終わる。forEach の中の return は forEach の1回分しか終わらない。

if (!validate(questions)) return;

使う課題:09・18調べる:MDN return(MDN)

async / await

時間のかかる処理(保存・通信)を待つ。チェックは最初の await より上に書く。

const handleSave = async () => { … await fetch(…) }

使う課題:09調べる:MDN async function(MDN)

import / export

ほかのファイルの部品や関数を使う/使えるようにする。export default は1ファイル1つ、{ } 付きは名前で選ぶ。

import { useTheme } from "next-themes";

使う課題:全部調べる:MDN import(MDN)

配列の関数

forEach

1つずつ取り出して処理する。何も返さない。途中で止められない(break も return も効かない)。

questions.forEach((q, i) => { … });

使う課題:09・15調べる:MDN forEach(MDN)

map

1つずつ変換した新しい配列を作る。React で一覧を表示するのもこれ。

questions.map((q) => q.id)

使う課題:全部調べる:MDN map(MDN)

filter

条件が true のものだけ残した新しい配列。削除や検索に使う。元の配列は変わらない。

questions.filter((q) => q.id !== id)

使う課題:13・18調べる:MDN filter(MDN)

some

1つでも条件に合えば true。

choices.some((c) => c.label === "")

使う課題:09調べる:MDN some(MDN)

findIndex

条件に合う最初のものが何番目か(無ければ -1)。

questions.findIndex((q) => q.id === active.id)

使う課題:19調べる:MDN findIndex(MDN)

splice

取り出す・差し込む。元の配列を直接変えるので、React ではコピー([...配列])にしてから使う。

next.splice(to, 0, item);

使う課題:19調べる:MDN splice(MDN)

push / join / length

push 最後に足す、join("\n") つないで1つの文字に、length 個数。

errors.push("…"); errors.join("\n");

使う課題:09調べる:MDN push / MDN join(MDN)

sort / includes

sort 並べ替え(元を変えるのでコピーしてから)、includes 含まれているか。

[...surveys].sort(…)

使う課題:06・13調べる:MDN sort / MDN includes(MDN)

String(x).trim()

String() で必ず文字に。trim() で前後の空白を消す。

String(c.label ?? "").trim() === ""

使う課題:09調べる:MDN trim(MDN)

React

"use client"

このファイルはブラウザで動く、という宣言。useState・useEffect・onClick を使うファイルの1行目。新しいファイルの印ではない。

"use client";

使う課題:12・15・19調べる:Next.js "use client"(Next.js公式)

onClick の渡し方

onClick={f} 関数を渡す、onClick={() => f(x)} 引数付きで呼ぶ関数を渡す。onClick={f()} は今すぐ実行してしまう(間違い)。

onClick={() => removeQuestion(q.id)}

使う課題:全部調べる:React イベントへの応答(React公式)

useState

画面の中で変わる値を覚える箱。set で変えると描き直される。set した直後の変数はまだ古い値。

const [open, setOpen] = useState(false);

使う課題:全部調べる:React useState(React公式)

配列の state は新しい配列で

push / splice で中身を変えて同じ配列を set しても描き直されない。filter・map・[...] で新しい配列を作って set。

setQuestions(questions.filter(…));

使う課題:18・19調べる:React state 内の配列の更新(React公式)

useEffect

表示の後や、値が変わった後に動く処理。最後の [ ] は「何が変わったら動くか」。[] なら最初の1回だけ。

useEffect(() => { … }, [theme]);

使う課題:12・15調べる:React useEffect(React公式)

props と children

親から子へ渡す値や関数。<Card onDelete={f} /> の onDelete。children はタグの間に書いた中身。

function Card({ onDelete, children }) { … }

使う課題:18・19調べる:React コンポーネントに props を渡す(React公式)

関数を children に渡す(19)

{(handle) => <div>…</div>} のように、中身を「関数」で渡すと、親から値(ハンドルの印など)を受け取れる。

{(handle) => <button {...handle}>⋮⋮</button>}

使う課題:19調べる:react render props(React公式)

key

一覧の1件ずつを見分ける印。ID を使う。番号(i)だと削除・並べ替えで入力欄がずれる。

<div key={q.id}>

使う課題:18・19調べる:React リストのレンダー key(React公式)

createContext / useContext / Provider

離れた部品どうしで値を共有する仕組み。Provider で囲まれていないと初期値が使われる(12 で set が効かない原因)。

const ThemeContext = createContext(…);

使う課題:12調べる:React useContext(React公式)

e.preventDefault()

ブラウザ本来の動き(送信・ページ移動・ドロップ禁止)を止める。

onSubmit={(e) => e.preventDefault()}

使う課題:09・19調べる:MDN preventDefault(MDN)

TypeScript(型)

型注釈 : 型

「この引数はこの種類」の印。(id: number) 数字、string 文字、boolean true/false、string[] 文字の配列、any 何でも。赤い波線は動作に関係ないことも多い。

function move(from: number, to: number)

使う課題:全部調べる:TypeScript Everyday Types(TypeScript公式)

A | B と <型>

number | null=数字か null。useState<number | null>(null) は箱の中身の型を決める書き方。

useState<number | null>(null)

使う課題:19調べる:TypeScript Union Types(TypeScript公式)

ブラウザ(DOM)

document.documentElement

<html> タグのこと。テーマのクラスを付ける場所。

document.documentElement.classList

使う課題:12・15調べる:MDN documentElement(MDN)

classList.add / remove / toggle

class を付ける/外す/条件で付け外し。className = … の代入と違い、ほかの class を消さない。

root.classList.toggle("dark", theme === "dark");

使う課題:12・15調べる:MDN classList(MDN)

localStorage

ブラウザに文字を保存。再読み込みしても残る。サーバーでは使えない(useEffect か onClick の中で使う)。

localStorage.setItem("themeColor", name);

使う課題:15調べる:MDN localStorage(MDN)

alert / confirm

alert メッセージを出す、confirm OK / キャンセル(OK で true)。

if (!window.confirm("削除しますか?")) return;

使う課題:09・18調べる:MDN alert / MDN confirm(MDN)

console.log

値を Console に出して確かめる。"use client" のファイルはブラウザの F12、無いファイルは VS Code のターミナルに出る。

console.log("押された", id);

使う課題:共通調べる:MDN console.log(MDN)

CSS・Tailwind

CSS 変数 --名前 と var()

--primary: red; で決めて var(--primary) で使う。クラスで値を切り替えるとテーマが変わる。

.theme-red { --primary: #ef4444; }

使う課題:15調べる:MDN CSS カスタムプロパティ(MDN)

bg-[var(--x)](任意の値)

Tailwind に無い値を [ ] で直接書く。

className="bg-[var(--primary)]"

使う課題:15調べる:tailwind arbitrary values(Tailwind公式)

dark: / md: / has-[…]:

dark: ダークのとき、md: 768px 以上、has-[:checked]: 中に選択済みがあるとき。

className="bg-white dark:bg-gray-900"

使う課題:11・12調べる:tailwind dark mode / responsive(Tailwind公式)

動的なクラスは作られない

Tailwind はコードに完成した形で書かれたクラス名しか CSS にしない。`bg-${color}-500` は効かない。

"bg-red-500"(そのまま書く)

使う課題:15調べる:tailwind detecting classes dynamic(Tailwind公式)

共通

ボタンが反応しないときの共通診断(12・15・18・19で使う)

「onClick はあるし、飛んだ先の関数もおかしくなさそう。でも押しても何も起きない」ときに、原因を順番に絞る。原因は「押したことが関数に届いていない」か「関数は動いたのに画面が変わらない」のどちらか。まずどちらなのかを確認②で決める。

確認①

画面に出ているボタンと、今見ているコードは同じもの?

見る場所ボタンの文字を一時的に変えて保存する(例:「ダーク」→「ダークX」)。ブラウザの画面を見る。

もし画面の文字も「ダークX」に変わった

見ているコードは正しい。文字を元に戻して確認②へ。

もし画面の文字が変わらない

別のファイルを見ている。同じ名前の部品が2つある(components/ModeToggle.tsxとcomponents/header/mode-toggle.tsxなど)か、使われていない古い部品。ヘッダーのファイルの import 行の部品名を Ctrl+クリックして、実際に読み込まれている方を開き直す。

もしどこを変えても画面が全く変わらない

npm run devが止まっている、または別のフォルダのプロジェクトを開いている。VS Code のターミナルを見る。動いていればブラウザで Ctrl+Shift+R(キャッシュを消して読み直す)。

確認②

押したとき、関数は動いている?(console.log で確かめる)

見る場所関数の中身の一番上に1行足す。ブラウザで F12 → Console タブを開いたままボタンを押す。

もしonClick={handleToggle} のように関数名が書いてある

飛んだ先の関数の1行目に足す。

確認用・後で消す飛んだ先の関数の中の一番上
console.log("押された");
1行ずつ解説
1console.log("押された");押したときにこの関数が動けば、Console に「押された」と出る。確認が終わったら消す。

もしonClick={() => setTheme(…)} のように中身が直接書いてある

{ } で囲んで1行足す。

確認用・後で消す今の onClick を置き換える
onClick={() => { console.log("押された"); setTheme(今の中身); }}
1行ずつ解説
1onClick={() => { console.log("押された"); setTheme(今の中身); }}() => 1つの処理 を () => { 処理1; 処理2; } の形にすると、処理を2つ以上書ける。今の中身 は元のカッコの中をそのまま入れる。

もし押すと Console に「押された」と出る

クリックは届いている。原因は関数の中身かその先(12 なら確認③〜⑤、15 なら確認④、18 なら確認④〜⑤、06 なら確認③)。このページの確認④も見る。

もし押しても何も出ない

クリックが関数に届いていない。確認③へ。

もしボタンを押す前(ページを開いただけ)で「押された」が出る

onClick={関数()} の形になっている。確認③の1つ目。

もしConsole に赤いエラーがたくさん出ている

確認③の「赤いエラー」の行。

確認③

クリックが届かない原因(上から順に当てはまるものを探す)

見る場所ボタンのタグ(<Button … onClick=…>)の行と、そのボタンを囲んでいるタグを見る。

もしonClick={toggle()} のように、関数名のあとに () が付いている

() を付けると「画面を描いた瞬間に1回だけ実行して、その結果を onClick に入れる」意味になる。押したときには何も起きない。

書き換えonClick の部分
onClick={() => toggle()}
1行ずつ解説
1onClick={() => toggle()}() => を前に付けると「押されたら toggle() を実行する関数」を渡す意味になる。引数が無い関数なら onClick={toggle}(() なし)でもよい。

もしonclick(小文字)や onPress になっている

React は onClick(C が大文字)だけを見る。小文字だと何も起きない(Console に黄色い警告が出ることがある)。

もし<Button(大文字)で、Button が自作の部品

F12 で Button の定義を開く。受け取った onClick を中の <button> に渡していないと、何も起きない。

書き換えButton の定義(例)
export function Button({ children, ...props }: React.ComponentProps<"button">) {
  return <button {...props}>{children}</button>;
}
1行ずつ解説
1export function Button({ children, ...props }: React.ComponentProps<"button">) {...props は「children 以外に渡されたもの(onClick・className・type など)を全部まとめて props という名前で受け取る」。
2return <button {...props}>{children}</button>;{...props} で受け取ったものを全部本物の button に渡す。今の className などの見た目の指定は残す。
3}カッコを閉じる。

もしボタンに disabled が付いている/className に pointer-events-none がある

押せない状態。F12 → Elements でボタンのタグを見て、disabled が付いていないか確かめる。

もしif (!mounted) return <Button disabled … /> のような行がある(ダークモードでよくある形)

画面が表示されたら mounted を true にする useEffect が無い/間違っていると、ずっと「押せない仮のボタン」のまま。

追記同じ部品の中(return より上)。すでにあれば見比べる
const [mounted, setMounted] = useState(false);
useEffect(() => {
  setMounted(true);
}, []);
1行ずつ解説
1const [mounted, setMounted] = useState(false);表示されたかどうかを覚える箱。最初は false。
2useEffect(() => {useEffect の中身は「画面に表示された後」に動く。
3setMounted(true);ここで true にする。
4}, []);最後の [] は「最初の1回だけ動かす」の印。これが無い・中身が違うと動きが変わる。useState と useEffect は react から import する。

もし透明な要素がボタンの上にかぶさっている

F12 の左上の矢印アイコンを押してから、画面のボタンをクリック。Elements で選ばれたのが button ではなく div などなら、それがかぶさっている。

書き換えボタンの className の前に足す
className="relative z-50 今のclassName"
1行ずつ解説
1className="relative z-50 今のclassName"relative z-50 でボタンを一番手前に出す。かぶさっている要素のほうに pointer-events-none を付けて「クリックを通す」でもよい。

もしこのボタンだけでなく、ページのほかのボタンも全部反応しない+ Console に赤いエラー

エラーで React が画面を動かせていない。赤いエラーの1行目を読む。「Hydration failed」「did not match」なら <html> に suppressHydrationWarning を足す/描画中に Date.now()・Math.random()・localStorage を使っていないか見る。「… is not defined」ならその名前の import が足りない。

もしボタンが <form> の中にあり、type が書いていない

form の中のボタンは押すと送信=ページが読み直され、一瞬で元に戻るので「反応しない」ように見える。ボタンに type="button" を足す。

もしボタンが <Link> や <a> の中にある

ページ移動が先に起きる。ボタンを Link の外に出す。

もしボタンが、並び替え(19)でドラッグできるカードの中にある

ドラッグの仕組み(dnd-kit の {...listeners} など)がカード全体に付いていると、押した瞬間を「ドラッグ開始」として取られ、中のボタンのクリックが届かない。listeners をつかむボタン(ハンドル)だけに付ける(19 の確認③)。

もし「Event handlers cannot be passed to Client Component props」というエラー

ボタンがあるファイルの1行目に "use client"; が無い。1行目に足す(新しいファイルを作るのではない)。

確認④

関数は動いている(「押された」が出る)のに、画面が変わらない

見る場所関数の中で state や配列をどう変えているかを見る。

もしquestions.push(…)・.splice(…)・q.title = … のように、今の配列やオブジェクトを直接変えてから set している

React は「新しい配列が来たら描き直す」。同じ配列の中身を変えただけだと、変わっていないと判断して描き直さない。コピーしてから変える(18 の確認④)。

もしquestions.filter(…) を書いただけで、set していない

filter は新しい配列を作るだけで、元の配列は変わらない。setQuestions(questions.filter(…)) のように set に渡す。

もしset した直後の console.log で、古い値が出る

正常。set は「次に描き直すときに新しい値にする」予約なので、同じ関数の中ではまだ古い値が見える。画面が変わっていれば問題ない。

もしset しているのに画面が変わらない

画面が map している配列と、書き換えている配列が別のもの。map の前の名前(fields.map か questions.map か)と、set している名前を見比べる。

自分で調べるなら(AIは使わずWeb検索で)

知りたいこと見る場所検索ワード
onClick に関数を渡す書き方React公式「イベントへの応答」react onClick 関数 渡し方 ()
console.log と Console の見方MDN「console.log」Chrome デベロッパーツール Console 使い方
透明な要素でクリックできないMDN「pointer-events」CSS pointer-events none クリック 透過
配列の state が更新されないReact公式「state 内の配列の更新」react state 配列 更新されない push
Hydration のエラーNext.js公式(エラー文で検索)"Hydration failed" nextjs

課題 06

アンケートの並び順変更(更新順)

アンケート一覧を更新日時の順に並べる。更新日時をデータから取り出し(抽出し)、数字にして比べて並べ替える。

わかっていること

  • 並べる基準は「更新順」=更新日時(updatedAt など)。
  • 並び替えのボタンはすでにある。

流れ:①今あるボタンの onClick が何を変えているか → ②更新日時の項目名と形(Date・文字・数字)を見る → ③数字にして並べ替え → ④ map を並べ替えた配列に変える(ここを忘れると何も変わらない)。

確認①

並び替えボタンの onClick を見る

見る場所画面のボタンの文字(「更新順」「新しい順」「並び替え」「↓」など)で全体検索 → そのボタンのタグ。

もしボタンに onClick が無い/中身が空・console.log だけ

並び順を覚える箱を作ってつなぐ(無ければ)。

追記一覧の部品の中(return より上)
const [sort, setSort] = useState("updated");
1行ずつ解説
1const [sort, setSort] = useState("updated");今の並び順を覚える箱。最初から更新順にしたいので "updated"。useState は react から import。
書き換え今あるボタンの onClick
onClick={() => setSort("updated")}
1行ずつ解説
1onClick={() => setSort("updated")}押したら並び順を「更新順」にする。古い順のボタンがあれば setSort("old")、作成順なら setSort("created") のように値を変える。

もしonClick={() => setSort("updated")} のように、すでに state を変えている

ボタンはつながっている。変えている state の名前と値(例:sortKey と "updatedAt")をメモして確認②へ。値だけ変わって、並べ替えの計算か map が無いことが多い。

もしonClick={handleSort} など関数に飛び、その中で surveys.sort(…) して setSurveys(surveys) している

sort は元の配列を並べ替えて同じ配列を返すので、同じものを set しても React は描き直さない。コピーしてから並べる:setSurveys([...surveys].sort(…))。比べ方は確認③の2行目を使う。

もしボタンが <Link href="?sort=updated"> や router.push("?sort=…")

URL で並び順を渡す作り。確認④(page.tsx 版)へ。

もし並び替えが <select onChange=…> のプルダウン

onChange={(e) => setSort(e.target.value)} の形か見る。option の value("updated" など)をメモ。

もしonClick はあるのに押しても何も起きない(console.log も出ない)

共通診断 へ。

確認②

更新日時の項目名と形を確かめる(日付の抽出)

見る場所一覧の部品に console.log(surveys[0]) を一時的に書いて Console で見る。または map の s. のあとにマウスを乗せて型を見る。

もしupdatedAt: Date・updatedAt: "2026-09-30T12:34:56.000Z"(Prisma や API から来たもの)

確認③のコードのまま使える。API(fetch)から来ると Date が文字になるが、toTime がどちらも数字にする。

もし名前が updated_at・modifiedAt・lastUpdated・updateDate など

確認③の updatedAt をその名前に変える。

もし"2026/09/30 12:34" のようなスラッシュの文字

確認③の toTime がそのまま読む。

もし"2026年9月30日" のような日本語の文字

new Date() では読めないので、toTime の7〜8行目で数字の部分を抽出して日付にする。

もし1727650000(10桁=秒)や 1727650000000(13桁=ミリ秒)の数字

toTime の3行目が秒をミリ秒にそろえる。

もし{ seconds: …, nanoseconds: … }(Firebase / Firestore)

toTime の4行目(toDate())で読む。toDate が無いただのオブジェクトなら v.seconds * 1000 に変える。

もしupdatedAt が null のアンケートがある

一度も更新していないもの。確認③の2行目の ?? createdAt で作成日時を代わりに使う。

もしデータに更新日時が無い(console.log に出てこない)

画面に届いていない。確認⑤へ(API や DB の取り出しに足す)。

確認③

並べ替えを書く(一覧の部品の1行目に "use client" がある場合)

見る場所一覧を .map( しているファイル(page.tsx に部品しか無ければ F12 で部品を開く)。

もしどの場合も(基本の形)

日付を数字にする関数と、並べ替えた配列を作る。

追記部品の外(ファイルの一番下など)
function toTime(v: any): number {
  if (!v) return 0;
  if (typeof v === "number") return v < 1e12 ? v * 1000 : v;
  if (typeof v.toDate === "function") return v.toDate().getTime();
  const t = new Date(v).getTime();
  if (!isNaN(t)) return t;
  const m = String(v).match(/(\d{4})\D+(\d{1,2})\D+(\d{1,2})(?:\D+(\d{1,2})\D+(\d{1,2}))?/);
  return m ? new Date(+m[1], +m[2] - 1, +m[3], +(m[4] ?? 0), +(m[5] ?? 0)).getTime() : 0;
}
1行ずつ解説
1function toTime(v: any): number {日付を「数字(1970年からのミリ秒)」に変える関数。どんな形の日付が来ても、数字にすれば引き算で大小を比べられる。any は「何でも受け取る」型。
2if (!v) return 0;値が無い(null・undefined・空文字)なら 0=一番古い扱い。
3if (typeof v === "number") return v < 1e12 ? v * 1000 : v;すでに数字なら、そのまま使う。1e12(1兆)より小さいのは「秒」なので 1000 倍してミリ秒にそろえる。
4if (typeof v.toDate === "function") return v.toDate().getTime();Firestore の Timestamp のように toDate() を持っている日付なら、それで Date にしてから数字に。
5const t = new Date(v).getTime();new Date(値) で日付として読み、getTime() で数字に。Date 型・"2026-09-30T12:34:56Z"・"2026/09/30 12:34" はここで数字になる。
6if (!isNaN(t)) return t;読めた(isNaN=「数字ではない」が false)なら、それを返す。
7const m = String(v).match(/(\d{4})\D+(\d{1,2})\D+(\d{1,2})(?:\D+(\d{1,2})\D+(\d{1,2}))?/);読めなかったとき("2026年9月30日 12時34分" など)は、文字から数字の部分を抽出する。\d{4}=4桁の数字(年)、\D+=数字以外の区切り、\d{1,2}=1〜2桁(月・日・時・分)。( ) で囲んだ部分が m[1]〜m[5] に入る。
8return m ? new Date(+m[1], +m[2] - 1, +m[3], +(m[4] ?? 0), +(m[5] ?? 0)).getTime() : 0;抽出できたら年・月・日・時・分で Date を作って数字に(月は0始まりなので -1。+ は文字を数字にする)。できなければ 0。
9}関数を閉じる。
追記部品の中(useState の下)
const sorted = [...surveys].sort((a, b) => {
  const diff = toTime(a.updatedAt ?? a.createdAt) - toTime(b.updatedAt ?? b.createdAt);
  return sort === "old" ? diff : -diff;
});
1行ずつ解説
1const sorted = [...surveys].sort((a, b) => {一覧をコピー([...配列])してから並べ替える。sort((a, b) => …) は2件ずつ比べて、答えがマイナスなら a が先、プラスなら b が先。
2const diff = toTime(a.updatedAt ?? a.createdAt) - toTime(b.updatedAt ?? b.createdAt);更新日時を数字にして引き算。updatedAt ?? createdAt は「更新日時が無ければ作成日時を使う」(一度も更新していないアンケート対策)。項目名は確認②で見た名前に。
3return sort === "old" ? diff : -diff;「古い順」なら差のまま(古いものが先)、それ以外(更新が新しい順)は - で逆にする。sort と "old" は今あるボタンが使っている state と値に合わせる。
4});カッコを閉じる。
書き換え今の map
{sorted.map((s) => (
1行ずつ解説
1{sorted.map((s) => (ここを変えないと並びは変わらない。今の surveys.map( の surveys だけを sorted に変える。後ろはそのまま。

もしボタンが「更新順」1つだけ(押したら更新順、押さなければ元の順)

3行目の三項演算子を「更新順のときだけ並べる」形にする。

書き換え並べ替えの部分を置き換え
const sorted = sort === "updated"
  ? [...surveys].sort((a, b) => toTime(b.updatedAt ?? b.createdAt) - toTime(a.updatedAt ?? a.createdAt))
  : surveys;
1行ずつ解説
1const sorted = sort === "updated"更新順が選ばれているなら…
2? [...surveys].sort((a, b) => toTime(b.updatedAt ?? b.createdAt) - toTime(a.updatedAt ?? a.createdAt))b − a(新しいほうが先)で並べた配列。
3: surveys;そうでなければ元の順のまま。

もしボタンが「作成順」「更新順」「タイトル順」など複数

値ごとに比べ方を変える。

書き換え並べ替えの部分を置き換え
const sorted = [...surveys].sort((a, b) => {
  if (sort === "title") return String(a.title).localeCompare(String(b.title), "ja");
  if (sort === "created") return toTime(b.createdAt) - toTime(a.createdAt);
  return toTime(b.updatedAt ?? b.createdAt) - toTime(a.updatedAt ?? a.createdAt);
});
1行ずつ解説
1const sorted = [...surveys].sort((a, b) => {コピーして並べる。
2if (sort === "title") return String(a.title).localeCompare(String(b.title), "ja");タイトル順:日本語の順で比べる。
3if (sort === "created") return toTime(b.createdAt) - toTime(a.createdAt);作成順:作成日時の新しい順。
4return toTime(b.updatedAt ?? b.createdAt) - toTime(a.updatedAt ?? a.createdAt);それ以外(更新順):更新日時の新しい順。
5});閉じる。

もし「↑↓」1つのボタンで、押すたびに新しい順/古い順が入れ替わる

state を true / false にする。

追記部品の中
const [asc, setAsc] = useState(false);
1行ずつ解説
1const [asc, setAsc] = useState(false);true=古い順、false=新しい順。ボタンは onClick={() => setAsc(!asc)}(押すたびに反対)。確認③の基本の3行目は return asc ? diff : -diff; にする。

もし今のコードに a.updatedAt.localeCompare(…) がある

Date 型には localeCompare が無い(エラーの原因)。基本の形の toTime の引き算に置き換える。

もし並びが合っているか目で確かめたい(任意)

一覧に更新日時を表示する。

追記map の中(タイトルの下)
<p className="text-xs text-gray-500">更新:{new Date(toTime(s.updatedAt ?? s.createdAt)).toLocaleString("ja-JP")}</p>
1行ずつ解説
1<p className="text-xs text-gray-500">更新:{new Date(toTime(s.updatedAt ?? s.createdAt)).toLocaleString("ja-JP")}</p>数字に直した日時を、日本の書き方(2026/9/30 12:34:56)で表示する。
確認④

ボタンが Link(URL の ?sort=)の場合:page.tsx で並べる

見る場所page.tsx の関数の1行目と、データを取っている行。

もしNext.js 15 以上(package.json の next が 15〜)

書き換えpage.tsx の関数の1行目(引数に searchParams を足す)
export default async function Page({ searchParams }: { searchParams: Promise<{ sort?: string }> }) {
  const { sort = "updated" } = await searchParams;
1行ずつ解説
1export default async function Page({ searchParams }: { searchParams: Promise<{ sort?: string }> }) {関数名は今のまま。() の中に searchParams を足す(params があれば並べる)。
2const { sort = "updated" } = await searchParams;URL の ?sort=… を取り出す。無ければ更新順。15 以上は await が必要。
追記データを取っている行のすぐ下(toTime は確認③と同じものをファイルの下に貼る)
const sorted = [...surveys].sort((a, b) => {
  const diff = toTime(a.updatedAt ?? a.createdAt) - toTime(b.updatedAt ?? b.createdAt);
  return sort === "old" ? diff : -diff;
});
1行ずつ解説
1const sorted = [...surveys].sort((a, b) => {コピーして並べる。
2const diff = toTime(a.updatedAt ?? a.createdAt) - toTime(b.updatedAt ?? b.createdAt);更新日時の差。
3return sort === "old" ? diff : -diff;古い順ならそのまま、それ以外は新しい順。
4});閉じる。

もしNext.js 14 以下

1行目を { searchParams }: { searchParams: { sort?: string } }、2行目を const sort = searchParams.sort ?? "updated"; にする(Promise と await を外す)。

もし部品(<SurveyList surveys={surveys} />)に渡している

並べた配列を渡す:<SurveyList surveys={sorted} />。部品の中は変えなくてよい。

もしPrisma(prisma.survey.findMany()で取っている

JS で並べる代わりに DB に並べさせてもよい:findMany の { } の中に orderBy: { updatedAt: sort === "old" ? "asc" : "desc" },(asc=古い順、desc=新しい順)。

もしSupabase(.from("surveys").select()で取っている

.order("updated_at", { ascending: sort === "old" }) を select の後ろにつなげる。

確認⑤

更新日時が無い・更新しても日時が変わらない(データ側)

見る場所prisma/schema.prisma(または API のファイル)で、アンケートのモデルと取り出し(select)を見る。

もしschema に updatedAt DateTime @updatedAt がある+ findMany に select: { … } があり updatedAt が入っていない

取り出していないだけ。select に足す。

書き換えfindMany の select の中
updatedAt: true,
1行ずつ解説
1updatedAt: true,更新日時も取り出す。

もしAPI(/api/surveys)が返すデータに更新日時が無い

API のファイルで、返しているオブジェクトを作っている所に updatedAt: s.updatedAt を足す。

もしschema に updatedAt が無い

DB の形を変える作業になる。勝手に変えず、先に社員の方に確認する。OK なら updatedAt DateTime @updatedAt を足して npx prisma migrate dev。

もし設問を編集しても更新日時が変わらない

@updatedAt はその行(アンケート本体)を update したときだけ変わる。設問だけ保存すると本体の日時は古いまま。設問を保存する処理の最後に、本体も更新する。

追記設問を保存する API の最後
await prisma.survey.update({ where: { id: surveyId }, data: { updatedAt: new Date() } });
1行ずつ解説
1await prisma.survey.update({ where: { id: surveyId }, data: { updatedAt: new Date() } });アンケート本体の更新日時を「今」にする。survey・surveyId は今のコードの名前に合わせる。

それでも動かないとき

こうなった原因と直し方
ボタンを押しても並びが変わらないmap がまだ surveys.map( のまま(確認③の最後)。または state の値とボタンの値の綴りが違う("updated" と "updatedAt")。
並び順が変わったように見えない全部の更新日時が同じ/1件しかない。確認③の「更新日時を表示」で数字を見る。テスト用にアンケートを2〜3件作り、1件だけ編集する。
一部だけ並びがおかしいその行の更新日時が null か、読めない形。toTime の結果(0)になっている。console.log(toTime(s.updatedAt)) で見る。
Property 'localeCompare' does not exist on type 'Date'日付を文字として比べている。toTime の引き算に(確認③)。
Property 'updatedAt' does not exist型に更新日時が無い。データに届いていない(確認⑤)か、名前が違う(確認②)。
いつも新しい順のまま(page.tsx 版)Next.js 15 以上なのに await searchParams していない。

自分で調べるなら(AIは使わずWeb検索で)

知りたいこと見る場所検索ワード
並べ替え(sort)と比べる関数MDN「Array.prototype.sort()」MDN sort 比較関数 日付
日付を数字にするMDN「Date.prototype.getTime()」「Date()」MDN getTime / javascript 日付 比較
文字から数字を抽出(正規表現)MDN「String.prototype.match()」「正規表現」MDN match 正規表現 / javascript 日付 文字列 変換
読めない日付の判定MDN「isNaN()」javascript Invalid Date 判定
日本の書き方で表示MDN「toLocaleString()」MDN toLocaleString ja-JP
Prisma の updatedAt と並び順Prisma公式prisma @updatedAt / prisma orderBy
URL の ?sort= を受け取るNext.js公式「page.tsx」の searchParamsnextjs searchParams page

課題 09

質問が選択の空欄チェック

保存ボタン → 保存の関数。その関数の最初で、設問を forEach で1つずつ調べ、質問文の空欄と、ラジオ(選択式)の選択肢の空欄を見つけたら alert を出して保存を止める。

わかっていること

  • 保存ボタンの onClick は、保存の関数につながっている(保存先が関数)。
  • チェックはその関数の先で、設問(question)のラジオや空欄を forEach で確かめる。

いちばん大事な注意:forEach の中で return しても「その1回分を飛ばす」だけで、保存の関数は止まらない(そのまま保存される)。だから forEach の中では空欄をメモするだけにして、forEach が終わった後で alert と return をする。下のコードはこの形になっている。

確認①

保存ボタンの onClick から、保存の関数まで行く

見る場所画面の「保存」「作成」「登録」の文字で全体検索(Ctrl+Shift+F)→ ボタンの onClick={…} の名前の上で Ctrl+クリック(F12)。

もしonClick={handleSave} → 同じファイルの const handleSave = … か function handleSave に飛ぶ

一番よくある形。このファイルで確認②へ。チェックの関数(確認③)もこのファイルに書く。

もしonClick={() => save(questions)} のように、関数に設問の配列を渡している

飛んだ先の関数は、配列を引数で受け取っている(例:function save(list))。チェックでは引数の名前(list)を使う。

もし飛んだ先が別のファイル(lib/api.ts・actions.ts など)で、1行目が "use server"

そのファイルはサーバーで動くので alert が使えない(「alert is not defined」になる)。チェックはボタンがあるファイルに書き、呼ぶ前に止める(確認④の最後の形)。

もし飛んだ先が別のファイルで、"use server" は無い(ただの関数)

そのファイルの関数の先頭にチェックを書いてもよい。ボタンのファイルに書いて呼ぶ前に止めてもよい。迷ったらボタンのファイル。

もし飛んだ先が store フォルダ(Zustand の create( の中の save: など)

store の中でも alert は使える。確認④の「Zustand」の形。

もしボタンに onClick が無く、<form onSubmit={handleSubmit(onSubmit)} がある(react-hook-form)

保存の関数は onSubmit。引数 data に入力内容が入っている。確認④の「react-hook-form」の形。

もし<form action={createSurvey}(Server Action)

送信の前に止める。確認④の「form action」の形。

確認②

設問のデータの形を確かめる(項目の名前と、ラジオの値)

見る場所保存の関数の一番上に console.log(questions) を一時的に書いてボタンを押す → F12 の Console で ▶ を開いて中身を見る。または questions にマウスを乗せて型を見る。

もし{ id, title, type: "radio", choices: [{ id, label }] } のような形

確認③のコードのまま使える。

もし質問文の項目名が title ではない(text・question・label など)

確認③の5行目の q.title をその名前に変える(例:q.text)。

もし種類の値が "radio" ではない("single"・"multiple"・"select"・"choice" など)

確認③の8行目の "radio" "checkbox" を、自分のデータに出てきた綴りにそろえる(大文字小文字も同じに)。

もし種類が定数で書かれている(QuestionType.Radio・QUESTION_TYPE.RADIO など)

文字の代わりにその定数を書く:q.type === QuestionType.Radio。定数の import が必要(ほかのファイルの import 行をコピー)。

もし種類の項目名が type ではない(kind・questionType など)

q.type をその名前に変える。

もし種類の項目が無い(選択肢の配列があるかどうかで決まっている)

確認③の「種類の項目が無い」の形。

もし選択肢の項目名が choices ではない(options・items・answers など)

確認③の9行目の q.choices をその名前に変える。

もし選択肢が ["はい", "いいえ"] のようにただの文字の並び

確認③の「選択肢がただの文字」の形。

もし選択肢が { id, text } や { value }

確認③の14行目の c.label を c.text や c.value に変える。

確認③

チェックの関数を書く(forEach 版)

見る場所保存の関数が書いてあるファイル。保存の関数の上(部品の外でも中でもよい)。

もしどの場合も(基本の形)

質問文の空欄と、ラジオ(とチェックボックス)の選択肢の空欄・0個を調べる。

追記保存の関数の上
function validate(questions: any[]) {
  const errors: string[] = [];
  questions.forEach((q, i) => {
    const no = i + 1;
    if (String(q.title ?? "").trim() === "") {
      errors.push(`Q${no}:質問文が空欄です`);
    }
    if (q.type === "radio" || q.type === "checkbox") {
      const choices = q.choices ?? [];
      if (choices.length === 0) {
        errors.push(`Q${no}:選択肢がありません`);
      }
      choices.forEach((c, j) => {
        if (String(c.label ?? "").trim() === "") {
          errors.push(`Q${no}:選択肢${j + 1}が空欄です`);
        }
      });
    }
  });
  if (errors.length > 0) {
    alert(errors.join("\n"));
    return false;
  }
  return true;
}
1行ずつ解説
1function validate(questions: any[]) {空欄チェックの関数。設問の配列を受け取る。空欄が無ければ true、あれば false を返す。any[] は「何かの配列」という型(型が分かれば Question[] などに変えてよい)。
2const errors: string[] = [];見つけた空欄のメッセージを貯める配列(最初は空)。
3questions.forEach((q, i) => {forEach:設問を1つずつ取り出して中の処理を動かす。q が今の設問、i は番号(0,1,2…)。
4const no = i + 1;画面に出す番号(0 からではなく 1 から)。
5if (String(q.title ?? "").trim() === "") {質問文が空か調べる。?? "" で null / undefined を空文字に、String() で必ず文字に、.trim() で前後の空白を消してから ""(空)と比べる。q.title は質問文の項目名(text・question など)に合わせる。
6errors.push(`Q${no}:質問文が空欄です`);空なら push でメッセージを配列の最後に足す。`…${no}…` は文字の中に値を入れる書き方。
7}if を閉じる。
8if (q.type === "radio" || q.type === "checkbox") {ラジオ(選択式)の設問のときだけ選択肢を調べる。|| は「または」。"radio" "checkbox" は自分のデータの値に合わせる(確認②)。チェックボックスが無ければ後ろ半分は消してよい。
9const choices = q.choices ?? [];選択肢の配列を取り出す。無い(undefined)ときは空の配列にして、下の forEach でエラーにならないようにする。
10if (choices.length === 0) {選択肢が1つも無いか調べる。
11errors.push(`Q${no}:選択肢がありません`);無ければメッセージを足す。
12}if を閉じる。
13choices.forEach((c, j) => {選択肢も forEach で1つずつ調べる。c が選択肢、j が選択肢の番号。
14if (String(c.label ?? "").trim() === "") {選択肢の文字が空か調べる(質問文と同じ形)。選択肢がただの文字なら String(c ?? "") にする(確認③)。
15errors.push(`Q${no}:選択肢${j + 1}が空欄です`);空ならメッセージを足す。
16}if を閉じる。
17});選択肢の forEach を閉じる。
18}ラジオの if を閉じる。
19});設問の forEach を閉じる。ここまでは「調べてメモするだけ」。forEach の中では return しない。
20if (errors.length > 0) {メモが1つでもあれば(空欄があれば)…
21alert(errors.join("\n"));alert でまとめて表示。join("\n") は配列を改行でつないで1つの文字にする。
22return false;false を返す=「保存してはいけない」の合図。
23}if を閉じる。
24return true;空欄が無ければ true=「保存してよい」。
25}関数を閉じる。

もし選択肢がただの文字の並び(["はい", "いいえ"])

14行目だけ変える。

書き換え14行目
        if (String(c ?? "").trim() === "") {
1行ずつ解説
1if (String(c ?? "").trim() === "") {c そのものが選択肢の文字なので、.label を付けない。

もし種類の項目が無い(選択肢の配列を持っているかで判断)

8行目を変える。

書き換え8行目
    if (Array.isArray(q.choices)) {
1行ずつ解説
1if (Array.isArray(q.choices)) {Array.isArray(x) は「x が配列なら true」。選択肢の配列を持っている設問だけ調べる。

もし選択肢は2つ以上ないとダメにしたい

10〜12行目を変える。

書き換え10〜12行目
      if (choices.length < 2) {
        errors.push(`Q${no}:選択肢を2つ以上入れてください`);
      }
1行ずつ解説
1if (choices.length < 2) {< 2=2つより少ない(0個か1個)。
2errors.push(`Q${no}:選択肢を2つ以上入れてください`);メッセージを足す。
3}if を閉じる。

もし同じ選択肢が2回入っていないかも調べたい(任意)

17行目(選択肢の forEach を閉じた行)の下に足す。

追記17行目の下
      const labels = choices.map((c) => String(c.label ?? "").trim());
      if (new Set(labels).size !== labels.length) {
        errors.push(`Q${no}:同じ選択肢があります`);
      }
1行ずつ解説
1const labels = choices.map((c) => String(c.label ?? "").trim());map で選択肢の文字だけの配列を作る。
2if (new Set(labels).size !== labels.length) {new Set(配列) は重複を消した集まり。.size がもとの数と違えば、重複があった。
3errors.push(`Q${no}:同じ選択肢があります`);メッセージを足す。
4}if を閉じる。

もしtoast(sonner・react-hot-toast)がすでに使われている

21行目の alert(…) を toast.error(errors.join("\n")) に変えてもよい。toast の import はほかのファイルからコピー。課題が「アラート」なら alert のままが確実。

もしforEach ではなく短く書きたい(参考)

some は「1つでも当てはまれば true」。ただし何番がダメか出せないので、この課題では forEach 版をすすめる。

追記参考(使わなくてよい)
const hasBlank = questions.some((q) =>
  (q.choices ?? []).some((c) => String(c.label ?? "").trim() === "")
);
1行ずつ解説
1const hasBlank = questions.some((q) =>設問のどれか1つでも…
2(q.choices ?? []).some((c) => String(c.label ?? "").trim() === "")選択肢のどれか1つでも空なら true。
3);カッコを閉じる。
確認④

保存の関数の先頭につなぐ(保存の仕方ごと)

見る場所確認①で見つけた保存の関数の中の一番上。await や fetch より上に書く。

もしuseState で持っていて、const handleSave = async () => { のような関数

1行足すだけ。

追記保存の関数の中の一番上
if (!validate(questions)) return;
1行ずつ解説
1if (!validate(questions)) return;! は「反対」。validate が false(空欄あり)なら return で関数を終わる=下の保存の処理まで行かない。

もし関数が配列を引数で受け取っている(function save(list) {)

questions を引数の名前にする。

追記保存の関数の中の一番上
if (!validate(list)) return;
1行ずつ解説
1if (!validate(list)) return;受け取った配列を調べる。

もしreact-hook-form(const onSubmit = (data) => {)

入力内容は data の中。

追記onSubmit の中の一番上
if (!validate(data.questions)) return;
1行ずつ解説
1if (!validate(data.questions)) return;data.questions は useForm の中の設問の配列の名前(useFieldArray({ name: "questions" }) の name と同じ)。

もしZustand の store の中(save: async () => {)

store の中身は get() で取り出す。確認③の関数は store のファイルの create( より上に貼る。

追記save の中の一番上
const { questions } = get();
if (!validate(questions)) return;
1行ずつ解説
1const { questions } = get();今の store の中身から設問を取り出す。create((set, get) => ({ の get。(set) => しか無ければ (set, get) => にする。
2if (!validate(questions)) return;空欄があれば止める。

もし<form action={createSurvey}(Server Action)

送信の前(ブラウザ側)で止める。form のタグに onSubmit を足す。

書き換えform のタグ
<form action={createSurvey} onSubmit={(e) => { if (!validate(questions)) e.preventDefault(); }}>
1行ずつ解説
1<form action={createSurvey} onSubmit={(e) => { if (!validate(questions)) e.preventDefault(); }}>e.preventDefault() は「本来の動き(送信)を止める」。空欄があるときだけ止める。このファイルの1行目に "use client" が必要。

もし保存の関数には触りたくない/関数がサーバー側("use server")

ボタンの onClick で、呼ぶ前に止める。

書き換え保存ボタンの onClick
onClick={() => { if (validate(questions)) handleSave(); }}
1行ずつ解説
1onClick={() => { if (validate(questions)) handleSave(); }}OK(true)のときだけ保存の関数を呼ぶ。handleSave() の () の中は今の書き方に合わせる。
確認⑤

(任意)空欄の入力欄を赤くしたい

見る場所選択肢の入力欄(<Input か <input)。

もしどの場合も

保存を押して失敗した後だけ赤くする。

追記部品の中(return より上)
const [showErr, setShowErr] = useState(false);
1行ずつ解説
1const [showErr, setShowErr] = useState(false);「空欄を赤くするか」を覚える箱。最初は false。
書き換え確認④の1行を変える
if (!validate(questions)) { setShowErr(true); return; }
1行ずつ解説
1if (!validate(questions)) { setShowErr(true); return; }失敗したら赤くする印を true にしてから止める。
書き換え選択肢の入力欄に className を足す
className={`今のclassName ${showErr && String(c.label ?? "").trim() === "" ? "border-red-500" : ""}`}
1行ずつ解説
1className={`今のclassName ${showErr && String(c.label ?? "").trim() === "" ? "border-red-500" : ""}`}&& は「両方 true なら」。赤くする印があり、空欄なら赤い枠、そうでなければ何も足さない。

それでも動かないとき

こうなった原因と直し方
空欄なのに保存される(1) return を forEach の中に書いている → forEach の外(確認③の形)に。(2) validate(questions); だけ書いて、結果を if で使っていない → if (!validate(…)) return;。(3) 保存が2か所(onClick と form の onSubmit)で行われていて、片方にしかチェックが無い。
空欄があるのに alert が出ない8行目の種類の値が違う。console.log(q.type) で実際の値を見る("Radio" と "radio" は別物)。質問文が出ないなら5行目の項目名が違う。
Cannot read properties of undefined (reading 'forEach')questions か q.choices が無い。名前が違う(確認②)か、?? [] を付け忘れ。
alert is not defined / window is not definedサーバーで動くファイル("use server" や page.tsx)に書いた。ボタンのファイル("use client")に移す(確認④の最後の形)。
alert を閉じるとページが読み直される保存ボタンが form の送信になっている。ボタンに type="button"、または form の onSubmit で e.preventDefault()。
空白だけの選択肢が通る.trim() を付け忘れ。
Parameter 'q' implicitly has an 'any' type(赤い波線)型の注意。validate の引数を (questions: any[]) にする。動作には影響しないことが多い。

自分で調べるなら(AIは使わずWeb検索で)

知りたいこと見る場所検索ワード
forEach の使い方MDN「Array.prototype.forEach()」MDN forEach
forEach の中の return で止まらない理由MDN の forEach のページの「メモ」/Stack OverflowforEach return 止まらない / forEach break
文字の前後の空白を消すMDN「String.prototype.trim()」MDN trim
alertMDN「Window.alert()」MDN alert
配列の最後に足す・つなぐMDN「push()」「join()」MDN Array push / MDN join
?? と ?.MDN「Null 合体演算子」「オプショナルチェーン」MDN Null合体演算子
react-hook-form の検証react-hook-form公式(register の rules)react-hook-form validate required

課題 11

レスポンシブ対応

スマホ(幅375px前後)でも崩れないようにする。Tailwind の決まった書き方を当てはめるだけ。

Tailwind は「接頭辞なし=スマホ」「sm:=640px以上」「md:=768px以上」「lg:=1024px以上」。スマホの形を先に書き、広い画面の形を md: などで足す。確かめ方:Chrome の開発者ツール(F12)→ 左上のスマホのアイコン(Ctrl+Shift+M)→ 幅 375 にする。

確認①

よく使う決まった書き方(崩れている場所の className を置き換える)

見る場所崩れている場所を開発者ツールの矢印アイコンでクリック → そのタグの className をコードで探す(className の一部をコピーして全体検索)。

もしページ全体が画面の端にくっつく・広がりすぎる

ページの一番外側のタグ。

書き換え一番外側(main など)
<main className="mx-auto w-full max-w-5xl px-4 py-6 sm:px-6">
1行ずつ解説
1<main className="mx-auto w-full max-w-5xl px-4 py-6 sm:px-6">中央寄せ(mx-auto)・最大幅(max-w-5xl)・左右に16pxの余白(px-4)。640px以上で余白を少し広く。

もし横に並んだものが、スマホで押しつぶされる

スマホは縦、広い画面で横。

書き換え横並びの親
<div className="flex flex-col gap-4 md:flex-row">
1行ずつ解説
1<div className="flex flex-col gap-4 md:flex-row">普段は縦並び(flex-col)、768px以上で横並び(md:flex-row)。

もしカードが何列も並んでいる

列の数を画面幅で変える。

書き換えカードの親
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
1行ずつ解説
1<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">スマホ1列、640px以上で2列、1024px以上で3列。

もし幅が固定(w-[600px] など)ではみ出す

書き換え固定幅のタグ
<div className="w-full max-w-[600px]">
1行ずつ解説
1<div className="w-full max-w-[600px]">画面いっぱい(w-full)、ただし最大600px。w-[600px] をこの2つに置き換える。

もしボタンが横にはみ出す

書き換えボタンの親とボタン
<div className="flex flex-wrap gap-2">
  <button className="w-full sm:w-auto">
1行ずつ解説
1<div className="flex flex-wrap gap-2">入りきらなければ次の行へ折り返す(flex-wrap)。
2<button className="w-full sm:w-auto">スマホでは横いっぱいのボタン、640px以上で中身に合わせた幅。

もし長いタイトルや URL がはみ出す

書き換え文字のタグ(flex の中なら親にも min-w-0)
<p className="min-w-0 break-words">
1行ずつ解説
1<p className="min-w-0 break-words">break-words で長い単語の途中でも折り返す。flex の中の子は縮まないことがあるので min-w-0 も付ける。

もし見出しがスマホで大きすぎる

書き換え見出し
<h1 className="text-xl font-bold sm:text-3xl">
1行ずつ解説
1<h1 className="text-xl font-bold sm:text-3xl">スマホは text-xl、640px以上で text-3xl。

もし表がはみ出す

書き換え表を囲む
<div className="overflow-x-auto">
  <table>…</table>
</div>
1行ずつ解説
1<div className="overflow-x-auto">はみ出した分だけ、この箱の中で横にスクロールできる(ページ全体は動かない)。
2<table>…</table>今の表をそのまま入れる。
3</div><div> を閉じる。

もし画像がはみ出す

書き換え画像
<img className="h-auto max-w-full" src={imageUrl} alt="画像" />
1行ずつ解説
1<img className="h-auto max-w-full" src={imageUrl} alt="画像" />画面より大きいときだけ縮める。src と alt は今のまま。next/image の Image でも同じ className でよい。

もしスマホだけ/PCだけ表示したいものがある

書き換え表示の切り替え
<div className="hidden md:block">PCだけ</div>
<div className="md:hidden">スマホだけ</div>
1行ずつ解説
1<div className="hidden md:block">PCだけ</div>普段は隠し(hidden)、768px以上で表示。
2<div className="md:hidden">スマホだけ</div>普段は表示、768px以上で隠す。
確認②

ヘッダーのメニューがスマホではみ出す

見る場所ヘッダーの部品(「一覧」などのリンクがあるファイル)。

もしリンクが少ない(3つくらい)

最短:折り返すだけ。

書き換えメニューの親
<nav className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
1行ずつ解説
1<nav className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">入りきらなければ次の行へ。横の間隔 gap-x-4、縦の間隔 gap-y-2。

もしリンクが多い・ハンバーガーメニューにしたい

ボタンで開閉する。ヘッダーのファイルに "use client" が無ければ1行目に足す(新しいファイルを作るのではない)。

追記ヘッダーの部品の中(return より上)
const [open, setOpen] = useState(false);
1行ずつ解説
1const [open, setOpen] = useState(false);メニューが開いているかを覚える箱(useState を react から import)。
書き換えメニューの部分
<button onClick={() => setOpen(!open)} className="md:hidden" aria-label="メニュー">☰</button>
<nav className={`${open ? "flex" : "hidden"} flex-col gap-2 md:flex md:flex-row`}>
  今のリンク
</nav>
1行ずつ解説
1<button onClick={() => setOpen(!open)} className="md:hidden" aria-label="メニュー">☰</button>スマホだけ出るボタン(md:hidden)。押すたびに開く/閉じるが入れ替わる(!open)。
2<nav className={`${open ? "flex" : "hidden"} flex-col gap-2 md:flex md:flex-row`}>スマホでは開いているときだけ縦に表示、768px以上では常に横並びで表示。
3今のリンク今のリンクをそのまま入れる。
4</nav><nav> を閉じる。
確認③

入力欄・ボタンがスマホで使いにくい

見る場所作成画面・回答画面の input / textarea / button。

もし入力欄

書き換え入力欄
<input className="w-full rounded border p-2 text-base" />
1行ずつ解説
1<input className="w-full rounded border p-2 text-base" />横いっぱい。文字は16px(text-base)以上にする。小さいと iPhone で入力時に画面が勝手に拡大される。

もし押す場所が小さい

書き換えボタンや選択肢
<button className="min-h-11 px-4">
1行ずつ解説
1<button className="min-h-11 px-4">高さを44px以上(min-h-11)にすると指で押しやすい。

それでも動かないとき

こうなった原因と直し方
どこが横にはみ出しているかわからない開発者ツールの Console に document.querySelectorAll('*').forEach(e => e.scrollWidth > innerWidth && console.log(e)) を貼って Enter。はみ出している要素が表示される。
md: を付けたのに変わらない開発者ツールの幅が 768 以上になっていないか確認。スマホの確認は 375 にする。

自分で調べるなら(AIは使わずWeb検索で)

知りたいこと見る場所検索ワード
画面幅ごとの書き方(sm: md:)Tailwind公式「Responsive design」tailwind responsive md:
横並び・縦並びTailwind公式「flex-direction」「grid-template-columns」tailwind flex-col md:flex-row
スマホ表示で確かめるChrome DevTools のドキュメントChrome デバイスモード 使い方
はみ出しの原因MDN「overflow」「min-width」flex min-w-0 はみ出す

課題 12

ダークモード

ヘッダーのダークモードのボタンを押したら、画面が暗い色に切り替わるようにする。

わかっていること

  • useTheme はあった。ボタンに onClick もあった。CSS(ダーク用の色)も用意されていた。
  • ボタンを押しても <html> の class は変わらなかった。
  • onClick から飛んだ先の関数は、見た限り問題なさそう。

考え方:html の class が変わらないなら、CSS はまだ関係ない(CSS は class が付いた後の話)。原因は「①クリックが関数に届いていない」か「②関数(setTheme)は呼ばれているのに、テーマの仕組み(ThemeProvider)が受け取っていない」のどちらか。確認①〜②でどちらかを決め、③〜⑤で直す。

確認①

useTheme はどこから import している?

見る場所ボタンのファイルの上のほうにある import { useTheme } from "…" の "…" の部分。

もしfrom "next-themes"

next-themes(ライブラリ)の useTheme。確認②へ。

もしfrom "@/components/theme-provider"・"@/hooks/use-theme"・"@/context/ThemeContext" など、自分のプロジェクトの場所

会社が自作した useTheme の可能性が高い。そのファイルを F12 で開き、中に from "next-themes" があるか見る。あれば next-themes を渡しているだけなので確認②へ。無く、createContext や useState で自作していれば確認⑤へ。

もしimport が2つある/別のファイルでは別の場所から import している

ボタンの useTheme と、layout の ThemeProvider が同じ仕組みでないと届かない。ThemeProvider の import(確認③)と見比べる。片方が next-themes、片方が自作だと動かない。

確認②

押したとき、関数は呼ばれている? useTheme の中身は?

見る場所useTheme の行のすぐ下に console.log を足す。F12 の Console を開いたままページを読み込み、ボタンを押す。

もしどの場合も

useTheme から取れるものを全部出してみる。

確認用・後で消すuseTheme の行のすぐ下(部品の中)
const { theme, resolvedTheme, themes, forcedTheme } = useTheme();
console.log("テーマ", { theme, resolvedTheme, themes, forcedTheme });
1行ずつ解説
1const { theme, resolvedTheme, themes, forcedTheme } = useTheme();今の useTheme の行と別に、確認用に取り出す(名前がかぶってエラーなら、今の行の { } の中に足すだけでもよい)。theme は選んだ設定、resolvedTheme は実際の色(light / dark)、themes は使えるテーマの一覧、forcedTheme は固定されたテーマ。
2console.log("テーマ", { theme, resolvedTheme, themes, forcedTheme });{ theme, … } のようにまとめて出すと、名前付きで表示される。確認が終わったら2行とも消す。
確認用・後で消すボタンの onClick の関数の一番上
console.log("押された");
1行ずつ解説
1console.log("押された");押したときに関数が動いたか確かめる。

もし押しても「押された」が出ない

クリックが届いていない。共通診断の確認③ を上から見る。ダークモードで多いのは:onClick={setTheme("dark")}(() => が無い)、mounted の仮のボタンが disabled のまま、自作 Button が onClick を渡していない、の3つ。

もし「押された」は出る。ページを開いたときの表示で theme: undefined・themes: []

ThemeProvider の外で useTheme を使っている。next-themes は ThemeProvider が無いと、setTheme が「何もしない関数」になる。エラーも出ないので「関数は正しいのに動かない」ように見える。確認③へ。

もし「押された」は出る。forcedTheme: "light" のように値がある

テーマが固定されていて、setTheme しても変わらない。確認③の forcedTheme の行。

もし「押された」は出る。押すたびに theme が light / dark に変わるのに、html の class は変わらない

テーマの値は変わっている。html に書き込む設定の問題。F12 → Elements の <html …> を全部見る。data-theme="dark" や style="color-scheme: dark" が変わっていれば確認③の attribute の行。何も変わらなければ ThemeProvider が2つある(確認③)。

もし「押された」は出る。theme: "system" のまま/1回目は何も起きず2回目から変わる

切り替えの式の問題。確認④へ。

もし「押された」は出るが、console.log の「テーマ」の行が出ない

console.log を書いた部品と、ボタンの部品が別。useTheme を使っている部品に書き直す。

確認③

ThemeProvider を確かめる(layout.tsx / providers.tsx)

見る場所全体検索 ThemeProvider。<ThemeProvider …> のタグと、その中に何が入っているか。

もしどこにも <ThemeProvider が無い

layout.tsx で body の中身を囲む(next-themes の ThemeProvider はそのまま layout で使える)。

追記app/layout.tsx の先頭の import の並び
import { ThemeProvider } from "next-themes";
1行ずつ解説
1import { ThemeProvider } from "next-themes";next-themes の ThemeProvider を読み込む。
書き換えapp/layout.tsx の return の中
<html lang="ja" suppressHydrationWarning>
  <body 今の属性>
    <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
      今の body の中身(ヘッダーも children も全部)
    </ThemeProvider>
  </body>
</html>
1行ずつ解説
1<html lang="ja" suppressHydrationWarning>今の html に suppressHydrationWarning を足す(next-themes が表示直後に class を書き換えることによる警告を止める)。
2<body 今の属性>body は今のまま。
3<ThemeProvider attribute="class" defaultTheme="system" enableSystem>body の中身を全部囲む。attribute="class"=html に class="dark" を付ける方式。defaultTheme="system"=最初は端末の設定。enableSystem=端末の設定を使ってよい。
4今の body の中身(ヘッダーも children も全部)今の中身をそのまま入れる。ヘッダーも必ず中に。
5</ThemeProvider>閉じる。
6</body>body を閉じる。
7</html>html を閉じる。

もし「createContext only works in Client Components」などのエラーが出た(上の形で)

古い next-themes だと layout で直接使えない。components/theme-provider.tsx を作り、1行目に "use client";、2行目以降で next-themes の ThemeProvider を包んで export する(shadcn/ui のダークモードのページと同じ形)。ここだけは新しいファイル。

もし<Providers> や <AppProvider> の中にある(providers.tsx)

layout.tsx で <Providers> が本当に使われているか確認。import だけあってタグで囲んでいないと効かない。

もしThemeProvider はあるが、ヘッダーのタグがその外(上や横)にある

ヘッダーを ThemeProvider の内側に移す。ヘッダーの外では useTheme が効かない。

もしヘッダーが別の layout.tsx(app/(main)/layout.tsx など)にあり、ThemeProvider は app/layout.tsx

一番外の app/layout.tsx が全部を囲んでいればOK。逆(ThemeProvider が内側の layout だけ)だと、外側の layout にあるヘッダーには効かない。

もしattribute が無い、または attribute="data-theme"

html に付くのが class ではなく data-theme になる。CSS が .dark を見ているなら attribute="class" にする。

書き換えThemeProvider のタグ
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
1行ずつ解説
1<ThemeProvider attribute="class" defaultTheme="system" enableSystem>attribute="class" にする。ほかの属性(storageKey など)は今のまま残す。

もしforcedTheme="light"(や "dark")が付いている

テーマ固定の指定。消す。ページごとに forcedTheme を渡している作りもあるので、全体検索 forcedTheme。

もしthemes={["light", "red", …]} に "dark" が入っていない

使えるテーマの一覧に dark が無いと切り替えられない。"dark" を足す。

もしvalue={{ dark: "…" }} がある

dark のときに付ける class の名前を変えている。その名前が CSS と同じか見る(CSS が .dark なら value は消す)。

もしThemeProvider が2か所にある(layout と providers の両方など)

内側のほうが優先され、外側の設定が効かない。1つにする(中身が同じなら内側を消す)。

もしimport { ThemeProvider } from "@/…" で、中が next-themes ではない自作

確認⑤へ。

確認④

切り替えの式を確かめる(関数は呼ばれている場合)

見る場所onClick から飛んだ先の関数の中の setTheme(…) の行。

もしsetTheme(theme === "dark" ? "light" : "dark")(theme を使っている)

最初の theme は "system"。端末がダークだと、1回目に "dark" を選んでも見た目が変わらない。resolvedTheme(実際の色)で判定する。

書き換えsetTheme の行
setTheme(resolvedTheme === "dark" ? "light" : "dark");
1行ずつ解説
1setTheme(resolvedTheme === "dark" ? "light" : "dark");今の実際の色がダークならライトに、そうでなければダークに。resolvedTheme は useTheme の { } の中に足して取り出す。

もしsetTheme(!isDark)・setTheme(true) のように true / false を渡している

setTheme は文字("light"・"dark"・"system")しか受け取らない。上の形に変える。

もしsetTheme("Dark") のように大文字

html に class="Dark" が付き、CSS の .dark と合わない。小文字にする。

もし関数の中で const [isDark, setIsDark] = useState(false) の set だけしていて、setTheme を呼んでいない

部品の中の state が変わるだけで、html には何も起きない(アイコンだけ変わるのはこれ)。setTheme を呼ぶ行を足す。

追記関数の中(set の行の下)
setTheme(isDark ? "light" : "dark");
1行ずつ解説
1setTheme(isDark ? "light" : "dark");今の state の値で判定して、テーマも一緒に変える(set した直後の isDark はまだ古い値なので、「今ダーク=これからライト」の向きで書く)。

もしDropdownMenu で「ライト」「ダーク」を選ぶ形

項目ごとに onClick を付ける(onSelect でもよい)。

書き換えメニューの項目
<DropdownMenuItem onClick={() => setTheme("light")}>ライト</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>ダーク</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>端末に合わせる</DropdownMenuItem>
1行ずつ解説
1<DropdownMenuItem onClick={() => setTheme("light")}>ライト</DropdownMenuItem>押したらライト。
2<DropdownMenuItem onClick={() => setTheme("dark")}>ダーク</DropdownMenuItem>ダーク。
3<DropdownMenuItem onClick={() => setTheme("system")}>端末に合わせる</DropdownMenuItem>OS の設定に合わせる。
確認⑤

自作の useTheme(next-themes ではない)の場合

見る場所F12 で useTheme の定義を開く。createContext・useContext・useState・useEffect を探す。

もしcreateContext({ theme: "light", setTheme: () => {} }) のように、中身が空の関数で始まっている

Provider で囲まれていないと、この空の関数が使われ、押しても何も起きない。同じファイルの export function ThemeProvider(または ThemeContext.Provider を返す部品)で、layout の body の中身を囲む(確認③の1つ目と同じ位置)。

もしProvider で囲まれている。state(theme)は変わるが、html に付ける処理が無い

Provider の部品の中に、theme が変わったら html の class を変える処理を足す。

追記Provider の部品の中(return より上)
useEffect(() => {
  document.documentElement.classList.toggle("dark", theme === "dark");
}, [theme]);
1行ずつ解説
1useEffect(() => {useEffect(処理, [theme]) は「theme が変わるたびに処理を動かす」。
2document.documentElement.classList.toggle("dark", theme === "dark");document.documentElement は <html> のこと。classList.toggle("dark", 条件) は、条件が true なら dark を付け、false なら外す。
3}, [theme]);[theme] がきっかけの一覧。useEffect は react から import。

もし処理はあるが classList.add("dark") だけ

付けるだけで外さない。上の toggle("dark", 条件) の形に。

もし処理はあるが useEffect の最後が []

最初に1回しか動かない。[theme] にする。

もし処理が document.body に付けている

Tailwind の dark: は祖先のどこかに .dark があれば効くので body でも中身は変わる。CSS が html.dark や :root.dark と書いているなら documentElement にする。

確認⑥

class="dark" は付くようになったのに色が変わらない:CSS 側

見る場所layout.tsx で import している CSS(globals.css など)と、package.json の tailwindcss の版。

もしTailwind v4(CSS に @import "tailwindcss")で @custom-variant dark が無い

1行足す。

追記CSS の @import の下
@custom-variant dark (&:is(.dark *));
1行ずつ解説
1@custom-variant dark (&:is(.dark *));「dark: は、どこかの親に class="dark" があるときに効かせる」という v4 の設定。無いと端末の設定でしか切り替わらない。

もしTailwind v3(tailwind.config がある)

config に darkMode: "class"(または ["class"])があるか。"media" なら "class" に変える。

もしCSS に .dark { --background: … } がある(shadcn/ui)が、部品が bg-white など色を直接書いている

その部品だけ変わらない。bg-white→bg-background、text-black→text-foreground に置き換えるか、dark:bg-gray-900 dark:text-white を足す。

もし用意された CSS が [data-theme="dark"] で書かれている

ThemeProvider を attribute="data-theme" にする(class ではなく data-theme を付ける)。CSS の書き方に合わせるのが正解。

もし用意された CSS が別ファイル(dark.css など)

layout.tsx で import されているか。無ければ import "./dark.css"; を足す(場所に合わせる)。

それでも動かないとき

こうなった原因と直し方
押すとアイコンは変わるが色が変わらない部品の state だけ変わっている(確認④の4つ目)か、CSS 側(確認⑥)。html の class を見れば区別できる。
「Hydration failed」「did not match」の警告html に suppressHydrationWarning。アイコンの出し分けで出るなら mounted の形(共通診断の確認③)。
useTheme の値が undefinedThemeProvider の外(確認③)。
再読み込みするとライトに戻るnext-themes は自動で覚える。戻るなら、別の場所で setTheme("light") を毎回呼んでいる(全体検索 setTheme()か、forcedTheme。
ダークにすると一部の画面だけ白いままその部品が色を直接書いている(確認⑥の3つ目)。

自分で調べるなら(AIは使わずWeb検索で)

知りたいこと見る場所検索ワード
next-themes の使い方(attribute・forcedTheme・resolvedTheme)next-themes(GitHub)(README)next-themes resolvedTheme / next-themes attribute class
shadcn/ui のダークモードの作り方shadcn/ui公式「Dark Mode」→「Next.js」shadcn dark mode next.js
Tailwind の dark: の設定(版ごと)Tailwind公式「Dark mode」tailwind v4 dark mode class / tailwind custom-variant dark
html の class を付け外しMDN「Element.classList」MDN classList toggle
useContext と ProviderReact公式「useContext」react useContext provider なし デフォルト値
useEffect の最後の [ ]React公式「useEffect」react useEffect 依存配列
ボタンが動かないときの一般的な原因Stack Overflownext-themes setTheme not working

課題 13

アンケート検索

アンケート一覧で、タイトルに含まれる言葉で絞り込めるようにする。

確認①

検索欄はもうある?

見る場所一覧画面を見る。コードは全体検索 placeholder・検索・type="search"・Search。

もしある(入力はできるが絞り込まれない)

その input の value=・onChange=・name= を見てメモし、確認②へ(入力欄はつなぐだけ)。

もしない

確認②で置き方を決める。

確認②

一覧の .map( があるファイルの1行目に "use client" がある?(06 の確認①②と同じ手順で探す)

見る場所page.tsx → 部品を置いているだけなら F12 で開く。

もしある

useState で検索語を覚え、配列を filter する。

追記部品の中(return より上)
const [keyword, setKeyword] = useState("");

const filtered = surveys.filter((s) =>
  String(s.title ?? "").toLowerCase().includes(keyword.toLowerCase())
);
1行ずつ解説
1const [keyword, setKeyword] = useState("");検索語を覚える箱(useState を react から import)。検索欄がすでにあり、その value に使っている state があるなら、この行は作らずその名前を使う。
3const filtered = surveys.filter((s) =>条件に合うものだけ残した新しい配列を作る(filter)。
4String(s.title ?? "").toLowerCase().includes(keyword.toLowerCase())タイトルを必ず文字にして(null でもエラーにしない)小文字にし、検索語(小文字)が含まれるか。英字の大文字小文字は無視される。
5);上の行で開いたカッコを閉じる。
書き換え検索欄(無ければ足す/あれば value と onChange をこれにする)
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="タイトルで検索" className="rounded border px-3 py-2" />
1行ずつ解説
1<input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="タイトルで検索" className="rounded border px-3 py-2" />打つたびに keyword が変わり、一覧がすぐ絞り込まれる。既存の Input 部品でも value と onChange を同じように書けばよい。
書き換え今の map を書き換え + 0件の表示
{filtered.length === 0 && <p>見つかりませんでした</p>}
{filtered.map((s) => (
1行ずつ解説
1{filtered.length === 0 && <p>見つかりませんでした</p>}1件も無いときだけメッセージ。
2{filtered.map((s) => (ここを変えないと絞り込まれない。surveys.map( → filtered.map(。

もしない + page.tsx

フォームで URL に ?q=… を付けて送り、page.tsx で受け取って絞り込む。

書き換えpage.tsx の関数の1行目
export default async function Page({ searchParams }: { searchParams: Promise<{ q?: string }> }) {
  const { q = "" } = await searchParams;
1行ずつ解説
1export default async function Page({ searchParams }: { searchParams: Promise<{ q?: string }> }) {引数に searchParams を足す(06 と同じ。すでに params があれば並べる。Next.js 14 以下は Promise と await を外す)。
2const { q = "" } = await searchParams;検索語を取り出す。無ければ空。
追記データを取っている行のすぐ下
const filtered = surveys.filter((s) => String(s.title ?? "").includes(q));
1行ずつ解説
1const filtered = surveys.filter((s) => String(s.title ?? "").includes(q));タイトルに検索語を含むものだけ残す。検索語が空なら全部残る。
追記return の中(一覧の上)
<form>
  <input name="q" defaultValue={q} placeholder="タイトルで検索" className="rounded border px-3 py-2" />
  <button>検索</button>
</form>
1行ずつ解説
1<form>action を書かない form は、送信すると今のURLに ?q=入力した文字 を付けて開き直す(HTMLの決まり)。
2<input name="q" defaultValue={q} placeholder="タイトルで検索" className="rounded border px-3 py-2" />name="q" が URL の q になる。defaultValue で検索後も文字が残る。
3<button>検索</button>検索ボタン(form の中のボタンは押すと送信)。
4</form><form> を閉じる。
書き換え今の map を書き換える
{filtered.map((s) => (
1行ずつ解説
1{filtered.map((s) => (surveys.map( → filtered.map(。

もしない + page.tsx 以外の部品

06 の確認②の3つ目と同じ:page.tsx で q を受け取り、q={q} で部品に渡して、部品の中で filter する。検索の form は page.tsx に置く。

確認③

(上級)データを取る行が prisma.….findMany( ?

見る場所page.tsx か、そこから呼んでいる関数を見る。

もしはい

DB に絞り込ませることもできる(filter の代わり)。

書き換えfindMany に where を足す
const surveys = await prisma.survey.findMany({
  where: q ? { title: { contains: q } } : undefined,
});
1行ずつ解説
1const surveys = await prisma.survey.findMany({今のデータ取得の行。
2where: q ? { title: { contains: q } } : undefined,検索語があるときだけ「タイトルに q を含む」条件を付ける。すでに where があれば、その中に title: { contains: q } を足す。
3});上の行で開いたカッコを閉じる。

もしfetch("/api/…") で取っている

取ってきた配列を確認②の filter で絞るのが一番簡単(API 側を直す必要はない)。

それでも動かないとき

こうなった原因と直し方
打っても一覧が変わらないmap がまだ surveys.map( のまま。または input の value / onChange が keyword につながっていない。
Enter を押すとページが再読み込みされて文字が消える"use client" 版で input を <form> で囲んでいる。form を外すか、<form onSubmit={(e) => e.preventDefault()}> にする。
Cannot read properties of null (reading 'toLowerCase')タイトルが空のデータがある。String(s.title ?? "") の形にする。

自分で調べるなら(AIは使わずWeb検索で)

知りたいこと見る場所検索ワード
絞り込み(filter)MDN「Array.prototype.filter()」MDN filter
含まれるか(includes)MDN「String.prototype.includes()」MDN includes 文字列
入力欄と stateReact公式「state を使って入力に反応する」react input value onChange
URL の ?q= を受け取るNext.js公式「page.tsx」の searchParamsnextjs searchParams
Prisma で絞り込むPrisma公式prisma where contains

課題 14

プライバシーポリシー画面

/privacy にプライバシーポリシーのページを置き、ヘッダーかフッターからリンクする。

わかっていること

  • app フォルダはある(App Router)。

Next.js は「フォルダの名前=URL」「中の page.tsx=その画面」という決まり。/privacy を作るには、app の中に privacy フォルダと page.tsx が要る。新しいファイルが必要なのはここだけで、リンクは今あるファイルへの追記。

確認①

app フォルダの中はどうなっている?(置く場所を決める)

見る場所VS Code の左のエクスプローラーで app を開く。一覧画面(例:/surveys)の page.tsx がどのフォルダにあるかを見る。

もしapp の直下に page.tsx・surveys などが並んでいる

app/privacy/page.tsx を作る(app を右クリック →「新しいフォルダー」→ privacy → その中に「新しいファイル」→ page.tsx)。

もしsrc/app になっている

src/app/privacy/page.tsx。

もし(main)・(site)・(public) のようなカッコ付きのフォルダがあり、surveys などはその中

カッコのフォルダは「ルートグループ」で、URL には出ない。一覧画面と同じカッコのフォルダの中に作る:app/(main)/privacy/page.tsx。ヘッダーがそのカッコの中の layout.tsx にあることが多く、外に作るとヘッダーが出ない。

もしカッコのフォルダが2つある((auth) と (main) など)

ログインしなくても見られるほう((public) など)か、ヘッダーがあるほう。迷ったら一覧画面と同じほう。

もし[locale]・[lang] のフォルダ(多言語)

app/[locale]/privacy/page.tsx。URL は /ja/privacy のようになる。

もしすでに privacy・policy・privacy-policy フォルダがある

中の page.tsx を開いて、中身を書き換える(新しく作らない)。リンクの href はそのフォルダ名に合わせる。

もしほかのページが page.jsx・page.js

同じ拡張子で作る。JavaScript なので、コードに : string のような型があれば消す(確認②のコードには型は無い)。

確認②

ページの中身を書く(ほかのページをまねる)

見る場所似た中身のページ(about・一覧など)の page.tsx を開いて、外枠の書き方を見る。

もしどの場合も(基本の形)

新規ファイルapp/privacy/page.tsx(確認①で決めた場所)
export const metadata = { title: "プライバシーポリシー" };

export default function PrivacyPage() {
  return (
    <main className="mx-auto max-w-2xl space-y-4 p-6">
      <h1 className="text-2xl font-bold">プライバシーポリシー</h1>
      <h2 className="font-bold">取得する情報</h2>
      <p>アンケートの回答内容と回答日時を取得します。氏名などの入力は求めません。</p>
      <h2 className="font-bold">利用目的</h2>
      <p>回答の集計とグラフ表示のためにのみ利用します。</p>
      <h2 className="font-bold">第三者への提供</h2>
      <p>法令に基づく場合を除き、第三者に提供しません。</p>
    </main>
  );
}
1行ずつ解説
1export const metadata = { title: "プライバシーポリシー" };タブに出るページの題名。metadata は Next.js の決まった名前(ほかのページに無ければこの行は無くてもよい)。
3export default function PrivacyPage() {ページの部品。export default を付けると、このファイルの場所(/privacy)の画面になる。関数名は自由。
4return (画面を返す。
5<main className="mx-auto max-w-2xl space-y-4 p-6">読みやすい幅の本文。space-y-4 で段落の間をあける。ほかのページが別の外枠(<Card> など)を使っていれば、それに合わせる。
6<h1 className="text-2xl font-bold">プライバシーポリシー</h1>見出し。
7<h2 className="font-bold">取得する情報</h2>小見出し(以下の文章は自由に書き換えてよい)。
8<p>アンケートの回答内容と回答日時を取得します。氏名などの入力は求めません。</p>本文。
9<h2 className="font-bold">利用目的</h2>小見出し。
10<p>回答の集計とグラフ表示のためにのみ利用します。</p>本文。
11<h2 className="font-bold">第三者への提供</h2>小見出し。
12<p>法令に基づく場合を除き、第三者に提供しません。</p>本文。
13</main>main を閉じる。
14);カッコを閉じる。
15}関数を閉じる。

もしほかのページが <Card>・<PageContainer>・<Container> などの部品で包んでいる

同じ部品で包むと見た目がそろう。<main …> の行と </main> をその部品に変え、import 行をほかのページからコピーする。

もしほかのページの1行目に "use client" がある

このページには不要(ボタンも state も無い)。"use client" があると metadata が書けない(エラーになる)ので、付けるなら1行目の metadata を消す。

もしほかのページに export const metadata が無い

1行目は無くてもよい。

もし本文は Markdown や別の文章ファイルで持ちたい

課題としては不要。上の形で十分。

確認③

リンクを置く(今あるヘッダー・フッターに追記)

見る場所画面のヘッダー・フッターにある文字(「一覧」「ホーム」など)で全体検索 → そのファイルを開く。

もし「プライバシーポリシー」の文字がすでにあり、href="#" や href=""

href を変えるだけ。

書き換えそのリンクの href
href="/privacy"
1行ずつ解説
1href="/privacy"フォルダ名と同じにする。ルートグループのカッコは入れない。

もしリンクが配列で書かれている(const links = [{ href: "/surveys", label: "一覧" }, …])

配列に1行足すだけ。

追記配列の最後に1行
{ href: "/privacy", label: "プライバシーポリシー" },
1行ずつ解説
1{ href: "/privacy", label: "プライバシーポリシー" },今の配列の1件と同じ項目名(href / label、path / name など)で書く。

もし<Link href=…> が並んでいる

隣のリンクをコピーして、href と文字だけ変える。

追記隣のリンクの下
<Link href="/privacy" className="隣と同じ">プライバシーポリシー</Link>
1行ずつ解説
1<Link href="/privacy" className="隣と同じ">プライバシーポリシー</Link>className は隣のリンクと同じものをコピーすると見た目がそろう。

もし<a href=…> が並んでいる

<a href="/privacy">プライバシーポリシー</a> を足す。

もしフッターの部品(components/footer.tsx など)がすでにある

そのファイルに上と同じ形で Link を足す。

もしフッターが無い

layout.tsx の {children} の下に足す。

追記app/layout.tsx の {children} の下
<footer className="mt-10 border-t p-4 text-center text-sm">
  <Link href="/privacy">プライバシーポリシー</Link>
</footer>
1行ずつ解説
1<footer className="mt-10 border-t p-4 text-center text-sm">全ページの下に出るフッター。
2<Link href="/privacy">プライバシーポリシー</Link>リンク。layout.tsx の先頭に import Link from "next/link"; が必要。
3</footer>footer を閉じる。
確認④

/privacy を開くと別の画面になる/ログイン画面に飛ばされる

見る場所プロジェクトの一番上(app と同じ階層か src の直下)に middleware.ts があるか。

もしmiddleware.ts がある

ログインしていない人を /login に送る処理があると、/privacy にも入れない。中の「ログインしなくてよいページの一覧」に足す。

書き換えmiddleware.ts の「公開ページの一覧」(例)
const publicPaths = ["/login", "/privacy"];
1行ずつ解説
1const publicPaths = ["/login", "/privacy"];今の配列に "/privacy" を足す。一覧が matcher で書かれている場合は、除外の書き方((?!login|privacy) など)に privacy を足す。

もしmiddleware.ts が無い

このケースではない。確認①の置き場所をもう一度見る。

それでも動かないとき

こうなった原因と直し方
/privacy が 404ファイル名が page.tsx でない(Page.tsx・privacy.tsx は不可)、フォルダが app の外、または URL にカッコのフォルダ名を入れている(/(main)/privacy ではなく /privacy)。npm run dev を止めて起動し直すと直ることもある。
ページは出るがヘッダーが無いヘッダーはカッコのフォルダの layout.tsx にある。privacy フォルダをそのカッコの中に移す(確認①の3つ目)。
「You are attempting to export "metadata" from a component marked with "use client"」"use client" と metadata は一緒に使えない。どちらかを消す。
Link is not definedそのファイルの先頭に import Link from "next/link";。

自分で調べるなら(AIは使わずWeb検索で)

知りたいこと見る場所検索ワード
フォルダ=URL の決まりNext.js公式「Project Structure」「Layouts and Pages」nextjs app router page.tsx フォルダ
カッコ付きのフォルダNext.js公式「Route Groups」nextjs route groups
リンクNext.js公式「Link」next/link Link 使い方
ページの題名Next.js公式「Metadata」nextjs metadata title
middleware でページを除外Next.js公式「Middleware」の matchernextjs middleware matcher 除外

課題 15

テーマカラー変更(16色)

16色のボタンから選んで、アプリの主な色(ボタンなど)とグラフの色を変えられるようにする。

わかっていること

  • 色のボタン(ブラック・グレー…)はすでにある。
  • 色の CSS もある。.theme-red のようなクラスがたくさん並んでいる。
  • → 新しいファイルは作らない。今あるボタンに onClick をつなぎ、html にクラスを付ける関数を足す(直す)だけ。

しくみ:CSS の .theme-red { … } は「theme-red という class が付いた要素(とその中身)にだけ効く」。だから押したら <html> に class="theme-red" を付ければ、ページ全体の色が変わる。確認①で CSS を読み、確認③④でボタンと関数を直す。

確認①

用意された CSS を読む(どこにクラスを付ける前提か)

見る場所全体検索(Ctrl+Shift+F)で .theme-red。検索欄の下の「…」→「含めるファイル」に *.css と入れると CSS だけ探せる。

もし.theme-red { --primary: …; --primary-foreground: …; } のように、クラスの中で変数(-- で始まる名前)を決めている

一番よくある形。html に付ければ全部に効く。変数の名前(--primary など)をメモして確認②へ。

もしhtml.theme-red・:root.theme-red で始まっている

必ず html に付ける前提。確認④の関数のまま。

もしbody.theme-red で始まっている

body に付ける前提。確認④の関数の document.documentElement を document.body に変える。

もし.theme-red .btn { background: … }・.theme-red button { … } のように、中の部品の色を直接決めている

クラスはボタンたちの親ならどこに付けてもよい。html に付けるのが確実。

もし.dark.theme-red・.theme-red.dark のように、ダーク用の指定も別にある

html に dark と theme-red の両方が付く前提。確認④の関数は dark を消さないのでそのままでよい。className = …(代入)で付けると dark が消えるので使わない。

もしクラス名が theme- で始まらない(.red・.color-red・.primary-red など)

確認④の "theme-" をその頭の文字に変える(.red のように頭が無いなら確認④の「頭が無い」の形)。

もしCSS が themes.css・colors.css など、globals.css 以外のファイル

そのファイルが読み込まれていないと効かない。layout.tsx(または globals.css の先頭)で import されているか見る。

追記app/layout.tsx の import の並び(無い場合)
import "./themes.css";
1行ずつ解説
1import "./themes.css";CSS を全ページで読み込む。パスは layout.tsx から見た場所に合わせる(@/styles/themes.css など)。globals.css の中に書くなら @import "./themes.css"; を一番上に。

もし16色のクラス名を、ボタンの名前(ブラック・グレー)と対応させる

CSS の .theme-black .theme-gray … の theme- の後ろ(black・gray …)をメモしておく。ボタンから渡すのはこの文字。

確認②

その変数は、画面の部品で使われている?

見る場所確認①でメモした変数名(例:--primary)を、今度は *.tsx と *.css で全体検索。

もしbg-primary・text-primary・bg-[var(--primary)]・var(--primary) が使われている

OK。クラスを付ければ変わる。確認③へ。

もしボタンなどが bg-blue-500・bg-black のように色を直接書いている

クラスが付いても変わらない。色を変えたい部品(主なボタン・ヘッダーなど)の色のクラスを、変数を使う形に置き換える。

書き換え色を変えたい部品の className(例)
className="今の色以外のクラス bg-[var(--primary)] text-[var(--primary-foreground)]"
1行ずつ解説
1className="今の色以外のクラス bg-[var(--primary)] text-[var(--primary-foreground)]"bg-[var(--primary)] は「背景を CSS 変数 --primary の色に」という Tailwind の書き方。bg-blue-500 と置き換える。文字色の変数が無ければ text-white のままでよい。

もしCSS に @theme inline { --color-primary: var(--primary); } がある(Tailwind v4 / shadcn/ui)

bg-primary と書ける(上の bg-[var(--primary)] と同じ意味)。

もし今のコードに bg-${color}-500 のような、文字をつなげたクラスがある

Tailwind はこれを作れない(コードの中に完成したクラス名がそのまま書いてあるものしか CSS にしない)。変数の形(上の2つ目)にするか、16色分の完成したクラス名を文字で全部書いた配列を使う。

確認③

色のボタンの onClick を確かめる

見る場所画面のボタンの文字(「ブラック」など)で全体検索 → そのファイル。

もしボタンが配列の map で作られている(const colors = [...] → colors.map(…))

onClick を1か所足すだけ。配列の1件の形を見る(下の2つ)。

もし配列が [{ name: "ブラック", value: "black" }, …] のようにオブジェクト

CSS の theme- の後ろと同じ文字が入っている項目を渡す。

書き換えmap の中のボタン
<button key={c.value} onClick={() => changeColor(c.value)} 今の属性>
1行ずつ解説
1<button key={c.value} onClick={() => changeColor(c.value)} 今の属性>() => changeColor(c.value) で「押されたらこの色の名前で changeColor を動かす」。c.value は配列の項目名に合わせる(c.id・c.key・c.theme など)。

もし配列の項目に "theme-red" のようにクラス名が丸ごと入っている

確認④の「クラス名が丸ごと来る」の形の関数を使う。

もし配列が ["red", "blue", …] のようにただの文字

onClick={() => changeColor(c)}(c そのもの)。

もし16個のボタンが1つずつ書いてある

それぞれに onClick を付ける。

書き換え1つ目のボタン(ほかも同じように)
<Button onClick={() => changeColor("black")} 今の属性>ブラック</Button>
1行ずつ解説
1<Button onClick={() => changeColor("black")} 今の属性>ブラック</Button>"black" の部分だけボタンごとに変える(CSS の theme- の後ろと同じ綴り)。

もしonClick はあり、changeTheme("red") のような関数につながっている

その関数の中身を確認④で直す(新しく作らない。関数名はそのまま)。

もしonClick があるのに、押しても関数が動かない(console.log が出ない)

共通診断 へ。

もしボタンではなく <select> や <input type="radio">

onChange を使う:onChange={(e) => changeColor(e.target.value)}。e.target.value は選ばれた option / radio の value。

確認④

関数の中身(html にクラスを付ける)

見る場所関数の1行目に console.log(name) を一時的に足して押す。そのあと F12 → Elements の <html class="…"> を見る。

もし関数が空・console.log だけ・useState に入れるだけ

中身をこの形にする(関数名が違えば今の名前のまま)。

書き換え色を変える関数(無ければ部品の中に追記)
function changeColor(name: string) {
  const root = document.documentElement;
  [...root.classList].forEach((c) => {
    if (c.startsWith("theme-")) root.classList.remove(c);
  });
  root.classList.add(`theme-${name}`);
  localStorage.setItem("themeColor", name);
}
1行ずつ解説
1function changeColor(name: string) {色を変える関数。name には "red" や "black" のような色の名前が入る。
2const root = document.documentElement;document.documentElement は <html> のこと。何度も使うので短い名前に入れる。
3[...root.classList].forEach((c) => {root.classList は html に付いている class の一覧。[...一覧] で普通の配列にして、forEach で1つずつ見る。
4if (c.startsWith("theme-")) root.classList.remove(c);startsWith("theme-")=「theme- で始まる」class なら外す=前に選んだ色を全部外す。dark など他の class は残る。
5});forEach を閉じる。
6root.classList.add(`theme-${name}`);選んだ色の class を付ける(例:theme-red)。theme- の部分は CSS のクラス名の頭に合わせる。
7localStorage.setItem("themeColor", name);選んだ色をブラウザに保存(再読み込みで戻すため・確認⑤)。
8}関数を閉じる。

もしボタンから "theme-red" のようにクラス名が丸ごと来る

6行目を変える。

書き換え6行目
  root.classList.add(name);
1行ずつ解説
1root.classList.add(name);来た名前をそのまま付ける。

もしクラス名に頭が無い(.red・.black)

前の色を外す行(3〜5行目)を、色の一覧で外す形にする。

書き換え3〜5行目
  colors.forEach((c) => root.classList.remove(c.value));
1行ずつ解説
1colors.forEach((c) => root.classList.remove(c.value));ボタンの配列(colors)の全部の色を外す。6行目は root.classList.add(name) にする。

もし今の関数が document.documentElement.className = … と代入している

代入は class を全部入れ替えるので、dark や next-themes の class が消える(ダークモードが壊れる)。上の classList の形にする。

もし今の関数が useState に入れて、外側の div の className に `theme-${color}` を付けている

その div の中だけ色が変わる。ヘッダーが div の外にある・背景(body)が変わらないなら、state が変わったときに html に付ける。

追記同じ部品の中(return より上)
useEffect(() => {
  changeColor(color);
}, [color]);
1行ずつ解説
1useEffect(() => {state(color)が変わるたびに動く。
2changeColor(color);上の changeColor で html に付ける。
3}, [color]);[color] がきっかけ。useEffect は react から import。

もし色が Context や Zustand の store に入っている(useThemeColor()・useSettingsStore など)

ボタンはその set を呼び、html に付ける処理は Provider(または store を使う部品)の useEffect に書く(上の形)。

もしThemeProvider(next-themes)に themes={["red", "blue", …]} がある

setTheme("red") で色も変えられるが、ダークモードと同じ1枠を取り合う(赤にするとダークが外れる)。色は classList の関数、ダークは next-themes と分けるほうが安全。

もしclass は付く(Elements で見える)のに色が変わらない

確認①②に戻る:CSS のクラス名と綴りが違う(theme-Red と theme-red)、CSS ファイルが読み込まれていない、部品が変数を使っていない、のどれか。

確認⑤

再読み込みしても色を残す

見る場所色のボタンがある部品(今あるファイル)。

もしどの場合も(かんたん)

表示されたときに保存された色を読み直す。一瞬元の色が見えることがあるが、課題としては十分。

追記色のボタンがある部品の中(return より上)
useEffect(() => {
  const saved = localStorage.getItem("themeColor");
  if (saved) changeColor(saved);
}, []);
1行ずつ解説
1useEffect(() => {表示された後に1回だけ動く。
2const saved = localStorage.getItem("themeColor");保存した色を読む(無ければ null)。"themeColor" は確認④の7行目と同じ名前。
3if (saved) changeColor(saved);あれば付ける。
4}, []);[]=最初の1回だけ。

もし一瞬元の色が見えるのをなくしたい(任意)

layout.tsx の <head> に、画面を描く前に動く1行を足す。

追記app/layout.tsx の <html> の中(<head> が無ければ <body> の上に <head></head> を作る)
<script dangerouslySetInnerHTML={{ __html: `const c = localStorage.getItem("themeColor"); if (c) document.documentElement.classList.add("theme-" + c);` }} />
1行ずつ解説
1<script dangerouslySetInnerHTML={{ __html: `const c = localStorage.getItem("themeColor"); if (c) document.documentElement.classList.add("theme-" + c);` }} />画面を描く前に保存された色を html に付ける。html に suppressHydrationWarning が無ければ足す。

もしヘッダーが2か所ある・色のボタンが設定画面にしか無い

useEffect をヘッダー(全ページに出る部品)に書く。設定画面にだけ書くと、そのページを開くまで色が戻らない。

確認⑥

グラフの色も変える

見る場所グラフの部品を開く(全体検索 fill=・color:・backgroundColor・--chart)。

もし色のクラスの中に --chart-1 などもある + グラフが var(--chart-1) を使っている

何もしなくてよい。一緒に変わる。

もしshadcn/ui の chartConfig(color: "var(--chart-1)")で、色のクラスに --chart が無い

color を主な色の変数にする:color: "var(--primary)"。

もしRecharts で fill="#8884d8" のように色が直接

fill="var(--primary)" に置き換える(SVG は CSS 変数を読める)。円グラフの <Cell なら fillOpacity={Math.max(0.25, 1 - i * 0.15)} を足すと、1色の濃さ違いで見分けられる。

もしChart.js(react-chartjs-2)

canvas は CSS 変数を読めない。色を文字として取り出して渡す。

追記グラフの部品の中
const primary = getComputedStyle(document.documentElement).getPropertyValue("--primary").trim() || "#3b82f6";
1行ずつ解説
1const primary = getComputedStyle(document.documentElement).getPropertyValue("--primary").trim() || "#3b82f6";今の --primary の値を文字で取り出す(空なら青)。datasets の backgroundColor にこれを入れる。色を変えても描き直されないので、グラフに key={primary} を付ける。
確認⑦

ブラック・グレーの注意(見た目の仕上げ)

見る場所ブラックを選んでからダークモードにしてみる。

もしブラックのボタンがダークの背景に溶けて見えない

CSS に .dark.theme-black { --primary: … } のようなダーク用があるか見る。無ければ、ダークのときはボタンに枠線を付ける:className に dark:border dark:border-white/30。

もしブラック・グレーでボタンの文字が読めない

文字色の変数(--primary-foreground など)が CSS にあれば text-[var(--primary-foreground)] を使う。無ければ text-white。

もし選んでいる色がどれかわからない

ボタンに選ばれている印を付ける:今の色を state で覚え、className={選んだ色 === c.value ? "ring-2 ring-offset-2" : ""}(今の className の後ろにつなげる)。

それでも動かないとき

こうなった原因と直し方
押しても html の class が変わらない関数が動いていない(共通診断)か、関数が html ではなく別の場所を変えている(確認④)。
class は付くのに色が変わらないCSS 側(確認①②)。綴り・読み込み・変数を使っているか。
ダークモードにすると色が戻る/ダークが消えるダーク用の CSS(.dark { --primary: … })が後から上書きしている → .dark.theme-red の指定が要る。ダークが消えるなら className に代入している(確認④の4つ目)。
赤を押した後に青を押すと、赤のまま前の色を外していない(確認④の3〜5行目)。CSS の後ろに書かれた色が勝つ。
localStorage is not definedサーバーで動く場所(page.tsx や部品の外)で使った。useEffect の中か、onClick の関数の中で使う。

自分で調べるなら(AIは使わずWeb検索で)

知りたいこと見る場所検索ワード
class を付ける・外すMDN「Element.classList」MDN classList add remove
CSS 変数(--primary と var())MDN「CSS カスタムプロパティ(変数)の使用」MDN CSS 変数 var
[…] で一覧を配列にするMDN「スプレッド構文」MDN スプレッド構文 classList 配列
bg-[var(--x)] の書き方Tailwind公式「Adding custom styles」(任意の値)tailwind arbitrary value var
bg-${color} が効かない理由Tailwind公式「Detecting classes in source files」tailwind dynamic class name not working
ブラウザに保存MDN「Window.localStorage」MDN localStorage
Recharts / Chart.js の色Recharts公式 / Chart.js公式recharts fill css variable / chartjs css variable color

課題 16・17

円グラフ・棒グラフ(既存のグラフの仕組みを使う)

グラフの仕組みはすでにある。それを使って円グラフ・棒グラフを表示し、ボタンで切り替えられるようにする(切り替えに少しアニメーションを付ける)。

確認①

既存のグラフは何で作られている?

見る場所package.json の dependencies を見る。全体検索なら recharts・react-chartjs-2・ChartContainer・BarChart・PieChart。

もしrecharts(<BarChart> などのタグ。shadcn/ui の ChartContainer で囲まれていることもある)

確認② → 確認③。

もしreact-chartjs-2 / chart.js(<Bar data={…} /> の形)

確認② → 確認④。

もしどちらも無い(div や SVG で自作)

確認②で「種類」を受け取れるか見て、無ければ確認⑤の切り替えだけ入れる。

確認②

既存のグラフの部品は、すでに「種類」を受け取れる?

見る場所グラフの部品のファイルを開き、props に type・chartType・variant があるか、中に "pie"・"bar" の文字があるか(Ctrl+F)。

もしある(例:if (type === "pie") や switch (chartType))

グラフを作る必要はない。使う側から種類を渡し、切り替えボタンを付けるだけ(確認⑤へ)。

もしない(棒だけ、円だけの部品)

足りないほうのグラフを、既存のデータをそのまま使って作る(確認③か④)。

確認③

Recharts の場合:足りないほうを作る

見る場所既存のグラフの dataKey= の文字をそのまま使うのがコツ。棒グラフなら <Bar dataKey="A"> の A が数、<XAxis dataKey="B"> の B が名前。

もし棒グラフはある → 円グラフを作る

新規ファイル新規ファイル:components/pie-result.tsx(Recharts)
"use client";
import { Cell, Legend, Pie, PieChart, Tooltip } from "recharts";

export function PieResult({ data }: { data: any[] }) {
  return (
    <PieChart width={320} height={280}>
      <Pie data={data} dataKey="count" nameKey="label" outerRadius={100} label animationDuration={800}>
        {data.map((_, i) => (
          <Cell key={i} fill="var(--primary, #3b82f6)" fillOpacity={Math.max(0.25, 1 - i * 0.15)} stroke="#fff" />
        ))}
      </Pie>
      <Tooltip />
      <Legend />
    </PieChart>
  );
}
1行ずつ解説
1"use client";ブラウザで動かすという宣言。useState や onClick を使うファイルの1行目に書く(新しいファイルの印ではない)。
2import { Cell, Legend, Pie, PieChart, Tooltip } from "recharts";Recharts から円グラフの部品を読み込む。
4export function PieResult({ data }: { data: any[] }) {円グラフの部品。data は既存の棒グラフに渡しているものと同じ配列(any[] は「何の配列でもよい」という型)。
5return (画面を返す。
6<PieChart width={320} height={280}>円グラフ全体の大きさ。
7<Pie data={data} dataKey="count" nameKey="label" outerRadius={100} label animationDuration={800}>dataKey に既存の Bar の dataKey(数)、nameKey に既存の XAxis の dataKey(名前)をそのまま写す。animationDuration は円が描かれる動きの時間。
8{data.map((_, i) => (切れ端ごとに色を決める。
9<Cell key={i} fill="var(--primary, #3b82f6)" fillOpacity={Math.max(0.25, 1 - i * 0.15)} stroke="#fff" />テーマカラー(15)の変数を使い、番号が進むほど薄くする。変数が無いときは青。
10))}上の行で開いたカッコを閉じる。
11</Pie><Pie> を閉じる。
12<Tooltip />マウスを乗せると値が出る。
13<Legend />凡例。
14</PieChart><PieChart> を閉じる。
15);上の行で開いたカッコを閉じる。
16}上の行で開いたカッコを閉じる。

もし円グラフはある → 棒グラフを作る

新規ファイル新規ファイル:components/bar-result.tsx(Recharts)
"use client";
import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from "recharts";

export function BarResult({ data }: { data: any[] }) {
  return (
    <BarChart width={500} height={280} data={data}>
      <CartesianGrid vertical={false} />
      <XAxis dataKey="label" />
      <YAxis allowDecimals={false} />
      <Tooltip />
      <Bar dataKey="count" fill="var(--primary, #3b82f6)" radius={[4, 4, 0, 0]} animationDuration={800} />
    </BarChart>
  );
}
1行ずつ解説
1"use client";ブラウザで動かすという宣言。useState や onClick を使うファイルの1行目に書く(新しいファイルの印ではない)。
2import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from "recharts";Recharts から棒グラフの部品を読み込む。
4export function BarResult({ data }: { data: any[] }) {棒グラフの部品。data は既存の円グラフと同じ配列。
5return (画面を返す。
6<BarChart width={500} height={280} data={data}>棒グラフ全体。スマホではみ出すなら width を 320 に。
7<CartesianGrid vertical={false} />横の目盛り線だけ出す。
8<XAxis dataKey="label" />横軸:既存の Pie の nameKey の文字をそのまま写す。
9<YAxis allowDecimals={false} />縦軸。件数なので小数を出さない。
10<Tooltip />マウスを乗せると値が出る。
11<Bar dataKey="count" fill="var(--primary, #3b82f6)" radius={[4, 4, 0, 0]} animationDuration={800} />棒:既存の Pie の dataKey の文字をそのまま写す。色はテーマカラー。animationDuration は棒が伸びる時間。
12</BarChart><BarChart> を閉じる。
13);上の行で開いたカッコを閉じる。
14}上の行で開いたカッコを閉じる。

もし既存のグラフが <ChartContainer config={chartConfig}> で囲まれている(shadcn/ui)

新しいグラフも同じように囲むと、色やツールチップの見た目がそろう。

書き換え新しいグラフの一番外側
<ChartContainer config={chartConfig} className="h-[280px] w-full">
  <PieChart>…</PieChart>
</ChartContainer>
1行ずつ解説
1<ChartContainer config={chartConfig} className="h-[280px] w-full">既存のグラフと同じ ChartContainer と config を使う(import も既存のファイルからコピー)。ChartContainer で囲むときは PieChart / BarChart の width・height は消してよい。
2<PieChart>…</PieChart>上で作ったグラフの中身。
3</ChartContainer><ChartContainer> を閉じる。
確認④

Chart.js(react-chartjs-2)の場合:足りないほうを作る

見る場所既存の <Bar data={…} /> などの data はそのまま円にも使える({ labels, datasets } の形は共通)。

もし円グラフを足す

新規ファイル新規ファイル:components/pie-result.tsx(Chart.js)
"use client";
import { ArcElement, Chart as ChartJS, Legend, Tooltip } from "chart.js";
import { Pie } from "react-chartjs-2";

ChartJS.register(ArcElement, Tooltip, Legend);

export function PieResult({ data }: { data: any }) {
  return <Pie data={data} />;
}
1行ずつ解説
1"use client";ブラウザで動かすという宣言。useState や onClick を使うファイルの1行目に書く(新しいファイルの印ではない)。
2import { ArcElement, Chart as ChartJS, Legend, Tooltip } from "chart.js";chart.js から、円グラフに必要な部品を読み込む。
3import { Pie } from "react-chartjs-2";react-chartjs-2 から円グラフを読み込む。
5ChartJS.register(ArcElement, Tooltip, Legend);必ず必要:円グラフの部品を登録する。無いと「"arc" is not a registered element」というエラーになる。
7export function PieResult({ data }: { data: any }) {円グラフの部品。data は既存の棒グラフに渡しているものと同じ。
8return <Pie data={data} />;円グラフを描く。Chart.js は最初から回りながら描かれるアニメーションが付いている。
9}上の行で開いたカッコを閉じる。

もし棒グラフを足す

新規ファイル新規ファイル:components/bar-result.tsx(Chart.js)
"use client";
import { BarElement, CategoryScale, Chart as ChartJS, Legend, LinearScale, Tooltip } from "chart.js";
import { Bar } from "react-chartjs-2";

ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend);

export function BarResult({ data }: { data: any }) {
  return <Bar data={data} />;
}
1行ずつ解説
1"use client";ブラウザで動かすという宣言。useState や onClick を使うファイルの1行目に書く(新しいファイルの印ではない)。
2import { BarElement, CategoryScale, Chart as ChartJS, Legend, LinearScale, Tooltip } from "chart.js";棒グラフに必要な部品(棒・横軸・縦軸・吹き出し・凡例)を読み込む。
3import { Bar } from "react-chartjs-2";react-chartjs-2 から棒グラフを読み込む。
5ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend);部品を登録する。無いと「"category" is not a registered scale」などのエラーになる。
7export function BarResult({ data }: { data: any }) {棒グラフの部品。
8return <Bar data={data} />;棒グラフを描く(棒が伸びるアニメーションが最初から付いている)。
9}上の行で開いたカッコを閉じる。
確認⑤

棒 ↔ 円をボタンで切り替える(アニメーション付き)

見る場所集計画面で、今グラフを置いている場所。

もし既存の部品が種類を受け取れる(確認②で「ある」)

下の ChartSwitch の18行目を <ExistingChart type={type} data={data} /> のように既存の部品1つに変える。

もしどの場合も

新規ファイル新規ファイル:components/chart-switch.tsx
"use client";
import { useState } from "react";
import { BarResult } from "@/components/bar-result";
import { PieResult } from "@/components/pie-result";

export function ChartSwitch({ data }: { data: any }) {
  const [type, setType] = useState<"bar" | "pie">("bar");
  const btn = "rounded px-3 py-1 text-sm transition-colors";
  const on = "bg-[var(--primary,#3b82f6)] text-white";

  return (
    <div>
      <div className="mb-3 flex gap-2">
        <button onClick={() => setType("bar")} className={`${btn} ${type === "bar" ? on : "border"}`}>棒グラフ</button>
        <button onClick={() => setType("pie")} className={`${btn} ${type === "pie" ? on : "border"}`}>円グラフ</button>
      </div>
      <div key={type} className="chart-in">
        {type === "bar" ? <BarResult data={data} /> : <PieResult data={data} />}
      </div>
    </div>
  );
}
1行ずつ解説
1"use client";ブラウザで動かすという宣言。useState や onClick を使うファイルの1行目に書く(新しいファイルの印ではない)。
2import { useState } from "react";state を使うので useState を読み込む。
3import { BarResult } from "@/components/bar-result";棒グラフの部品を読み込む(既存の部品を使うなら、その名前と場所に変える)。
4import { PieResult } from "@/components/pie-result";円グラフの部品を読み込む(同上)。
6export function ChartSwitch({ data }: { data: any }) {切り替え部品。data はそのまま下のグラフへ渡す。
7const [type, setType] = useState<"bar" | "pie">("bar");今どちらを出しているかを覚える箱。最初は棒。
8const btn = "rounded px-3 py-1 text-sm transition-colors";2つのボタンに共通の見た目。
9const on = "bg-[var(--primary,#3b82f6)] text-white";選ばれているボタンの見た目(テーマカラーの背景・白文字)。
11return (画面を返す。
12<div>全体の入れ物。
13<div className="mb-3 flex gap-2">ボタンを横に並べる。
14<button onClick={() => setType("bar")} className={`${btn} ${type === "bar" ? on : "border"}`}>棒グラフ</button>棒グラフのボタン。押すと type を bar に。選ばれていれば on の見た目、違えば枠線だけ。
15<button onClick={() => setType("pie")} className={`${btn} ${type === "pie" ? on : "border"}`}>円グラフ</button>円グラフのボタン。同じ仕組み。
16</div><div> を閉じる。
17<div key={type} className="chart-in">グラフの入れ物。key が変わると React は中身を作り直すので、切り替えるたびに chart-in のアニメーションと、グラフ自体の描画アニメーションが最初から動く。
18{type === "bar" ? <BarResult data={data} /> : <PieResult data={data} />}type が bar なら棒グラフ、それ以外は円グラフ。
19</div><div> を閉じる。
20</div><div> を閉じる。
21);上の行で開いたカッコを閉じる。
22}上の行で開いたカッコを閉じる。
追記layout.tsx で import している CSS の末尾(globals.css など)
@keyframes chart-in {
  from { opacity: 0; transform: scale(0.95); }
  to   { opacity: 1; transform: scale(1); }
}
.chart-in {
  animation: chart-in 0.4s ease-out;
}
1行ずつ解説
1@keyframes chart-in {アニメーションの動きに chart-in という名前を付けて定義する。
2from { opacity: 0; transform: scale(0.95); }始まり:透明で少し小さい。
3to { opacity: 1; transform: scale(1); }終わり:はっきり見えて元の大きさ。
4}上の行で開いたカッコを閉じる。
5.chart-in {このクラスを付けた要素に…
6animation: chart-in 0.4s ease-out;上の動きを0.4秒かけて、最後がゆっくりになるように動かす。
7}上の行で開いたカッコを閉じる。
書き換え集計画面:今のグラフの行を置き換える
<ChartSwitch data={question.data} />
1行ずつ解説
1<ChartSwitch data={question.data} />今のグラフに渡している data を、そのまま渡す。

それでも動かないとき

こうなった原因と直し方
円グラフが真っ白・何も出ないdataKey / nameKey の文字がデータの項目名と違う。既存のグラフの dataKey をそのままコピーする。Chart.js なら ChartJS.register(ArcElement…) の行が無い。
Module not found: Can't resolve '@/components/bar-result'import の場所が違う。作ったファイルの場所に合わせる。既存の部品を使うなら、その部品を import している他のファイルの import 行をコピーする。
切り替えてもアニメーションしないCSS を足したファイルが layout.tsx で import されているか。key={type} を付け忘れていないか。
Unexpected any の警告lint の警告で動作には関係ない。気になるなら any を既存のグラフの data の型に変える(マウスを乗せると型がわかる)。

自分で調べるなら(AIは使わずWeb検索で)

知りたいこと見る場所検索ワード
Recharts の円・棒Recharts公式「PieChart」「BarChart」recharts PieChart example
Chart.js の登録Chart.js公式「Integration」chart.js register ArcElement react-chartjs-2
CSS のアニメーションMDN「@keyframes」「animation」MDN keyframes
key で作り直すReact公式「key で state をリセット」react key reset component
shadcn/ui のグラフshadcn/ui公式「Chart」shadcn chart pie

課題 18

質問項目の削除(編集モード)

サイトの編集モードで、設問ごとの ❌ ボタンを押したらその設問が消えるようにする(最後の1問は消さない)。

わかっていること

  • 編集モードで、設問を追加したあと ❌(削除ボタン)が出る。
  • ❌ を押しても反応しない。

まず確かめること:「最初からあった設問」の ❌ と「追加した設問」の ❌ のどちらが反応しないか。追加した設問だけなら、ほぼ「新しい設問に id が無い(まだ DB に無い)」のが原因。両方なら、onClick がつながっていないかクリックが届いていない。編集モードだけなら、作成画面と別の部品・別の配列を使っている。

確認①

どの ❌ が反応しない?(原因をしぼる)

見る場所編集モードで、(1) 最初からあった設問の ❌、(2) 追加した設問の ❌ を順に押してみる。作成画面にも ❌ があれば、そちらも押してみる。

もし追加した設問の ❌ だけ反応しない(最初からの設問は消える)

新しい設問の id の問題。確認④へ。

もしどの ❌ も反応しない

onClick かクリックの問題。確認②→③へ。

もし作成画面では消えるのに、編集画面では消えない

編集画面が別の部品・別の配列・別の条件を使っている。確認⑤へ。

もし一瞬消えて、すぐ戻る

画面の外からデータを入れ直している。確認⑤の「すぐ戻る」の行。

もし画面では消えるが、保存して開き直すと戻る

保存の処理が「消した設問」を DB から消していない。確認⑤の最後の行。

確認②

❌ ボタンのタグと onClick を見る

見る場所全体検索で順に:×・❌・<X・XIcon・Trash・aria-label="削除"。見つかったタグと、それを囲んでいる <button / <Button を見る。

もし<X className="…" />(アイコン)だけで、onClick もボタンも無い

見た目だけ。ボタンで囲んで onClick を付ける。

書き換え❌ アイコンの所
<button type="button" onClick={() => removeQuestion(q)} aria-label="削除" className="今のclassName">
  <X className="今のアイコンのclassName" />
</button>
1行ずつ解説
1<button type="button" onClick={() => removeQuestion(q)} aria-label="削除" className="今のclassName">ボタンで囲む。type="button" で form の送信にならない。removeQuestion(q) は設問そのものを渡す(確認④の関数)。q は設問の map の引数名に合わせる(question など)。
2<X className="今のアイコンのclassName" />今のアイコンを中に入れる。
3</button>閉じる。

もしボタンに onClick が無い/onClick={() => {}}/中身が console.log だけ

onClick={() => removeQuestion(q)} を付け、確認④の関数を作る。

もしonClick={removeQuestion(q.id)}(() => が無い)

表示した瞬間に動いてしまい、押したときには動かない。onClick={() => removeQuestion(q.id)} にする。

もしonClick={removeQuestion} と関数名だけで、関数は (id) => を受け取る

id の代わりにクリックの情報が入り、何も消えない。() => removeQuestion(q.id) にする。

もしonClick={() => onDelete?.(…)}・props.onRemove(…)(親から受け取る関数)

編集画面の親が onDelete を渡していないと、?. のせいでエラーも出ず何も起きない。部品名(<QuestionItem など)で全体検索し、編集画面で使っている行に onDelete があるか見る(作成画面の行にはあって、編集画面の行に無いことが多い)。

書き換え編集画面のファイル:設問の部品を置いている行
<QuestionItem 今の属性 onDelete={() => removeQuestion(q)} />
1行ずつ解説
1<QuestionItem 今の属性 onDelete={() => removeQuestion(q)} />子が呼ぶ名前(onDelete)で、編集画面の削除の関数を渡す。removeQuestion は編集画面の部品の中に作る(確認④)。

もしdisabled={isEdit}・disabled={!canDelete} など、条件で押せなくしている

編集モードでは押せない設定になっている。条件を disabled={questions.length <= 1}(1問のときだけ押せない)に変える。

もし❌ が <AlertDialogTrigger・<DialogTrigger の中(押すと確認画面が出る作り)

確認画面が出ないなら、Trigger に asChild があるか・中のボタンが自作 Button なら ref と props を渡しているか(共通診断 の自作 Button)。確認画面は出るのに消えないなら、「削除する」ボタン(<AlertDialogAction)の onClick を見る。

もしonClick は正しく見える

確認③へ。

確認③

押したとき、関数は動いている?

見る場所削除の関数の一番上に1行足す。F12 → Console を開いたまま ❌ を押す。

もしどの場合も

確認用・後で消す削除の関数の中の一番上
console.log("削除", target, questions);
1行ずつ解説
1console.log("削除", target, questions);押されたら、渡された設問と今の設問の配列が出る。確認が終わったら消す。

もし何も出ない+ ❌ がカードの右上にある(absolute が付いている)

入力欄やカードの中身が ❌ の上にかぶさっていることが多い。F12 の左上の矢印で ❌ をクリックし、選ばれたのが button か見る。違えば ❌ のボタンの className に z-10 を足す(親に relative が必要)。

もし何も出ない+ 設問がドラッグで並べ替えできる

ドラッグの仕組みが押した瞬間を取っている。19 の確認③(listeners はハンドルだけ)。

もし何も出ない(それ以外)

共通診断の確認③ を上から見る。form の中で type が無い(押すたびに読み直される)も多い。

もし出る。渡された設問の id: undefined

追加した設問に id が無い。確認④へ。

もし出る。Console に赤いエラー(404・500・Failed to fetch)も出る

削除の関数が API に DELETE を送っていて失敗している(まだ DB に無い新しい設問を消そうとしている)。確認④の「API で消している」。

確認④

関数は動くのに消えない(追加した設問で多い)

見る場所削除の関数の中身と、設問を追加している行(「設問を追加」ボタンの関数)。

もしfilter((q) => q.id !== id) で消していて、新しい設問の id が undefined

設問そのもので比べる形にすると、id が無くても消せる(一番確実)。

書き換え削除の関数(まるごと置き換え)
function removeQuestion(target: any) {
  if (questions.length <= 1) return;
  if (!window.confirm("この設問を削除しますか?")) return;
  setQuestions((prev) => prev.filter((q) => q !== target));
}
1行ずつ解説
1function removeQuestion(target: any) {削除の関数。id ではなく設問そのもの(オブジェクト)を受け取る。こうすると、追加したばかりで id が無い設問でも消せる。
2if (questions.length <= 1) return;設問が1つしかないときは消さない。
3if (!window.confirm("この設問を削除しますか?")) return;確認を出し、キャンセルなら終わり。
4setQuestions((prev) => prev.filter((q) => q !== target));prev は set する直前の最新の配列。q !== target=「押された設問そのもの」以外を残す。オブジェクトの !== は「同じ物かどうか」を比べるので、id が無くても・重なっていても正しく1つだけ消える。
5}関数を閉じる。
書き換え❌ ボタンの onClick
onClick={() => removeQuestion(q)}
1行ずつ解説
1onClick={() => removeQuestion(q)}q.id ではなく q(設問そのもの)を渡す。

もしid で消す作りのままにしたい

設問を追加する所で id を付ける。

書き換え設問を追加している行の新しい設問の { } の中
id: crypto.randomUUID(),
1行ずつ解説
1id: crypto.randomUUID(),重ならない ID を作る。DB の id が数字の作りなら、tempId: crypto.randomUUID() のように別の名前にして、削除は tempId か id で比べる(保存時に DB の id と混ざらないように)。

もし削除の関数が await fetch(`/api/questions/${id}`, { method: "DELETE" }) のように API で消している

新しい設問はまだ DB に無いので失敗する。DB にある設問だけ API で消し、画面からは必ず消す。

書き換え削除の関数(API 版)
async function removeQuestion(target: any) {
  if (questions.length <= 1) return;
  if (!window.confirm("この設問を削除しますか?")) return;
  if (target.id) {
    await fetch(`/api/questions/${target.id}`, { method: "DELETE" });
  }
  setQuestions((prev) => prev.filter((q) => q !== target));
}
1行ずつ解説
1async function removeQuestion(target: any) {async:中で await を使う印。
2if (questions.length <= 1) return;1問なら消さない。
3if (!window.confirm("この設問を削除しますか?")) return;確認。
4if (target.id) {DB の id がある設問(最初からあった設問)だけ…
5await fetch(`/api/questions/${target.id}`, { method: "DELETE" });今の API の書き方のまま DELETE を送る(URL は今のコードに合わせる)。
6}if を閉じる。
7setQuestions((prev) => prev.filter((q) => q !== target));画面からは、どちらの設問も消す。
8}閉じる。

もしquestions.splice(i, 1) してから setQuestions(questions)

同じ配列を set しても描き直されない。上の filter の形にする。

もし最初からの設問と追加した設問を別々の配列で持っている(questions と newQuestions など)

片方からしか消していない。両方から消す。

書き換え削除の関数の set の行
setQuestions((prev) => prev.filter((q) => q !== target));
setNewQuestions((prev) => prev.filter((q) => q !== target));
1行ずつ解説
1setQuestions((prev) => prev.filter((q) => q !== target));最初からの設問の配列から消す(入っていなければ何も変わらない)。
2setNewQuestions((prev) => prev.filter((q) => q !== target));追加した設問の配列からも消す。

もしid の型が違う(最初からは数字 3、渡したのは文字 "3")

設問そのもので比べる形(1つ目)にするか、String(q.id) !== String(id) で比べる。

確認⑤

編集モードだけの原因

見る場所編集画面のファイル(URL が /surveys/[id]/edit なら app/surveys/[id]/edit/page.tsx → そこから部品を F12 でたどる)。

もし{isEditing ? <EditQuestion … /> : <QuestionCard … />} のように、編集モードで別の部品を使っている

❌ があるのは編集用の部品。その部品と、それを置いている行に onClick / onDelete があるか(確認②)。作成画面のほうを直しても編集モードは変わらない。

もし削除の関数の最初に if (mode !== "create") return; のような条件がある

編集モードでは何もせずに終わっている。その行を消す(または "edit" も通す)。

もし一瞬消えてすぐ戻る+ useEffect(() => { setQuestions(data.questions) }, [data]) がある

取ってきたデータ(data)が変わるたびに設問を入れ直している(SWR や React Query は画面に戻るたびに取り直す)。最初の1回だけ入れる。

書き換えその useEffect
const loaded = useRef(false);
useEffect(() => {
  if (!data || loaded.current) return;
  setQuestions(data.questions);
  loaded.current = true;
}, [data]);
1行ずつ解説
1const loaded = useRef(false);「もう入れたか」を覚える印(useRef は描き直しても値が残り、変えても描き直さない)。useRef を react から import。
2useEffect(() => {data が変わったら動く。
3if (!data || loaded.current) return;データがまだ無い、またはもう入れたなら何もしない。
4setQuestions(data.questions);最初の1回だけ設問を入れる。
5loaded.current = true;入れた印を付ける。
6}, [data]);閉じる。

もし一瞬消えてすぐ戻る+ react-hook-form の reset(data) が useEffect の中にある

同じ理由。上と同じく loaded の印で最初の1回だけ reset する。

もし画面では消えるが、保存して開き直すと戻る

保存の API が「今ある設問を上書き・追加」だけして、消した設問を DB から消していない。保存の API で、送られてこなかった設問を消す。

追記保存の API(Prisma の例):設問を保存する処理の前
await prisma.question.deleteMany({
  where: { surveyId: surveyId, id: { notIn: questions.filter((q) => q.id).map((q) => q.id) } },
});
1行ずつ解説
1await prisma.question.deleteMany({このアンケートの設問を消す…
2where: { surveyId: surveyId, id: { notIn: questions.filter((q) => q.id).map((q) => q.id) } },ただし「送られてきた設問の id の一覧」に入っていないものだけ(notIn)。id が無い新しい設問は一覧から外す。
3});閉じる。
確認⑥

設問の持ち方ごとの削除の関数(参考)

見る場所作成・編集画面のファイルで Ctrl+F:useState(・useFieldArray・useReducer、または store フォルダの create(。

もしuseState

確認④の「設問そのもので比べる」形が一番確実。

もしuseFieldArray(react-hook-form)

すでに remove がある。番号で消すので id が無くても消せる。

書き換えuseFieldArray の行
const { fields, append, remove } = useFieldArray({ control, name: "questions" });
1行ずつ解説
1const { fields, append, remove } = useFieldArray({ control, name: "questions" });{ } の中に remove を足す。❌ は onClick={() => remove(i)}(i は fields.map((field, i) => の番号)。

もしZustand の store

store に削除の関数を足す(または今ある remove / delete を使う)。

追記store の create((set) => ({ … })) の中
removeQuestion: (target: any) =>
  set((state) => ({ questions: state.questions.filter((q) => q !== target) })),
1行ずつ解説
1removeQuestion: (target: any) =>設問そのものを受け取る削除の関数(型の定義があれば removeQuestion: (target: Question) => void; も)。
2set((state) => ({ questions: state.questions.filter((q) => q !== target) })),今の中身から、その設問以外を残す。

もしuseReducer

reducer に case "REMOVE": return { ...state, questions: state.questions.filter((q) => q !== action.target) };、❌ は dispatch({ type: "REMOVE", target: q })。

もし親から props(questions と onChange)

onChange(questions.filter((q) => q !== target))。

確認⑦

削除したら、下の設問の入力内容がずれる

見る場所設問の map の一番外側のタグの key。

もしkey={i}(番号)

id があれば key={q.id}、useFieldArray なら key={field.id}。追加した設問に id が無ければ確認④で id を付ける。

もしkey={q.id} だが、追加した設問は id が undefined

key が重なって、消した後に入力欄が取り違えられる。確認④で id(tempId)を付ける。

それでも動かないとき

こうなった原因と直し方
押すとページが再読み込みされるform の中のボタンに type="button" が無い。
Too many re-rendersonClick={removeQuestion(q.id)} の形(確認②)。
追加した設問を消すと、ほかの追加した設問も全部消えるid が全部 undefined で、q.id !== id が全部 false になっている。設問そのもので比べる(確認④)。
違う設問が消える渡している番号が別の map の番号(選択肢の map の j など)。設問の map の q か i を渡す。
Console に 404 / 500API で DB に無い設問を消そうとしている(確認④の API 版)。
Warning: Encountered two children with the same key追加した設問の key(id)が重なっている。id を付ける(確認④)。

自分で調べるなら(AIは使わずWeb検索で)

知りたいこと見る場所検索ワード
配列の state から削除React公式「state 内の配列の更新」react 配列 削除 filter state
filter と !==MDN「Array.prototype.filter()」「厳密不等価」MDN filter
set((prev) => …) の書き方React公式「useState」の「前の state に基づいて更新」react setState prev 関数
重ならない ID を作るMDN「Crypto.randomUUID()」MDN randomUUID
fetch で DELETEMDN「フェッチ API の使用」fetch method DELETE
useRef(描き直しても残る印)React公式「useRef」react useRef useEffect 1回だけ
Prisma で残りを消すPrisma公式prisma deleteMany notIn
useFieldArray の removereact-hook-form公式「useFieldArray」react-hook-form useFieldArray remove

課題 19

質問項目の並び替え(つかんでドラッグ)

作成画面で、設問のつかむボタン(ハンドル)を押しながらドラッグして、上下を入れ替えられるようにする。

わかっていること

  • 各設問に、つかむためのボタン(⋮⋮ や ≡ のアイコン)はすでにある。
  • 押しながらドラッグで上下を入れ替える方式。

流れ:確認①で何のライブラリが入っているかを見る → 確認②で「並べ替える関数(move)」を用意 → 使っているライブラリの確認(③〜⑥のどれか1つ)で、今あるハンドルのボタンに「つかむ仕組み」をつなぐ。

確認①

ドラッグの仕組みは何が入っている?

見る場所package.json の dependencies。ハンドルのボタンのファイルでも Ctrl+F:useSortable・Reorder・Draggable・draggable。

もし@dnd-kit/core・@dnd-kit/sortable がある

確認② → 確認③。

もしframer-motion または motion がある

確認② → 確認④(Reorder)。

もし@hello-pangea/dnd または react-beautiful-dnd がある

確認② → 確認⑤。

もしどれも無い+ npm install してよい

ターミナルで npm i @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities → 確認② → 確認③。

もしどれも無い+ install できない(してはいけない)

確認② → 確認⑥(ライブラリ無しの HTML の draggable)。

もしハンドルのファイルにすでに useSortable や DndContext が途中まで書いてある

作り直さない。確認③の最後の「チェック表」で、足りないものだけ足す。

もしハンドルのボタンに draggable や onDragStart が書いてある

HTML の draggable で作りかけ。確認⑥と見比べて足りないものを足す。

確認②

並べ替える関数(move)を用意する(設問の持ち方ごと)

見る場所作成画面のファイルで Ctrl+F:useState(・useFieldArray・useReducer、または store フォルダの create(。

もしconst [questions, setQuestions] = useState(…)

追記設問の配列がある部品の中
function move(from: number, to: number) {
  if (to < 0 || to >= questions.length) return;
  const next = [...questions];
  const [item] = next.splice(from, 1);
  next.splice(to, 0, item);
  setQuestions(next);
}
1行ずつ解説
1function move(from: number, to: number) {from 番目の設問を to 番目へ動かす関数(どのライブラリでも最後はこれを呼ぶ)。
2if (to < 0 || to >= questions.length) return;動かし先が範囲の外なら何もしない。
3const next = [...questions];配列をコピー([...配列])。元を直接いじらない。
4const [item] = next.splice(from, 1);splice(from, 1) で from 番目を1つ取り出す。戻りは配列なので [item] で中身を受け取る。
5next.splice(to, 0, item);splice(to, 0, item) で to 番目に差し込む(真ん中の 0 は「何も消さない」)。
6setQuestions(next);新しい並びで置き換える。
7}関数を閉じる。

もしuseFieldArray(react-hook-form)

すでに move がある。

書き換えuseFieldArray の行
const { fields, append, remove, move } = useFieldArray({ control, name: "questions" });
1行ずつ解説
1const { fields, append, remove, move } = useFieldArray({ control, name: "questions" });{ } の中に move を足す。以下のコードの questions は fields、q.id は field.id(useFieldArray が付ける ID)に読み替える。

もしZustand の store

store のファイルで move・reorder・swap を検索。あればそれを使う。無ければ足す。

追記store の create((set) => ({ … })) の中
move: (from: number, to: number) =>
  set((state) => {
    const next = [...state.questions];
    const [item] = next.splice(from, 1);
    next.splice(to, 0, item);
    return { questions: next };
  }),
1行ずつ解説
1move: (from: number, to: number) =>並べ替えの関数を store に足す(型の定義があれば move: (from: number, to: number) => void; も)。
2set((state) => {store の中身を書き換える。
3const next = [...state.questions];配列をコピー。
4const [item] = next.splice(from, 1);from 番目を取り出す。
5next.splice(to, 0, item);to 番目に差し込む。
6return { questions: next };新しい並びを返す。
7}),閉じる。
追記ハンドルがある部品の中
const move = useBuilderStore((s) => s.move);
1行ずつ解説
1const move = useBuilderStore((s) => s.move);store から move を取り出す。

もしuseReducer

reducer に case "MOVE": を足し、中身は useState 版の3〜5行目と同じ(最後は return { ...state, questions: next };)。呼ぶときは dispatch({ type: "MOVE", from, to })。

もし親から props で受け取っている

useState 版の6行目を onChange(next) に変える(親の set に新しい並びを渡す)。

もし設問に id が無い

dnd-kit・Reorder・pangea は 1件ずつの ID が必須。追加している行の新しい設問に id: crypto.randomUUID() を足す(18 の確認④)。useFieldArray なら field.id が最初からある。

確認③

dnd-kit の場合

見る場所ハンドルのボタンがある、設問を map しているファイル。

もしどの場合も(3か所に足す)

①import、②1件分の入れ物(ファイルの一番下)、③離したときの関数と map の包み方。

追記① ファイルの先頭の import の並び
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
1行ずつ解説
1import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";DndContext=ドラッグできる範囲、closestCenter=どこに落としたかの判定方法、DragEndEvent=ドラッグが終わったときの情報の型。
2import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";SortableContext=並べ替える一覧、useSortable=1件をつかめるようにする道具、verticalListSortingStrategy=縦に並んだ一覧用の動き方。
3import { CSS } from "@dnd-kit/utilities";CSS=動いている位置を style の文字にする道具。
追記② ファイルの一番下(今の部品の外)
function SortableRow({ id, children }: { id: string | number; children: (handle: any) => React.ReactNode }) {
  const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({ id });
  const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.6 : 1 };
  return (
    <div ref={setNodeRef} style={style}>
      {children({ ref: setActivatorNodeRef, ...attributes, ...listeners })}
    </div>
  );
}
1行ずつ解説
1function SortableRow({ id, children }: { id: string | number; children: (handle: any) => React.ReactNode }) {1件分の「つかめる入れ物」の部品。id はその設問の ID。children は「ハンドル用の印を受け取って、中身を返す関数」(下の map で書く)。
2const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({ id });useSortable({ id }) から、並べ替えに必要なものを取り出す。listeners=押してドラッグを始める仕組み、setActivatorNodeRef=つかむ場所(ハンドル)の印、setNodeRef=動く箱の印、transformtransition=ドラッグ中の位置と動き。
3const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.6 : 1 };ドラッグ中の位置を style にする。つかんでいる間は少し薄く(0.6)。
4return (画面を返す。
5<div ref={setNodeRef} style={style}>この div が動く箱。ref={setNodeRef} で「これが1件」と教える。
6{children({ ref: setActivatorNodeRef, ...attributes, ...listeners })}中身を作る関数に、ハンドル用の印(ref・attributes・listeners)をまとめて渡す。... で中身を広げて1つの { } にしている。
7</div>div を閉じる。
8);カッコを閉じる。
9}関数を閉じる。
追記③-1 設問の配列がある部品の中(move の下)
function handleDragEnd(e: DragEndEvent) {
  const { active, over } = e;
  if (!over || active.id === over.id) return;
  const from = questions.findIndex((q) => q.id === active.id);
  const to = questions.findIndex((q) => q.id === over.id);
  move(from, to);
}
1行ずつ解説
1function handleDragEnd(e: DragEndEvent) {ドラッグを離したときに動く関数。e にどれをどこへ動かしたかが入っている。
2const { active, over } = e;active=つかんだ設問、over=離した場所にあった設問。
3if (!over || active.id === over.id) return;一覧の外で離した、または同じ場所なら何もしない。
4const from = questions.findIndex((q) => q.id === active.id);findIndex で、つかんだ設問が今何番目かを探す。
5const to = questions.findIndex((q) => q.id === over.id);離した場所の設問が何番目か。
6move(from, to);確認②の move で並べ替える(useFieldArray なら move(from, to)、store なら store の move)。
7}関数を閉じる。
書き換え③-2 今の設問の map を包む
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
  <SortableContext items={questions.map((q) => q.id)} strategy={verticalListSortingStrategy}>
    {questions.map((q, i) => (
      <SortableRow key={q.id} id={q.id}>
        {(handle) => (
          <div className="今の設問カードのclassName">
            <button type="button" {...handle} className="cursor-grab touch-none 今のclassName">今のハンドルの中身</button>
            今のカードの中身(ハンドル以外)
          </div>
        )}
      </SortableRow>
    ))}
  </SortableContext>
</DndContext>
1行ずつ解説
1<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>ドラッグできる範囲。離したら handleDragEnd が動く。
2<SortableContext items={questions.map((q) => q.id)} strategy={verticalListSortingStrategy}>並べ替える一覧。items には ID だけの配列 を渡す(下の id={q.id} と同じ値)。
3{questions.map((q, i) => (今の設問の map(そのまま)。
4<SortableRow key={q.id} id={q.id}>今のカードを SortableRow で包む。key と id に同じ設問 ID。
5{(handle) => ((handle) => (…):SortableRow から「ハンドル用の印」を handle という名前で受け取る。
6<div className="今の設問カードのclassName">今の設問カードの一番外側のタグ(そのまま)。
7<button type="button" {...handle} className="cursor-grab touch-none 今のclassName">今のハンドルの中身</button>今あるハンドルのボタンに {...handle} を足す。これで「このボタンを押しながらドラッグ」になる。touch-none はスマホで画面スクロールと取り合わないため。cursor-grab はつかむ形のカーソル。
8今のカードの中身(ハンドル以外)カードの残りの中身(質問文・選択肢・削除ボタンなど)は今のまま。
9</div>カードを閉じる。
10)}関数を閉じる。
11</SortableRow>SortableRow を閉じる。
12))}map を閉じる。
13</SortableContext>SortableContext を閉じる。
14</DndContext>DndContext を閉じる。

もし設問の map とハンドルのボタンが別のファイル(<QuestionCard> の中にハンドルがある)

map のファイルでは {(handle) => <QuestionCard 今の属性 handleProps={handle} />} と渡し、QuestionCard の中のハンドルのボタンに {...props.handleProps} を付ける(受け取る側の引数に handleProps を足す)。

もしハンドルではなく、カード全体をつかめるようにしたい

{...handle} をカードの div に付ける。ただし中の入力欄や削除ボタンが押しにくくなるので、DndContext に sensors で「5px 動いたらドラッグ開始」を付ける(dnd-kit 公式の「Sensors」→ activationConstraint)。課題は「ボタンを押しながら」なのでハンドルのままがよい。

もしすでに途中まで書いてある(チェック表)

上から順に見て、無いものを足す。(1) DndContext に onDragEnd がある? (2) SortableContext の items と、useSortable の id が同じ値?(items にオブジェクトを渡していないか) (3) 動く箱に ref={setNodeRef} と style={{ transform: CSS.Transform.toString(transform), transition }} がある? (4) listeners がハンドルに付いている?(付いていないとつかめない) (5) onDragEnd の中で set(move)している?(していないと、離すと元に戻る)

確認④

framer-motion / motion(Reorder)の場合

見る場所ハンドルのボタンがある、設問を map しているファイル。

もしどの場合も(3か所)

Reorder は「押しながら」専用の仕組み(useDragControls)がある。

追記① ファイルの先頭
import { Reorder, useDragControls } from "framer-motion";
1行ずつ解説
1import { Reorder, useDragControls } from "framer-motion";package.json が motion なら "motion/react" にする。
追記② ファイルの一番下(今の部品の外)
function ReorderRow({ item, children }: { item: any; children: (startDrag: (e: React.PointerEvent) => void) => React.ReactNode }) {
  const controls = useDragControls();
  return (
    <Reorder.Item as="div" value={item} dragListener={false} dragControls={controls}>
      {children((e) => controls.start(e))}
    </Reorder.Item>
  );
}
1行ずつ解説
1function ReorderRow({ item, children }: { item: any; children: (startDrag: (e: React.PointerEvent) => void) => React.ReactNode }) {1件分の入れ物。children は「ドラッグを始める関数」を受け取って中身を返す関数。
2const controls = useDragControls();ドラッグを始める道具。1件ごとに1つ必要なので、別の部品に分けている。
3return (画面を返す。
4<Reorder.Item as="div" value={item} dragListener={false} dragControls={controls}>dragListener={false}=カードのどこでもつかめる、をやめる。dragControls=この道具で始めたときだけ動く。as="div" で li ではなく div にする。
5{children((e) => controls.start(e))}中身を作る関数に「押されたらドラッグ開始」の関数を渡す。
6</Reorder.Item>閉じる。
7);カッコを閉じる。
8}関数を閉じる。
書き換え③ 今の設問の map を包む
<Reorder.Group as="div" axis="y" values={questions} onReorder={setQuestions}>
  {questions.map((q) => (
    <ReorderRow key={q.id} item={q}>
      {(startDrag) => (
        <div className="今の設問カードのclassName">
          <button type="button" onPointerDown={startDrag} className="cursor-grab touch-none">今のハンドルの中身</button>
          今のカードの中身(ハンドル以外)
        </div>
      )}
    </ReorderRow>
  ))}
</Reorder.Group>
1行ずつ解説
1<Reorder.Group as="div" axis="y" values={questions} onReorder={setQuestions}>並べ替える一覧。axis="y"=縦。values に設問の配列、onReorder に「新しい並びを受け取る関数」(set をそのまま渡せる)。Reorder は move が要らない。
2{questions.map((q) => (今の map。
3<ReorderRow key={q.id} item={q}>item={q}:values の中の同じものを渡す(コピーを渡すと動かない)。
4{(startDrag) => (ドラッグを始める関数を startDrag という名前で受け取る。
5<div className="今の設問カードのclassName">今のカード。
6<button type="button" onPointerDown={startDrag} className="cursor-grab touch-none">今のハンドルの中身</button>今あるハンドルに onPointerDown={startDrag} を足す=押した瞬間にドラッグ開始。
7今のカードの中身(ハンドル以外)残りは今のまま。
8</div>カードを閉じる。
9)}関数を閉じる。
10</ReorderRow>閉じる。
11))}map を閉じる。
12</Reorder.Group>Group を閉じる。

もしstore(Zustand)で set が無い

onReorder={(next) => setQuestions(next)} の setQuestions を store に足す:setQuestions: (q) => set({ questions: q })。

確認⑤

@hello-pangea/dnd(react-beautiful-dnd)の場合

見る場所設問を map しているファイル。

もしどの場合も

ハンドルは dragHandleProps を付けたところになる。

追記ファイルの先頭
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
1行ずつ解説
1import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";package.json が react-beautiful-dnd ならその名前にする。
書き換え今の設問の map を包む
<DragDropContext onDragEnd={(r) => { if (r.destination) move(r.source.index, r.destination.index); }}>
  <Droppable droppableId="questions">
    {(provided) => (
      <div ref={provided.innerRef} {...provided.droppableProps}>
        {questions.map((q, i) => (
          <Draggable key={q.id} draggableId={String(q.id)} index={i}>
            {(p) => (
              <div ref={p.innerRef} {...p.draggableProps} className="今のclassName">
                <button type="button" {...p.dragHandleProps}>今のハンドルの中身</button>
                今のカードの中身
              </div>
            )}
          </Draggable>
        ))}
        {provided.placeholder}
      </div>
    )}
  </Droppable>
</DragDropContext>
1行ずつ解説
1<DragDropContext onDragEnd={(r) => { if (r.destination) move(r.source.index, r.destination.index); }}>離したら、元の番号(source.index)から先の番号(destination.index)へ move。一覧の外で離すと destination が無いので何もしない。
2<Droppable droppableId="questions">落とせる範囲。名前は自由。
3{(provided) => (範囲の道具を provided で受け取る。
4<div ref={provided.innerRef} {...provided.droppableProps}>範囲の箱に印を付ける。
5{questions.map((q, i) => (今の map(番号 i を使う)。
6<Draggable key={q.id} draggableId={String(q.id)} index={i}>1件分。draggableId は文字でないといけないので String()。
7{(p) => (1件の道具を p で受け取る。
8<div ref={p.innerRef} {...p.draggableProps} className="今のclassName">動く箱に印を付ける。
9<button type="button" {...p.dragHandleProps}>今のハンドルの中身</button>今あるハンドルに {...p.dragHandleProps}=ここを押しながらドラッグ。
10今のカードの中身残りは今のまま。
11</div>閉じる。
12)}閉じる。
13</Draggable>閉じる。
14))}map を閉じる。
15{provided.placeholder}ドラッグ中にすき間を空ける印(必須)。
16</div>閉じる。
17)}閉じる。
18</Droppable>閉じる。
19</DragDropContext>閉じる。

もしreact-beautiful-dnd で「Unable to find draggable with id」が出る

React 18 の開発モードで起きやすい古いライブラリの問題。@hello-pangea/dnd(同じ書き方の後継)に入れ替えられるなら入れ替える。

確認⑥

ライブラリ無し(HTML の draggable)の場合

見る場所設問を map しているファイル。

もしどの場合も

「ハンドルを押している間だけ、カードをドラッグできる」ようにする。スマホ(タッチ)では動かないので、スマホも必要なら dnd-kit。

追記部品の中(move の下)
const [grab, setGrab] = useState<number | null>(null);
const [dragFrom, setDragFrom] = useState<number | null>(null);
1行ずつ解説
1const [grab, setGrab] = useState<number | null>(null);今ハンドルを押している設問の番号(押していなければ null)。<number | null> は「数字か null」という型。
2const [dragFrom, setDragFrom] = useState<number | null>(null);ドラッグを始めた設問の番号。
書き換え設問カードの一番外側のタグとハンドル
<div
  key={q.id}
  draggable={grab === i}
  onDragStart={(e) => { e.dataTransfer.setData("text/plain", String(i)); setDragFrom(i); }}
  onDragOver={(e) => e.preventDefault()}
  onDrop={() => { if (dragFrom !== null) move(dragFrom, i); }}
  onDragEnd={() => { setDragFrom(null); setGrab(null); }}
  className={`今のclassName ${dragFrom === i ? "opacity-50" : ""}`}
>
  <button type="button" onMouseDown={() => setGrab(i)} onMouseUp={() => setGrab(null)} className="cursor-grab">今のハンドルの中身</button>
1行ずつ解説
1<div今の設問カードの一番外側のタグ(タグ名は今のまま)。
2key={q.id}key は今のまま。
3draggable={grab === i}ハンドルを押しているカードだけ draggable(つかめる)にする。
4onDragStart={(e) => { e.dataTransfer.setData("text/plain", String(i)); setDragFrom(i); }}ドラッグ開始:番号を覚える。setData は Firefox で必要な決まり。
5onDragOver={(e) => e.preventDefault()}preventDefault() で「ここに落としてよい」にする(無いと drop が起きない)。
6onDrop={() => { if (dragFrom !== null) move(dragFrom, i); }}落としたら、始めた番号からこの番号へ move。
7onDragEnd={() => { setDragFrom(null); setGrab(null); }}ドラッグが終わったら全部元に戻す。
8className={`今のclassName ${dragFrom === i ? "opacity-50" : ""}`}ドラッグ中のカードを薄くする。
9>タグを閉じる(中身は今のまま続く)。
10<button type="button" onMouseDown={() => setGrab(i)} onMouseUp={() => setGrab(null)} className="cursor-grab">今のハンドルの中身</button>今あるハンドル:押したら grab をこの番号に、離したら null に。
確認⑦

並びは変わるのに、保存すると元の順番に戻る

見る場所保存の関数で、設問を送っている部分。

もし設問に order・position・sortOrder のような順番の項目がある

保存の直前に、今の配列の順で番号を付け直す。

追記保存の関数の中(送る前)
const ordered = questions.map((q, i) => ({ ...q, order: i + 1 }));
1行ずつ解説
1const ordered = questions.map((q, i) => ({ ...q, order: i + 1 }));今の並びの順で 1,2,3… を付け直した新しい配列。{ ...q, order: i + 1 } は「q をコピーして order だけ上書き」。送る配列を questions から ordered に変える。

もし順番の項目が無い

配列の順のまま送っていれば保存される。戻るなら、保存後の読み込みで id 順などに並べ直している(全体検索 orderBy・.sort()。

それでも動かないとき

こうなった原因と直し方
ハンドルを押しても何も動かないlisteners(dnd-kit)・onPointerDown(Reorder)・dragHandleProps(pangea)がハンドルに付いていない。ハンドルのタグにあるか見る。自作の Button なら props を渡しているか(共通診断)。
ドラッグはできるが、離すと元に戻る離したときの関数で set(move)していない、または move が配列をコピーしていない(確認②)。
つかむと違う設問が動く/変な位置に飛ぶitems(ID の配列)と、1件ごとの id が合っていない。または key が番号(key={i})。両方 q.id に。
中の入力欄や削除ボタンが押せなくなったlisteners をカード全体に付けている。ハンドルだけに(確認③)。
スマホでドラッグすると画面がスクロールしてしまうハンドルに touch-none を付ける。
Cannot find module '@dnd-kit/…'入っていない。npm i をした後、npm run dev を起動し直す。
押すとページが再読み込みされるハンドルのボタンに type="button" が無い。

自分で調べるなら(AIは使わずWeb検索で)

知りたいこと見る場所検索ワード
dnd-kit の並べ替え・ハンドルdnd kit公式「Sortable」「Drag handle」dnd-kit sortable drag handle / useSortable setActivatorNodeRef
Motion の Reorder と「押しながら」Motion公式「Reorder」framer motion reorder drag handle useDragControls
@hello-pangea/dnd の使い方@hello-pangea/dnd(GitHub)(README・docs)hello-pangea dnd dragHandleProps
HTML のドラッグ&ドロップMDN「HTML ドラッグ&ドロップ API」MDN drag and drop dragover preventDefault
splice(取り出して差し込む)MDN「Array.prototype.splice()」MDN splice
findIndexMDN「Array.prototype.findIndex()」MDN findIndex
useFieldArray の movereact-hook-form公式「useFieldArray」react-hook-form useFieldArray move