ヒント集
クリア済みの課題(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 など)。わかったら消す。
当日の探し方
- 画面の文字で探すCtrl+Shift+F(Mac は Cmd+Shift+F)で全体検索。「削除」「保存」など画面に出ている文字で探すのが一番早い。
button onclickで出ない理由コードでは<Button(大文字の部品)で、onClickは別の行に書かれていることが多い。複数の単語をまとめて検索しても出ない。1語ずつ(onClickだけ、画面の文字だけ)で探し、見つかったファイルの中で Ctrl+F する。- 名前の正体を見る名前にマウスを乗せると型が出る(
{ id: number; title: string … }のように項目名がわかる)。F12(または Ctrl+クリック)で定義へ移動。 - ファイルをたどるpage.tsx に
<SurveyList … />のような部品しか無いときは、その部品名を Ctrl+クリックして中を開く。本体はだいたいcomponentsフォルダにある。 "use client"は新規ファイルの印ではないuseState・useTheme・onClick などを使うファイルの1行目に書く宣言。今あるファイルで使うなら、そのファイルの1行目に無ければ足すだけ。- console.log の出る場所
"use client"があるファイル → ブラウザ(F12 の Console)。無いファイル(page.tsx など)→ VS Code のターミナル(npm run dev を動かしている画面)。 - 入っているものを確かめる
package.jsonの dependencies(next の版、next-themes、recharts、react-hook-form、zustand など)。components/uiフォルダ(shadcn/ui の部品)。 - ボタンが反応しないときonClick があって関数も正しそうなら、まず関数の1行目に
console.log("押された")。出るか出ないかで原因が半分に絞れる(共通診断)。 - 新しいファイルはほとんど作らないボタン・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 ダークモード 実装」など。記事の日付と版を必ず確認 |
検索のコツ
- まず版を確かめるpackage.json で next・react・tailwindcss・next-themes などの版を見る。Next.js 13 以降の App Router と、古い Pages Router(pages フォルダ)は書き方が違う。Tailwind も v3 と v4 で設定が違う。検索に「app router」「tailwind v4」を足すと混ざらない。
- 公式を先に見るQiita・Zenn・ブログは読みやすいが、古い書き方も多い。公式の例(Examples)をまず見て、わからなかったら記事で補う。
- 英語で検索すると早い「next-themes setTheme not working」のように英語で書くと、Stack Overflow や GitHub の Issues に同じ困りごとが見つかりやすい。英語はブラウザの翻訳で読んでよい。
- エラー文は固有の部分を消すファイル名・行番号・自分の変数名を消して、残りを " " で囲んで検索する。例:
"Cannot read properties of undefined (reading 'forEach')"。 - 記事の日付を見る2022年以前の React / Next.js の記事は、今と書き方が違うことがある(
getServerSideProps・pages/・_app.tsxが出てきたら古い Pages Router)。 - 検索しても答えが出ないとき自分で console.log を入れて値を見るほうが早いことが多い(共通診断)。「どこで止まっているか」がわかると検索の言葉も決まる。
- 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)
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)
filter
条件が true のものだけ残した新しい配列。削除や検索に使う。元の配列は変わらない。
questions.filter((q) => q.id !== id)使う課題:13・18調べる:MDN filter(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行ずつ解説
| 1 | console.log("押された"); | 押したときにこの関数が動けば、Console に「押された」と出る。確認が終わったら消す。 |
もしonClick={() => setTheme(…)} のように中身が直接書いてある
{ } で囲んで1行足す。
onClick={() => { console.log("押された"); setTheme(今の中身); }}1行ずつ解説
| 1 | onClick={() => { console.log("押された"); setTheme(今の中身); }} | () => 1つの処理 を () => { 処理1; 処理2; } の形にすると、処理を2つ以上書ける。今の中身 は元のカッコの中をそのまま入れる。 |
もし押すと Console に「押された」と出る
クリックは届いている。原因は関数の中身かその先(12 なら確認③〜⑤、15 なら確認④、18 なら確認④〜⑤、06 なら確認③)。このページの確認④も見る。
もし押しても何も出ない
クリックが関数に届いていない。確認③へ。
もしボタンを押す前(ページを開いただけ)で「押された」が出る
onClick={関数()} の形になっている。確認③の1つ目。
もしConsole に赤いエラーがたくさん出ている
確認③の「赤いエラー」の行。
クリックが届かない原因(上から順に当てはまるものを探す)
見る場所ボタンのタグ(<Button … onClick=…>)の行と、そのボタンを囲んでいるタグを見る。
もしonClick={toggle()} のように、関数名のあとに () が付いている
() を付けると「画面を描いた瞬間に1回だけ実行して、その結果を onClick に入れる」意味になる。押したときには何も起きない。
onClick={() => toggle()}1行ずつ解説
| 1 | onClick={() => toggle()} | () => を前に付けると「押されたら toggle() を実行する関数」を渡す意味になる。引数が無い関数なら onClick={toggle}(() なし)でもよい。 |
もしonclick(小文字)や onPress になっている
React は onClick(C が大文字)だけを見る。小文字だと何も起きない(Console に黄色い警告が出ることがある)。
もし<Button(大文字)で、Button が自作の部品
F12 で Button の定義を開く。受け取った onClick を中の <button> に渡していないと、何も起きない。
export function Button({ children, ...props }: React.ComponentProps<"button">) {
return <button {...props}>{children}</button>;
}1行ずつ解説
| 1 | export function Button({ children, ...props }: React.ComponentProps<"button">) { | ...props は「children 以外に渡されたもの(onClick・className・type など)を全部まとめて props という名前で受け取る」。 |
| 2 | return <button {...props}>{children}</button>; | {...props} で受け取ったものを全部本物の button に渡す。今の className などの見た目の指定は残す。 |
| 3 | } | カッコを閉じる。 |
もしボタンに disabled が付いている/className に pointer-events-none がある
押せない状態。F12 → Elements でボタンのタグを見て、disabled が付いていないか確かめる。
もしif (!mounted) return <Button disabled … /> のような行がある(ダークモードでよくある形)
画面が表示されたら mounted を true にする useEffect が無い/間違っていると、ずっと「押せない仮のボタン」のまま。
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);1行ずつ解説
| 1 | const [mounted, setMounted] = useState(false); | 表示されたかどうかを覚える箱。最初は false。 |
| 2 | useEffect(() => { | useEffect の中身は「画面に表示された後」に動く。 |
| 3 | setMounted(true); | ここで true にする。 |
| 4 | }, []); | 最後の [] は「最初の1回だけ動かす」の印。これが無い・中身が違うと動きが変わる。useState と useEffect は react から import する。 |
もし透明な要素がボタンの上にかぶさっている
F12 の左上の矢印アイコンを押してから、画面のボタンをクリック。Elements で選ばれたのが button ではなく div などなら、それがかぶさっている。
className="relative z-50 今のclassName"1行ずつ解説
| 1 | className="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 だけ
並び順を覚える箱を作ってつなぐ(無ければ)。
const [sort, setSort] = useState("updated");1行ずつ解説
| 1 | const [sort, setSort] = useState("updated"); | 今の並び順を覚える箱。最初から更新順にしたいので "updated"。useState は react から import。 |
onClick={() => setSort("updated")}1行ずつ解説
| 1 | onClick={() => 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行ずつ解説
| 1 | function toTime(v: any): number { | 日付を「数字(1970年からのミリ秒)」に変える関数。どんな形の日付が来ても、数字にすれば引き算で大小を比べられる。any は「何でも受け取る」型。 |
| 2 | if (!v) return 0; | 値が無い(null・undefined・空文字)なら 0=一番古い扱い。 |
| 3 | if (typeof v === "number") return v < 1e12 ? v * 1000 : v; | すでに数字なら、そのまま使う。1e12(1兆)より小さいのは「秒」なので 1000 倍してミリ秒にそろえる。 |
| 4 | if (typeof v.toDate === "function") return v.toDate().getTime(); | Firestore の Timestamp のように toDate() を持っている日付なら、それで Date にしてから数字に。 |
| 5 | const t = new Date(v).getTime(); | new Date(値) で日付として読み、getTime() で数字に。Date 型・"2026-09-30T12:34:56Z"・"2026/09/30 12:34" はここで数字になる。 |
| 6 | if (!isNaN(t)) return t; | 読めた(isNaN=「数字ではない」が false)なら、それを返す。 |
| 7 | const 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] に入る。 |
| 8 | return m ? new Date(+m[1], +m[2] - 1, +m[3], +(m[4] ?? 0), +(m[5] ?? 0)).getTime() : 0; | 抽出できたら年・月・日・時・分で Date を作って数字に(月は0始まりなので -1。+ は文字を数字にする)。できなければ 0。 |
| 9 | } | 関数を閉じる。 |
const sorted = [...surveys].sort((a, b) => {
const diff = toTime(a.updatedAt ?? a.createdAt) - toTime(b.updatedAt ?? b.createdAt);
return sort === "old" ? diff : -diff;
});1行ずつ解説
| 1 | const sorted = [...surveys].sort((a, b) => { | 一覧をコピー([...配列])してから並べ替える。sort((a, b) => …) は2件ずつ比べて、答えがマイナスなら a が先、プラスなら b が先。 |
| 2 | const diff = toTime(a.updatedAt ?? a.createdAt) - toTime(b.updatedAt ?? b.createdAt); | 更新日時を数字にして引き算。updatedAt ?? createdAt は「更新日時が無ければ作成日時を使う」(一度も更新していないアンケート対策)。項目名は確認②で見た名前に。 |
| 3 | return sort === "old" ? diff : -diff; | 「古い順」なら差のまま(古いものが先)、それ以外(更新が新しい順)は - で逆にする。sort と "old" は今あるボタンが使っている state と値に合わせる。 |
| 4 | }); | カッコを閉じる。 |
{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行ずつ解説
| 1 | const 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行ずつ解説
| 1 | const sorted = [...surveys].sort((a, b) => { | コピーして並べる。 |
| 2 | if (sort === "title") return String(a.title).localeCompare(String(b.title), "ja"); | タイトル順:日本語の順で比べる。 |
| 3 | if (sort === "created") return toTime(b.createdAt) - toTime(a.createdAt); | 作成順:作成日時の新しい順。 |
| 4 | return toTime(b.updatedAt ?? b.createdAt) - toTime(a.updatedAt ?? a.createdAt); | それ以外(更新順):更新日時の新しい順。 |
| 5 | }); | 閉じる。 |
もし「↑↓」1つのボタンで、押すたびに新しい順/古い順が入れ替わる
state を true / false にする。
const [asc, setAsc] = useState(false);1行ずつ解説
| 1 | const [asc, setAsc] = useState(false); | true=古い順、false=新しい順。ボタンは onClick={() => setAsc(!asc)}(押すたびに反対)。確認③の基本の3行目は return asc ? diff : -diff; にする。 |
もし今のコードに a.updatedAt.localeCompare(…) がある
Date 型には localeCompare が無い(エラーの原因)。基本の形の toTime の引き算に置き換える。
もし並びが合っているか目で確かめたい(任意)
一覧に更新日時を表示する。
<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〜)
export default async function Page({ searchParams }: { searchParams: Promise<{ sort?: string }> }) {
const { sort = "updated" } = await searchParams;1行ずつ解説
| 1 | export default async function Page({ searchParams }: { searchParams: Promise<{ sort?: string }> }) { | 関数名は今のまま。() の中に searchParams を足す(params があれば並べる)。 |
| 2 | const { sort = "updated" } = await searchParams; | URL の ?sort=… を取り出す。無ければ更新順。15 以上は await が必要。 |
const sorted = [...surveys].sort((a, b) => {
const diff = toTime(a.updatedAt ?? a.createdAt) - toTime(b.updatedAt ?? b.createdAt);
return sort === "old" ? diff : -diff;
});1行ずつ解説
| 1 | const sorted = [...surveys].sort((a, b) => { | コピーして並べる。 |
| 2 | const diff = toTime(a.updatedAt ?? a.createdAt) - toTime(b.updatedAt ?? b.createdAt); | 更新日時の差。 |
| 3 | return 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 に足す。
updatedAt: true,1行ずつ解説
| 1 | updatedAt: true, | 更新日時も取り出す。 |
もしAPI(/api/surveys)が返すデータに更新日時が無い
API のファイルで、返しているオブジェクトを作っている所に updatedAt: s.updatedAt を足す。
もしschema に updatedAt が無い
DB の形を変える作業になる。勝手に変えず、先に社員の方に確認する。OK なら updatedAt DateTime @updatedAt を足して npx prisma migrate dev。
もし設問を編集しても更新日時が変わらない
@updatedAt はその行(アンケート本体)を update したときだけ変わる。設問だけ保存すると本体の日時は古いまま。設問を保存する処理の最後に、本体も更新する。
await prisma.survey.update({ where: { id: surveyId }, data: { updatedAt: new Date() } });1行ずつ解説
| 1 | await 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」の searchParams | nextjs 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行ずつ解説
| 1 | function validate(questions: any[]) { | 空欄チェックの関数。設問の配列を受け取る。空欄が無ければ true、あれば false を返す。any[] は「何かの配列」という型(型が分かれば Question[] などに変えてよい)。 |
| 2 | const errors: string[] = []; | 見つけた空欄のメッセージを貯める配列(最初は空)。 |
| 3 | questions.forEach((q, i) => { | forEach:設問を1つずつ取り出して中の処理を動かす。q が今の設問、i は番号(0,1,2…)。 |
| 4 | const no = i + 1; | 画面に出す番号(0 からではなく 1 から)。 |
| 5 | if (String(q.title ?? "").trim() === "") { | 質問文が空か調べる。?? "" で null / undefined を空文字に、String() で必ず文字に、.trim() で前後の空白を消してから ""(空)と比べる。q.title は質問文の項目名(text・question など)に合わせる。 |
| 6 | errors.push(`Q${no}:質問文が空欄です`); | 空なら push でメッセージを配列の最後に足す。`…${no}…` は文字の中に値を入れる書き方。 |
| 7 | } | if を閉じる。 |
| 8 | if (q.type === "radio" || q.type === "checkbox") { | ラジオ(選択式)の設問のときだけ選択肢を調べる。|| は「または」。"radio" "checkbox" は自分のデータの値に合わせる(確認②)。チェックボックスが無ければ後ろ半分は消してよい。 |
| 9 | const choices = q.choices ?? []; | 選択肢の配列を取り出す。無い(undefined)ときは空の配列にして、下の forEach でエラーにならないようにする。 |
| 10 | if (choices.length === 0) { | 選択肢が1つも無いか調べる。 |
| 11 | errors.push(`Q${no}:選択肢がありません`); | 無ければメッセージを足す。 |
| 12 | } | if を閉じる。 |
| 13 | choices.forEach((c, j) => { | 選択肢も forEach で1つずつ調べる。c が選択肢、j が選択肢の番号。 |
| 14 | if (String(c.label ?? "").trim() === "") { | 選択肢の文字が空か調べる(質問文と同じ形)。選択肢がただの文字なら String(c ?? "") にする(確認③)。 |
| 15 | errors.push(`Q${no}:選択肢${j + 1}が空欄です`); | 空ならメッセージを足す。 |
| 16 | } | if を閉じる。 |
| 17 | }); | 選択肢の forEach を閉じる。 |
| 18 | } | ラジオの if を閉じる。 |
| 19 | }); | 設問の forEach を閉じる。ここまでは「調べてメモするだけ」。forEach の中では return しない。 |
| 20 | if (errors.length > 0) { | メモが1つでもあれば(空欄があれば)… |
| 21 | alert(errors.join("\n")); | alert でまとめて表示。join("\n") は配列を改行でつないで1つの文字にする。 |
| 22 | return false; | false を返す=「保存してはいけない」の合図。 |
| 23 | } | if を閉じる。 |
| 24 | return true; | 空欄が無ければ true=「保存してよい」。 |
| 25 | } | 関数を閉じる。 |
もし選択肢がただの文字の並び(["はい", "いいえ"])
14行目だけ変える。
if (String(c ?? "").trim() === "") {1行ずつ解説
| 1 | if (String(c ?? "").trim() === "") { | c そのものが選択肢の文字なので、.label を付けない。 |
もし種類の項目が無い(選択肢の配列を持っているかで判断)
8行目を変える。
if (Array.isArray(q.choices)) {1行ずつ解説
| 1 | if (Array.isArray(q.choices)) { | Array.isArray(x) は「x が配列なら true」。選択肢の配列を持っている設問だけ調べる。 |
もし選択肢は2つ以上ないとダメにしたい
10〜12行目を変える。
if (choices.length < 2) {
errors.push(`Q${no}:選択肢を2つ以上入れてください`);
}1行ずつ解説
| 1 | if (choices.length < 2) { | < 2=2つより少ない(0個か1個)。 |
| 2 | errors.push(`Q${no}:選択肢を2つ以上入れてください`); | メッセージを足す。 |
| 3 | } | if を閉じる。 |
もし同じ選択肢が2回入っていないかも調べたい(任意)
17行目(選択肢の forEach を閉じた行)の下に足す。
const labels = choices.map((c) => String(c.label ?? "").trim());
if (new Set(labels).size !== labels.length) {
errors.push(`Q${no}:同じ選択肢があります`);
}1行ずつ解説
| 1 | const labels = choices.map((c) => String(c.label ?? "").trim()); | map で選択肢の文字だけの配列を作る。 |
| 2 | if (new Set(labels).size !== labels.length) { | new Set(配列) は重複を消した集まり。.size がもとの数と違えば、重複があった。 |
| 3 | errors.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行ずつ解説
| 1 | const 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行ずつ解説
| 1 | if (!validate(questions)) return; | ! は「反対」。validate が false(空欄あり)なら return で関数を終わる=下の保存の処理まで行かない。 |
もし関数が配列を引数で受け取っている(function save(list) {)
questions を引数の名前にする。
if (!validate(list)) return;1行ずつ解説
| 1 | if (!validate(list)) return; | 受け取った配列を調べる。 |
もしreact-hook-form(const onSubmit = (data) => {)
入力内容は data の中。
if (!validate(data.questions)) return;1行ずつ解説
| 1 | if (!validate(data.questions)) return; | data.questions は useForm の中の設問の配列の名前(useFieldArray({ name: "questions" }) の name と同じ)。 |
もしZustand の store の中(save: async () => {)
store の中身は get() で取り出す。確認③の関数は store のファイルの create( より上に貼る。
const { questions } = get();
if (!validate(questions)) return;1行ずつ解説
| 1 | const { questions } = get(); | 今の store の中身から設問を取り出す。create((set, get) => ({ の get。(set) => しか無ければ (set, get) => にする。 |
| 2 | if (!validate(questions)) return; | 空欄があれば止める。 |
もし<form action={createSurvey}(Server Action)
送信の前(ブラウザ側)で止める。form のタグに onSubmit を足す。
<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={() => { if (validate(questions)) handleSave(); }}1行ずつ解説
| 1 | onClick={() => { if (validate(questions)) handleSave(); }} | OK(true)のときだけ保存の関数を呼ぶ。handleSave() の () の中は今の書き方に合わせる。 |
(任意)空欄の入力欄を赤くしたい
見る場所選択肢の入力欄(<Input か <input)。
もしどの場合も
保存を押して失敗した後だけ赤くする。
const [showErr, setShowErr] = useState(false);1行ずつ解説
| 1 | const [showErr, setShowErr] = useState(false); | 「空欄を赤くするか」を覚える箱。最初は false。 |
if (!validate(questions)) { setShowErr(true); return; }1行ずつ解説
| 1 | if (!validate(questions)) { setShowErr(true); return; } | 失敗したら赤くする印を true にしてから止める。 |
className={`今のclassName ${showErr && String(c.label ?? "").trim() === "" ? "border-red-500" : ""}`}1行ずつ解説
| 1 | className={`今の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 Overflow | forEach return 止まらない / forEach break |
| 文字の前後の空白を消す | MDN「String.prototype.trim()」 | MDN trim |
| alert | MDN「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 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 がはみ出す
<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行目に足す(新しいファイルを作るのではない)。
const [open, setOpen] = useState(false);1行ずつ解説
| 1 | const [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 から取れるものを全部出してみる。
const { theme, resolvedTheme, themes, forcedTheme } = useTheme();
console.log("テーマ", { theme, resolvedTheme, themes, forcedTheme });1行ずつ解説
| 1 | const { theme, resolvedTheme, themes, forcedTheme } = useTheme(); | 今の useTheme の行と別に、確認用に取り出す(名前がかぶってエラーなら、今の行の { } の中に足すだけでもよい)。theme は選んだ設定、resolvedTheme は実際の色(light / dark)、themes は使えるテーマの一覧、forcedTheme は固定されたテーマ。 |
| 2 | console.log("テーマ", { theme, resolvedTheme, themes, forcedTheme }); | { theme, … } のようにまとめて出すと、名前付きで表示される。確認が終わったら2行とも消す。 |
console.log("押された");1行ずつ解説
| 1 | console.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 で使える)。
import { ThemeProvider } from "next-themes";1行ずつ解説
| 1 | import { ThemeProvider } from "next-themes"; | next-themes の ThemeProvider を読み込む。 |
<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 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(resolvedTheme === "dark" ? "light" : "dark");1行ずつ解説
| 1 | setTheme(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 を呼ぶ行を足す。
setTheme(isDark ? "light" : "dark");1行ずつ解説
| 1 | setTheme(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 を変える処理を足す。
useEffect(() => {
document.documentElement.classList.toggle("dark", theme === "dark");
}, [theme]);1行ずつ解説
| 1 | useEffect(() => { | useEffect(処理, [theme]) は「theme が変わるたびに処理を動かす」。 |
| 2 | document.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行足す。
@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 の値が undefined | ThemeProvider の外(確認③)。 |
| 再読み込みするとライトに戻る | 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 と Provider | React公式「useContext」 | react useContext provider なし デフォルト値 |
| useEffect の最後の [ ] | React公式「useEffect」 | react useEffect 依存配列 |
| ボタンが動かないときの一般的な原因 | Stack Overflow | next-themes setTheme not working |
課題 13
アンケート検索
アンケート一覧で、タイトルに含まれる言葉で絞り込めるようにする。
検索欄はもうある?
見る場所一覧画面を見る。コードは全体検索 placeholder・検索・type="search"・Search。
もしある(入力はできるが絞り込まれない)
その input の value=・onChange=・name= を見てメモし、確認②へ(入力欄はつなぐだけ)。
もしない
確認②で置き方を決める。
一覧の .map( があるファイルの1行目に "use client" がある?(06 の確認①②と同じ手順で探す)
見る場所page.tsx → 部品を置いているだけなら F12 で開く。
もしある
useState で検索語を覚え、配列を filter する。
const [keyword, setKeyword] = useState("");
const filtered = surveys.filter((s) =>
String(s.title ?? "").toLowerCase().includes(keyword.toLowerCase())
);1行ずつ解説
| 1 | const [keyword, setKeyword] = useState(""); | 検索語を覚える箱(useState を react から import)。検索欄がすでにあり、その value に使っている state があるなら、この行は作らずその名前を使う。 |
| 3 | const filtered = surveys.filter((s) => | 条件に合うものだけ残した新しい配列を作る(filter)。 |
| 4 | String(s.title ?? "").toLowerCase().includes(keyword.toLowerCase()) | タイトルを必ず文字にして(null でもエラーにしない)小文字にし、検索語(小文字)が含まれるか。英字の大文字小文字は無視される。 |
| 5 | ); | 上の行で開いたカッコを閉じる。 |
<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 を同じように書けばよい。 |
{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 で受け取って絞り込む。
export default async function Page({ searchParams }: { searchParams: Promise<{ q?: string }> }) {
const { q = "" } = await searchParams;1行ずつ解説
| 1 | export default async function Page({ searchParams }: { searchParams: Promise<{ q?: string }> }) { | 引数に searchParams を足す(06 と同じ。すでに params があれば並べる。Next.js 14 以下は Promise と await を外す)。 |
| 2 | const { q = "" } = await searchParams; | 検索語を取り出す。無ければ空。 |
const filtered = surveys.filter((s) => String(s.title ?? "").includes(q));1行ずつ解説
| 1 | const filtered = surveys.filter((s) => String(s.title ?? "").includes(q)); | タイトルに検索語を含むものだけ残す。検索語が空なら全部残る。 |
<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> を閉じる。 |
{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 の代わり)。
const surveys = await prisma.survey.findMany({
where: q ? { title: { contains: q } } : undefined,
});1行ずつ解説
| 1 | const surveys = await prisma.survey.findMany({ | 今のデータ取得の行。 |
| 2 | where: 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検索で)
課題 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 を開いて、外枠の書き方を見る。
もしどの場合も(基本の形)
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行ずつ解説
| 1 | export const metadata = { title: "プライバシーポリシー" }; | タブに出るページの題名。metadata は Next.js の決まった名前(ほかのページに無ければこの行は無くてもよい)。 |
| 3 | export default function PrivacyPage() { | ページの部品。export default を付けると、このファイルの場所(/privacy)の画面になる。関数名は自由。 |
| 4 | return ( | 画面を返す。 |
| 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="/privacy"1行ずつ解説
| 1 | href="/privacy" | フォルダ名と同じにする。ルートグループのカッコは入れない。 |
もしリンクが配列で書かれている(const links = [{ href: "/surveys", label: "一覧" }, …])
配列に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} の下に足す。
<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 にも入れない。中の「ログインしなくてよいページの一覧」に足す。
const publicPaths = ["/login", "/privacy"];1行ずつ解説
| 1 | const 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」の matcher | nextjs 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 されているか見る。
import "./themes.css";1行ずつ解説
| 1 | import "./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="今の色以外のクラス bg-[var(--primary)] text-[var(--primary-foreground)]"1行ずつ解説
| 1 | className="今の色以外のクラス 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- の後ろと同じ文字が入っている項目を渡す。
<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 を付ける。
<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行ずつ解説
| 1 | function changeColor(name: string) { | 色を変える関数。name には "red" や "black" のような色の名前が入る。 |
| 2 | const root = document.documentElement; | document.documentElement は <html> のこと。何度も使うので短い名前に入れる。 |
| 3 | [...root.classList].forEach((c) => { | root.classList は html に付いている class の一覧。[...一覧] で普通の配列にして、forEach で1つずつ見る。 |
| 4 | if (c.startsWith("theme-")) root.classList.remove(c); | startsWith("theme-")=「theme- で始まる」class なら外す=前に選んだ色を全部外す。dark など他の class は残る。 |
| 5 | }); | forEach を閉じる。 |
| 6 | root.classList.add(`theme-${name}`); | 選んだ色の class を付ける(例:theme-red)。theme- の部分は CSS のクラス名の頭に合わせる。 |
| 7 | localStorage.setItem("themeColor", name); | 選んだ色をブラウザに保存(再読み込みで戻すため・確認⑤)。 |
| 8 | } | 関数を閉じる。 |
もしボタンから "theme-red" のようにクラス名が丸ごと来る
6行目を変える。
root.classList.add(name);1行ずつ解説
| 1 | root.classList.add(name); | 来た名前をそのまま付ける。 |
もしクラス名に頭が無い(.red・.black)
前の色を外す行(3〜5行目)を、色の一覧で外す形にする。
colors.forEach((c) => root.classList.remove(c.value));1行ずつ解説
| 1 | colors.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 に付ける。
useEffect(() => {
changeColor(color);
}, [color]);1行ずつ解説
| 1 | useEffect(() => { | state(color)が変わるたびに動く。 |
| 2 | changeColor(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 ファイルが読み込まれていない、部品が変数を使っていない、のどれか。
再読み込みしても色を残す
見る場所色のボタンがある部品(今あるファイル)。
もしどの場合も(かんたん)
表示されたときに保存された色を読み直す。一瞬元の色が見えることがあるが、課題としては十分。
useEffect(() => {
const saved = localStorage.getItem("themeColor");
if (saved) changeColor(saved);
}, []);1行ずつ解説
| 1 | useEffect(() => { | 表示された後に1回だけ動く。 |
| 2 | const saved = localStorage.getItem("themeColor"); | 保存した色を読む(無ければ null)。"themeColor" は確認④の7行目と同じ名前。 |
| 3 | if (saved) changeColor(saved); | あれば付ける。 |
| 4 | }, []); | []=最初の1回だけ。 |
もし一瞬元の色が見えるのをなくしたい(任意)
layout.tsx の <head> に、画面を描く前に動く1行を足す。
<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行ずつ解説
| 1 | const 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 が名前。
もし棒グラフはある → 円グラフを作る
"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行目に書く(新しいファイルの印ではない)。 |
| 2 | import { Cell, Legend, Pie, PieChart, Tooltip } from "recharts"; | Recharts から円グラフの部品を読み込む。 |
| 4 | export function PieResult({ data }: { data: any[] }) { | 円グラフの部品。data は既存の棒グラフに渡しているものと同じ配列(any[] は「何の配列でもよい」という型)。 |
| 5 | return ( | 画面を返す。 |
| 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 | } | 上の行で開いたカッコを閉じる。 |
もし円グラフはある → 棒グラフを作る
"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行目に書く(新しいファイルの印ではない)。 |
| 2 | import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from "recharts"; | Recharts から棒グラフの部品を読み込む。 |
| 4 | export function BarResult({ data }: { data: any[] }) { | 棒グラフの部品。data は既存の円グラフと同じ配列。 |
| 5 | return ( | 画面を返す。 |
| 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 } の形は共通)。
もし円グラフを足す
"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行目に書く(新しいファイルの印ではない)。 |
| 2 | import { ArcElement, Chart as ChartJS, Legend, Tooltip } from "chart.js"; | chart.js から、円グラフに必要な部品を読み込む。 |
| 3 | import { Pie } from "react-chartjs-2"; | react-chartjs-2 から円グラフを読み込む。 |
| 5 | ChartJS.register(ArcElement, Tooltip, Legend); | 必ず必要:円グラフの部品を登録する。無いと「"arc" is not a registered element」というエラーになる。 |
| 7 | export function PieResult({ data }: { data: any }) { | 円グラフの部品。data は既存の棒グラフに渡しているものと同じ。 |
| 8 | return <Pie data={data} />; | 円グラフを描く。Chart.js は最初から回りながら描かれるアニメーションが付いている。 |
| 9 | } | 上の行で開いたカッコを閉じる。 |
もし棒グラフを足す
"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行目に書く(新しいファイルの印ではない)。 |
| 2 | import { BarElement, CategoryScale, Chart as ChartJS, Legend, LinearScale, Tooltip } from "chart.js"; | 棒グラフに必要な部品(棒・横軸・縦軸・吹き出し・凡例)を読み込む。 |
| 3 | import { Bar } from "react-chartjs-2"; | react-chartjs-2 から棒グラフを読み込む。 |
| 5 | ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend); | 部品を登録する。無いと「"category" is not a registered scale」などのエラーになる。 |
| 7 | export function BarResult({ data }: { data: any }) { | 棒グラフの部品。 |
| 8 | return <Bar data={data} />; | 棒グラフを描く(棒が伸びるアニメーションが最初から付いている)。 |
| 9 | } | 上の行で開いたカッコを閉じる。 |
棒 ↔ 円をボタンで切り替える(アニメーション付き)
見る場所集計画面で、今グラフを置いている場所。
もし既存の部品が種類を受け取れる(確認②で「ある」)
下の ChartSwitch の18行目を <ExistingChart type={type} data={data} /> のように既存の部品1つに変える。
もしどの場合も
"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行目に書く(新しいファイルの印ではない)。 |
| 2 | import { useState } from "react"; | state を使うので useState を読み込む。 |
| 3 | import { BarResult } from "@/components/bar-result"; | 棒グラフの部品を読み込む(既存の部品を使うなら、その名前と場所に変える)。 |
| 4 | import { PieResult } from "@/components/pie-result"; | 円グラフの部品を読み込む(同上)。 |
| 6 | export function ChartSwitch({ data }: { data: any }) { | 切り替え部品。data はそのまま下のグラフへ渡す。 |
| 7 | const [type, setType] = useState<"bar" | "pie">("bar"); | 今どちらを出しているかを覚える箱。最初は棒。 |
| 8 | const btn = "rounded px-3 py-1 text-sm transition-colors"; | 2つのボタンに共通の見た目。 |
| 9 | const on = "bg-[var(--primary,#3b82f6)] text-white"; | 選ばれているボタンの見た目(テーマカラーの背景・白文字)。 |
| 11 | return ( | 画面を返す。 |
| 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 | } | 上の行で開いたカッコを閉じる。 |
@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 という名前を付けて定義する。 |
| 2 | from { opacity: 0; transform: scale(0.95); } | 始まり:透明で少し小さい。 |
| 3 | to { opacity: 1; transform: scale(1); } | 終わり:はっきり見えて元の大きさ。 |
| 4 | } | 上の行で開いたカッコを閉じる。 |
| 5 | .chart-in { | このクラスを付けた要素に… |
| 6 | animation: 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行ずつ解説
| 1 | console.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行ずつ解説
| 1 | function removeQuestion(target: any) { | 削除の関数。id ではなく設問そのもの(オブジェクト)を受け取る。こうすると、追加したばかりで id が無い設問でも消せる。 |
| 2 | if (questions.length <= 1) return; | 設問が1つしかないときは消さない。 |
| 3 | if (!window.confirm("この設問を削除しますか?")) return; | 確認を出し、キャンセルなら終わり。 |
| 4 | setQuestions((prev) => prev.filter((q) => q !== target)); | prev は set する直前の最新の配列。q !== target=「押された設問そのもの」以外を残す。オブジェクトの !== は「同じ物かどうか」を比べるので、id が無くても・重なっていても正しく1つだけ消える。 |
| 5 | } | 関数を閉じる。 |
onClick={() => removeQuestion(q)}1行ずつ解説
| 1 | onClick={() => removeQuestion(q)} | q.id ではなく q(設問そのもの)を渡す。 |
もしid で消す作りのままにしたい
設問を追加する所で id を付ける。
id: crypto.randomUUID(),1行ずつ解説
| 1 | id: crypto.randomUUID(), | 重ならない ID を作る。DB の id が数字の作りなら、tempId: crypto.randomUUID() のように別の名前にして、削除は tempId か id で比べる(保存時に DB の id と混ざらないように)。 |
もし削除の関数が await fetch(`/api/questions/${id}`, { method: "DELETE" }) のように API で消している
新しい設問はまだ DB に無いので失敗する。DB にある設問だけ 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行ずつ解説
| 1 | async function removeQuestion(target: any) { | async:中で await を使う印。 |
| 2 | if (questions.length <= 1) return; | 1問なら消さない。 |
| 3 | if (!window.confirm("この設問を削除しますか?")) return; | 確認。 |
| 4 | if (target.id) { | DB の id がある設問(最初からあった設問)だけ… |
| 5 | await fetch(`/api/questions/${target.id}`, { method: "DELETE" }); | 今の API の書き方のまま DELETE を送る(URL は今のコードに合わせる)。 |
| 6 | } | if を閉じる。 |
| 7 | setQuestions((prev) => prev.filter((q) => q !== target)); | 画面からは、どちらの設問も消す。 |
| 8 | } | 閉じる。 |
もしquestions.splice(i, 1) してから setQuestions(questions)
同じ配列を set しても描き直されない。上の filter の形にする。
もし最初からの設問と追加した設問を別々の配列で持っている(questions と newQuestions など)
片方からしか消していない。両方から消す。
setQuestions((prev) => prev.filter((q) => q !== target));
setNewQuestions((prev) => prev.filter((q) => q !== target));1行ずつ解説
| 1 | setQuestions((prev) => prev.filter((q) => q !== target)); | 最初からの設問の配列から消す(入っていなければ何も変わらない)。 |
| 2 | setNewQuestions((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回だけ入れる。
const loaded = useRef(false);
useEffect(() => {
if (!data || loaded.current) return;
setQuestions(data.questions);
loaded.current = true;
}, [data]);1行ずつ解説
| 1 | const loaded = useRef(false); | 「もう入れたか」を覚える印(useRef は描き直しても値が残り、変えても描き直さない)。useRef を react から import。 |
| 2 | useEffect(() => { | data が変わったら動く。 |
| 3 | if (!data || loaded.current) return; | データがまだ無い、またはもう入れたなら何もしない。 |
| 4 | setQuestions(data.questions); | 最初の1回だけ設問を入れる。 |
| 5 | loaded.current = true; | 入れた印を付ける。 |
| 6 | }, [data]); | 閉じる。 |
もし一瞬消えてすぐ戻る+ react-hook-form の reset(data) が useEffect の中にある
同じ理由。上と同じく loaded の印で最初の1回だけ reset する。
もし画面では消えるが、保存して開き直すと戻る
保存の API が「今ある設問を上書き・追加」だけして、消した設問を DB から消していない。保存の API で、送られてこなかった設問を消す。
await prisma.question.deleteMany({
where: { surveyId: surveyId, id: { notIn: questions.filter((q) => q.id).map((q) => q.id) } },
});1行ずつ解説
| 1 | await prisma.question.deleteMany({ | このアンケートの設問を消す… |
| 2 | where: { 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 が無くても消せる。
const { fields, append, remove } = useFieldArray({ control, name: "questions" });1行ずつ解説
| 1 | const { fields, append, remove } = useFieldArray({ control, name: "questions" }); | { } の中に remove を足す。❌ は onClick={() => remove(i)}(i は fields.map((field, i) => の番号)。 |
もしZustand の store
store に削除の関数を足す(または今ある remove / delete を使う)。
removeQuestion: (target: any) =>
set((state) => ({ questions: state.questions.filter((q) => q !== target) })),1行ずつ解説
| 1 | removeQuestion: (target: any) => | 設問そのものを受け取る削除の関数(型の定義があれば removeQuestion: (target: Question) => void; も)。 |
| 2 | set((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-renders | onClick={removeQuestion(q.id)} の形(確認②)。 |
| 追加した設問を消すと、ほかの追加した設問も全部消える | id が全部 undefined で、q.id !== id が全部 false になっている。設問そのもので比べる(確認④)。 |
| 違う設問が消える | 渡している番号が別の map の番号(選択肢の map の j など)。設問の map の q か i を渡す。 |
| Console に 404 / 500 | API で 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 で DELETE | MDN「フェッチ API の使用」 | fetch method DELETE |
| useRef(描き直しても残る印) | React公式「useRef」 | react useRef useEffect 1回だけ |
| Prisma で残りを消す | Prisma公式 | prisma deleteMany notIn |
| useFieldArray の remove | react-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行ずつ解説
| 1 | function move(from: number, to: number) { | from 番目の設問を to 番目へ動かす関数(どのライブラリでも最後はこれを呼ぶ)。 |
| 2 | if (to < 0 || to >= questions.length) return; | 動かし先が範囲の外なら何もしない。 |
| 3 | const next = [...questions]; | 配列をコピー([...配列])。元を直接いじらない。 |
| 4 | const [item] = next.splice(from, 1); | splice(from, 1) で from 番目を1つ取り出す。戻りは配列なので [item] で中身を受け取る。 |
| 5 | next.splice(to, 0, item); | splice(to, 0, item) で to 番目に差し込む(真ん中の 0 は「何も消さない」)。 |
| 6 | setQuestions(next); | 新しい並びで置き換える。 |
| 7 | } | 関数を閉じる。 |
もしuseFieldArray(react-hook-form)
すでに move がある。
const { fields, append, remove, move } = useFieldArray({ control, name: "questions" });1行ずつ解説
| 1 | const { fields, append, remove, move } = useFieldArray({ control, name: "questions" }); | { } の中に move を足す。以下のコードの questions は fields、q.id は field.id(useFieldArray が付ける ID)に読み替える。 |
もしZustand の store
store のファイルで move・reorder・swap を検索。あればそれを使う。無ければ足す。
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行ずつ解説
| 1 | move: (from: number, to: number) => | 並べ替えの関数を store に足す(型の定義があれば move: (from: number, to: number) => void; も)。 |
| 2 | set((state) => { | store の中身を書き換える。 |
| 3 | const next = [...state.questions]; | 配列をコピー。 |
| 4 | const [item] = next.splice(from, 1); | from 番目を取り出す。 |
| 5 | next.splice(to, 0, item); | to 番目に差し込む。 |
| 6 | return { questions: next }; | 新しい並びを返す。 |
| 7 | }), | 閉じる。 |
const move = useBuilderStore((s) => s.move);1行ずつ解説
| 1 | const 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 { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";1行ずつ解説
| 1 | import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core"; | DndContext=ドラッグできる範囲、closestCenter=どこに落としたかの判定方法、DragEndEvent=ドラッグが終わったときの情報の型。 |
| 2 | import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; | SortableContext=並べ替える一覧、useSortable=1件をつかめるようにする道具、verticalListSortingStrategy=縦に並んだ一覧用の動き方。 |
| 3 | import { 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行ずつ解説
| 1 | function SortableRow({ id, children }: { id: string | number; children: (handle: any) => React.ReactNode }) { | 1件分の「つかめる入れ物」の部品。id はその設問の ID。children は「ハンドル用の印を受け取って、中身を返す関数」(下の map で書く)。 |
| 2 | const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({ id }); | useSortable({ id }) から、並べ替えに必要なものを取り出す。listeners=押してドラッグを始める仕組み、setActivatorNodeRef=つかむ場所(ハンドル)の印、setNodeRef=動く箱の印、transformtransition=ドラッグ中の位置と動き。 |
| 3 | const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.6 : 1 }; | ドラッグ中の位置を style にする。つかんでいる間は少し薄く(0.6)。 |
| 4 | return ( | 画面を返す。 |
| 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 | } | 関数を閉じる。 |
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行ずつ解説
| 1 | function handleDragEnd(e: DragEndEvent) { | ドラッグを離したときに動く関数。e にどれをどこへ動かしたかが入っている。 |
| 2 | const { active, over } = e; | active=つかんだ設問、over=離した場所にあった設問。 |
| 3 | if (!over || active.id === over.id) return; | 一覧の外で離した、または同じ場所なら何もしない。 |
| 4 | const from = questions.findIndex((q) => q.id === active.id); | findIndex で、つかんだ設問が今何番目かを探す。 |
| 5 | const to = questions.findIndex((q) => q.id === over.id); | 離した場所の設問が何番目か。 |
| 6 | move(from, to); | 確認②の move で並べ替える(useFieldArray なら move(from, to)、store なら store の move)。 |
| 7 | } | 関数を閉じる。 |
<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行ずつ解説
| 1 | import { 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行ずつ解説
| 1 | function ReorderRow({ item, children }: { item: any; children: (startDrag: (e: React.PointerEvent) => void) => React.ReactNode }) { | 1件分の入れ物。children は「ドラッグを始める関数」を受け取って中身を返す関数。 |
| 2 | const controls = useDragControls(); | ドラッグを始める道具。1件ごとに1つ必要なので、別の部品に分けている。 |
| 3 | return ( | 画面を返す。 |
| 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 | } | 関数を閉じる。 |
<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行ずつ解説
| 1 | import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; | package.json が react-beautiful-dnd ならその名前にする。 |
<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。
const [grab, setGrab] = useState<number | null>(null);
const [dragFrom, setDragFrom] = useState<number | null>(null);1行ずつ解説
| 1 | const [grab, setGrab] = useState<number | null>(null); | 今ハンドルを押している設問の番号(押していなければ null)。<number | null> は「数字か null」という型。 |
| 2 | const [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 | 今の設問カードの一番外側のタグ(タグ名は今のまま)。 |
| 2 | key={q.id} | key は今のまま。 |
| 3 | draggable={grab === i} | ハンドルを押しているカードだけ draggable(つかめる)にする。 |
| 4 | onDragStart={(e) => { e.dataTransfer.setData("text/plain", String(i)); setDragFrom(i); }} | ドラッグ開始:番号を覚える。setData は Firefox で必要な決まり。 |
| 5 | onDragOver={(e) => e.preventDefault()} | preventDefault() で「ここに落としてよい」にする(無いと drop が起きない)。 |
| 6 | onDrop={() => { if (dragFrom !== null) move(dragFrom, i); }} | 落としたら、始めた番号からこの番号へ move。 |
| 7 | onDragEnd={() => { setDragFrom(null); setGrab(null); }} | ドラッグが終わったら全部元に戻す。 |
| 8 | className={`今の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行ずつ解説
| 1 | const 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 |
| findIndex | MDN「Array.prototype.findIndex()」 | MDN findIndex |
| useFieldArray の move | react-hook-form公式「useFieldArray」 | react-hook-form useFieldArray move |