🐤

【まとめ】Next.js初心者がTODOアプリを作って引っかかった点の解説

2024/01/11に公開

初のNext.jsでのアプリ作成です。
自分用の備忘録&チームへの勉強過程の報告用として書きました。
初心者目線で実際に引っかかった点が詰まっているので、役に立つところがあるかと思います。

以下の記事を主に参考にさせていただきました。ありがとうございます。
【Next.js】定番のTodoアプリを作ってみる
NextjsとTypeScriptで簡単なTodoアプリを作ろう!
Reactのコンポーネント内で関数を呼び出すときの書き方の違い【onclick, onchange, アロー関数】

仕様

  • Next.js + Tailwind CSS + shadcn/ui + Vercelを使う。
  • 最小限の機能で作る。
  • タスクを入力する欄がある。
  • タスクを入力してエンターを押したら入力欄の下にリスト形式で表示させる。
  • タスク一覧のアイテムは完了したら削除できるようにする。
  • 完了済みのタスク一覧から完了済みのタスクを復元できるようにする。

完成物

Vercelで適当にデプロイ。
スマホサイズで作成したため、開発者モードを使ってスマホサイズで表示した方が見栄えが良いです。
(レスポンシブ対応はまた別の機会に実装予定)
https://todo-app-nine-steel-49.vercel.app/

コード

<Input>, <Button>, <Accordion>はshadcn/uiでインストールしたもの。
細かい設定なしでかっこいいデザインが作れるので非常に便利です。
shadcn/uiについては、大量に先人たちの記事が出てくるので説明は省きます。

コード全体
TodoList.tsx
"use client";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";

import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/ui/accordion";

export const TodoList = () => {
  const [text, setText] = useState<string>("");
  const [todos, setTodos] = useState<{ sentence: string; visible: boolean }[]>(
    []
  );

  const changeText = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value);
  };

  const addTodo = () => {
    if (text === "") {
      console.log("空では追加できません");
      return;
    }
    const newTodos = [...todos, { sentence: text, visible: true }];
    setTodos(newTodos);

    console.log(text + "を追加しました");

    setText("");
  };

  const completeTodo = (index: number) => {
    const newTodos = [...todos];
    newTodos[index].visible = !newTodos[index].visible;
    setTodos(newTodos);
    console.log("完了しました");
  };

  return (
    <div className="flex flex-col items-center justify-center text-center space-y-4 mt-5 mx-auto w-4/5 pd-8">
      <Input
        className=" bg-zinc-300/50"
        placeholder="input TODO"
        value={text}
        onChange={changeText}
        onKeyDown={(e) => {
          if (e.key === "Enter" && !e.nativeEvent.isComposing) {
            e.preventDefault();
            addTodo();
          }
        }}
      />
      <Button className="w-full my-5 bg-zinc-500" onClick={addTodo}>
        add
      </Button>
      {/* <button className="w-full my-5 bg-zinc-500/50" onClick={addTodo}>
        追加
      </button> */}
      <div className="w-full container my-auto bg-zinc-300 rounded-sm">
        <div className="w-full flex flex-col space-y-4 h-fit p-2 pb-4">
          {todos.map(
            (todo, index) =>
              // todo.visibleがtrueである場合にのみ、括弧内のコード(つまりToDoアイテムの表示に関するコード)が実行されます。
              todo.visible && (
                <div
                  className="flex items-center mt-2 p-4 bg-white rounded-sm"
                  key={index}
                >
                  <button
                    className="p-2 rounded-full"
                    // 引数を渡す場合はアロー関数で囲む必要がある
                    onClick={() => completeTodo(index)}
                  >
                    ☑️
                  </button>
                  <p>{todo.sentence}</p>
                </div>
              )
          )}
        </div>
      </div>
      <Accordion type="single" collapsible className="w-full">
        <AccordionItem value="item-1">
          <AccordionTrigger>完了済み</AccordionTrigger>
          <AccordionContent>
            <div className="w-full container my-auto bg-zinc-300 rounded-sm">
              <div className="w-full flex flex-col space-y-4 h-fit p-2 pb-4">
                {todos.map(
                  (todo, index) =>
                    todo.visible || (
                      <div
                        className="flex items-center mt-2 p-4 bg-white rounded-sm"
                        key={index}
                      >
                        <button
                          className="p-2 rounded-full"
                          // 引数を渡す場合はアロー関数で囲む必要がある
                          onClick={() => completeTodo(index)}
                        ></button>
                        <p>{todo.sentence}</p>
                      </div>
                    )
                )}
              </div>
            </div>
          </AccordionContent>
        </AccordionItem>
      </Accordion>
    </div>
  );
};

page.tsx
"use client";

import Image from "next/image";
import { TodoList } from "@/components/TodoList";

export default function Home() {
  return (
    <main>
      <h1 className="flex justify-center text-gray-800 font-bold text-2xl mt-6">
        TODOリスト
      </h1>
      <TodoList></TodoList>
    </main>
  );
}

タスクの入力、表示、完了、復元の流れ

どこを説明しているかわかりやすいように、該当部分のコードを抜粋して下につける形で記載します。

<Input>に文字が入力される

文字が変わるたびに、onChange={changeText}が実行される

<Input
        className=" bg-zinc-300/50"
        placeholder="input TODO"
        value={text}
        onChange={changeText}
〜省略〜


事前に定義したステート変数であるtextに、changeText関数の中でセッター関数であるsetText(e.target.value)が実行されて、現在の文字列が代入される

export const TodoList = () => {
  const [text, setText] = useState<string>("");
  const [todos, setTodos] = useState<{ sentence: string; visible: boolean }[]>(
    []
  );
  const changeText = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value);
  };


<Input>のonKeyDown もしくは <Button>のonClickaddTodo関数が実行される

TodoList.tsx
<Input
  className=" bg-zinc-300/50"
  placeholder="input TODO"
  value={text}
  onChange={changeText}
  onKeyDown={(e) => {
      if (e.key === "Enter" && !e.nativeEvent.isComposing) {
        e.preventDefault();
        addTodo();
      }
    }}
 />

<Button className="w-full my-5 bg-zinc-500" onClick={addTodo}>
  add
</Button>

addTodo関数内で以下の処理が実行される

  • 文字列が空でないかの判定を行い、空の場合は関数を終了
  • スプレッド構文を使ってnewTodosという名称で現在のtodos配列のコピーを作成し、新しい要素をnewTodosに追加
    • 新しい要素では、sentencetext(入力フォームの文字列)に、visibleを初期値としてtrueに設定する
  • setTodos関数を使用して、新しい要素が追加された配列をtodos配列に上書き
  • setText("")を呼び出して、textを空文字列にリセットすることで、入力フィールドをクリアする
TodoList.tsx
  const addTodo = () => {
    if (text === "") {
      console.log("空では追加できません");
      return;
    }
    const newTodos = [...todos, { sentence: text, visible: true }];
    setTodos(newTodos);

    console.log(text + "を追加しました");

    setText("");
  };


stateであるtodosが更新されたので、再レンダリングが走る

todos.map()メソッドは、todos配列の各要素に対して指定された関数を実行する

TodoList.tsx
{todos.map(
            (todo, index) =>
〜省略〜

それぞれの要素に対して、論理AND演算子&&を使ってtodo.visibletrueかどうかの判定を行う

TodoList.tsx
              todo.visible && (
                <div
                  className="flex items-center mt-2 p-4 bg-white rounded-sm"
                  key={index}
                >
〜省略〜


初期値でtodo.visibletrueなので、<div>, <button>, <p>が表示される

TodoList.tsx
              todo.visible && (
                <div
                  className="flex items-center mt-2 p-4 bg-white rounded-sm"
                  key={index}
                >
                  <button
                    className="p-2 rounded-full"
                    // 引数を渡す場合はアロー関数で囲む必要がある
                    onClick={() => completeTodo(index)}
                  >
                    ☑️
                  </button>
                  <p>{todo.sentence}</p>
                </div>
〜省略〜


<button>を押すとonClick={() => completeTodo(index)}completeTodo(index)が実行される

TodoList.tsx
                  <button
                    className="p-2 rounded-full"
                    // 引数を渡す場合はアロー関数で囲む必要がある
                    onClick={() => completeTodo(index)}
                  >
                    ☑️
                  </button>


completeTodo(index)ではクリックされた要素のvisibleの真偽値を、逆転させて上書きする。

TodoList.tsx
  const completeTodo = (index: number) => {
    const newTodos = [...todos];
    newTodos[index].visible = !newTodos[index].visible;
    setTodos(newTodos);
    console.log("完了しました");
  };


stateのnewTodosが更新されたので再レンダリングされる

shadcn/uiでインストールした<Accordion>の中に、タスク一覧と同様にmap関数、論理OR演算子||を使って完了済みタスク一覧を表示する

TodoList.tsx
 <Accordion type="single" collapsible className="w-full">
        <AccordionItem value="item-1">
          <AccordionTrigger>完了済み</AccordionTrigger>
          <AccordionContent>
            <div className="w-full container my-auto bg-zinc-300 rounded-sm">
              <div className="w-full flex flex-col space-y-4 h-fit p-2 pb-4">
                {todos.map(
                  (todo, index) =>
                    todo.visible || (
                      <div
                        className="flex items-center mt-2 p-4 bg-white rounded-sm"
                        key={index}
                      >
                        <button
                          className="p-2 rounded-full"
                          // 引数を渡す場合はアロー関数で囲む必要がある
                          onClick={() => completeTodo(index)}
                        ></button>
                        <p>{todo.sentence}</p>
                      </div>
                    )
                )}
              </div>
            </div>
          </AccordionContent>
        </AccordionItem>
      </Accordion>

引っかかった点

私と同様な初心者が引っかかりそうなポイントをまとめます。
GitHub Copilot 先生のおかげで自力でも解決できました。

JavaScript, TypeScript, React, Next.jsの関係は?

JavaScript

↓ スーパーセット(拡張版):静的型付けなどの機能を追加

TypeScript

↓ UIを提供する機能を追加

React
(→JavaScriptやTypeScriptのライブラリ)

↓ ルーティングやレンダリングの機能を追加

Next.js
(→Reactのフレームワーク)

React:ライブラリかフレームワークか?

ChatGPT4の回答
Reactは通常、「ライブラリ」として分類されます。なぜなら、Reactは主にUIの構築に焦点を当てており、アプリケーションの全体的なアーキテクチャを定義しません。Reactはコンポーネントを作成し、それらを組み合わせてUIを構築するためのツールを提供しますが、ルーティングや状態管理のような他の側面は開発者に委ねられています。これに対して、フレームワークはこれらの側面を統合的に管理し、より包括的なガイドラインと構造を提供します。

簡潔に言えば、React(ライブラリ)は「使いたい部分を選んで使う」ものであり、フレームワークは「全体のルールに従って開発する」ものと言えます。

冒頭の"use client";とは?

とてつもなく雑な表現をすると、useStateを使いたいファイルには記載が必要。
https://reffect.co.jp/react/next-js-13#Client_Component

厳密に理解したい人向けの参考ページ集

実際にはuseStateを使いたい全てのファイルに記載するのではなく、reactツリー内のサーバーコンポーネントとクライアントコンポーネントの境界に一度だけ書くのが望ましい。

https://zenn.dev/uhyo/articles/react-server-components-multi-stage
https://zenn.dev/noko_noko/articles/7987456909978c
https://zenn.dev/msy/articles/a042024e12fca1
https://postd.cc/how-react-server-components-work/

e.target.valueとは?

e.target.valueは「ユーザーが何かアクションを起こした場所で(target)、そのアクションによって生まれた具体的な情報(value)を取得する」という意味になります。

target:そのイベントが起きた場所や要素
value:具体的な値、ここでは<input>にユーザーが入力した値

https://commte.net/nextjs-e-target-value

onKeyDownで、変換確定の際にEnterを押すと、誤って登録が実行されてしまう

e.isComposingはIMEの入力状態が未確定かどうかを判別して、未確定の場合にtrue、違うならfalseを返す。
https://lab.syncer.jp/Web/API_Interface/Reference/IDL/KeyboardEvent/isComposing/#google_vignette

e.preventDefault();はブラウザで標準の動作をブロックする。
inputフォームの標準動作は「actionで指定されたURLへ遷移+データ送信」である。
標準動作をブロックしたのちに自分が期待する動作の関数を実行すればよい。

画像は以下からお借りしました。
https://qiita.com/tochiji/items/4e9e64cabc0a1cd7a1ae

以上2つを組み合わせて以下の形にすると、変換が確定した状態でEnterを押した場合のみ、addTodoが実行され、タスクが登録される。

TodoList.tsx
onKeyDown={(e) => {
  if (e.key === "Enter" && !e.nativeEvent.isComposing) {
    e.preventDefault();
    addTodo();
  }
}}

onClick内で引数を渡す場合はアロー関数で囲む

結論から書くと、onClick={addTodo}は正しいが、onClick={completeTodo(index)}は誤り。また、引数がある場合は基本的にアロー関数を使う。
なお、アロー関数を使わずに書く方法もある。

typescriptで関数を呼ぶときは以下のパターンがある。
①「関数名」
②「関数名( )」
③「( ) => 関数名 ( )」

①「関数名」はイベントによって実行したい関数を参照しているだけである。そのため、onClick={addTodo}はクリックされたときに実行される。

JavaScriptでは、②のように関数名の後に括弧()を付けると、その関数は即時に実行される。したがって、onClick={completeTodo(index)}と書くと、completeTodo(index)関数はレンダリング時に即時実行されてしまう。
つまり、今回はクリックされてから実行したいので、アロー関数で③「( ) => 関数名 ( )」の書き方にする必要がある。

https://lorem-co-ltd.com/wrong-react-function/
https://zenn.dev/kisukeyas/articles/9af27eab7122ce

【補足】 onClick={addTodo}はonClick={() => addTodo()}と書いても問題ありませんか?

GitHub Copilotの回答
はい、onClick={addTodo}とonClick={() => addTodo()}は同じ動作をしますが、微妙な違いがあります。

onClick={addTodo}は、クリックイベントが発生したときにaddTodo関数を直接呼び出します。この場合、addTodo関数はクリックイベントオブジェクトを引数として受け取ります。

一方、onClick={() => addTodo()}は、クリックイベントが発生したときに新しい無名関数(アロー関数)を呼び出し、その無名関数がaddTodo関数を呼び出します。この場合、addTodo関数は引数を受け取りません。

したがって、addTodo関数がイベントオブジェクトを必要としない場合、どちらの書き方でも問題ありません。しかし、addTodo関数がイベントオブジェクトを必要とする場合、onClick={addTodo}を使用する必要があります。

&&||はなぜ条件分岐に使えるのか?

react公式ページによると、そういう仕様らしい。
頭がPython基準なので、ANDとORは真偽値の評価にしか使えないと思っていた。

これが動作するのは、JavaScript では true && expression は必ず expression と評価され、false && expression は必ず false と評価されるからです。
従って、条件部分が true であれば、&& の後に書かれた要素が出力に現れます。もし false であれば、React はそれを無視して飛ばします

https://ja.legacy.reactjs.org/docs/conditional-rendering.html#inline-if-with-logical--operator
https://qiita.com/akifumii/items/c302fdc633d8eba2af0a

Stateとは?

とてつもなく雑な認識だと、いわゆる「変数」のようなもの。
今まではよくわからないものだと思っていたが、ちょっと扱いが面倒なただの変数だと思うと腑に落ちた。

変数との大きな違いとして
①Stateが変更されるとコンポーネントが再レンダリングされる
②setTextなどの専用の関数を使って値を更新する

TodoList.tsx
// Stateの定義
// ステート変数とセッター関数を同時に定義する
const [text, setText] = useState<string>("Hello");

// 値を更新
// 直接代入できないので、専用のセッター関数を使う
setText("Goodbye");
TodoList.tsx
// 変数の定義
let text: string = "Hello";
	
// 値の更新
// 直接代入できる
text = "Goodbye";

https://zenn.dev/pu_ay/articles/99df8c9175a5f0

Discussion