Chapter 08

状態管理の壁

複数のデータソースと DOM の同期問題をバニラ JS で体験し、React のようなフレームワークが必要な理由を理解します。

40 min

このチャプターで学ぶこと

  • 状態(state)と UI の同期問題を理解できる
  • バニラ JS で複雑な状態管理がなぜ辛いかを体感する
  • DOM の手動更新で起こるバグのパターンを知る
  • 宣言的 UI と命令的 UI の違いを理解できる
  • React が解決する問題を自分の言葉で説明できる

これまでのチャプターで、JavaScript の基礎・DOM 操作・非同期処理・イベントと、フロントエンド開発の土台となる知識を積み上げてきました。

このチャプターはその集大成であり、同時に「転換点」です。

バニラ JS でそこそこ複雑なアプリを作ってみます。そして、それがいかに辛いかを実際に体験します。その体験こそが、React を学ぶ最大の動機になります。

8.1 状態(State)とは何か

状態(State)」とは、時間の経過とともに変化し、ユーザーが見ている画面に影響を与えるデータのことです。

具体例を挙げてみましょう。

PHP/Laravel との比較

PHP や Laravel を書いてきた方には、次のように考えると理解しやすいです。

PHP/Laravelブラウザの JavaScript
状態はデータベースや Session に保存される状態は JavaScript の変数に保存される
ページリクエストのたびに PHP が HTML を生成する状態が変わるたびに JS が DOM を更新する
ページ遷移で「リセット」されるシングルページアプリでは維持される

PHP では「状態が変わる = ページリクエストが発生する = PHP が新しい HTML を返す」という流れが自然でした。しかしブラウザの JavaScript では、ページを再読み込みせずに画面を書き換えなければなりません。

ここが難しい

状態が変わるたびに、その状態を「映し出している」すべての DOM 要素を手動で更新しなければなりません。

たった 1 つの変数が変わっただけで、画面の複数の箇所を更新しなければならないケースがよくあります。これを管理するのが、バニラ JS では非常に大変です。その大変さを、これから実際に体験してもらいます。


8.2 ミニ SNS を作ってみる

シンプルなソーシャルフィードアプリを作ります。機能は以下の通りです。

小さなアプリに見えますが、これだけでも状態管理は複雑になります。

データ構造の設計

まず、アプリが扱うデータ(状態)を定義します。

// アプリ全体の状態を1つのオブジェクトにまとめる
let state = {
  currentUser: "田中太郎",  // ログイン中のユーザー
  posts: [
    {
      id: 1,
      author: "鈴木花子",
      text: "今日はいい天気!",
      likes: 3,
      likedByMe: false,  // 自分がいいねしたか
      comments: [
        { author: "佐藤", text: "本当ですね!" }
      ]
    },
    {
      id: 2,
      author: "佐藤次郎",
      text: "新しいカフェを見つけた。コーヒーが最高だった!",
      likes: 1,
      likedByMe: true,
      comments: []
    },
    {
      id: 3,
      author: "山田花",
      text: "週末のハイキング、参加者を募集中です!",
      likes: 5,
      likedByMe: false,
      comments: [
        { author: "田中太郎", text: "ぜひ参加します!" },
        { author: "鈴木花子", text: "私も行きたい!" }
      ]
    }
  ],
  filter: "all",     // "all" | "liked"
  nextId: 4,         // 次の投稿に使う ID
};

HTML 構造

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>ミニ SNS</title>
  <style>
    body { font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; }
    .header { background: #1da1f2; color: white; padding: 16px; border-radius: 8px; margin-bottom: 20px; }
    .header-stats { display: flex; gap: 20px; margin-top: 8px; font-size: 14px; }
    .post { border: 1px solid #ddd; padding: 16px; border-radius: 8px; margin-bottom: 12px; }
    .post-author { font-weight: bold; margin-bottom: 4px; }
    .post-text { margin-bottom: 12px; }
    .post-actions { display: flex; gap: 12px; margin-bottom: 12px; }
    .like-btn { cursor: pointer; padding: 6px 12px; border: 1px solid #ddd; border-radius: 4px; background: white; }
    .like-btn.liked { background: #e0f0ff; border-color: #1da1f2; color: #1da1f2; }
    .comments { border-top: 1px solid #eee; padding-top: 12px; }
    .comment { font-size: 14px; margin-bottom: 4px; }
    .comment-form { display: flex; gap: 8px; margin-top: 8px; }
    .comment-form input { flex: 1; padding: 6px; border: 1px solid #ddd; border-radius: 4px; }
    .new-post-form { border: 1px solid #ddd; padding: 16px; border-radius: 8px; margin-bottom: 20px; background: #f9f9f9; }
    .new-post-form textarea { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; }
    .filter-tabs { display: flex; gap: 8px; margin-bottom: 16px; }
    .filter-tab { padding: 8px 16px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer; background: white; }
    .filter-tab.active { background: #1da1f2; color: white; border-color: #1da1f2; }
    button[type="submit"] { padding: 8px 16px; background: #1da1f2; color: white; border: none; border-radius: 4px; cursor: pointer; }
  </style>
</head>
<body>
  <div id="app">
    <!-- JS が全部レンダリングする -->
  </div>
  <script src="mini-sns.js"></script>
</body>
</html>

レンダリング関数の実装

// ===============================
// レンダリング関数
// ===============================

// アプリ全体を描画する(最初の一回と、状態が変わるたびに呼ぶ)
function render() {
  const app = document.getElementById("app");
  app.innerHTML = "";  // 全部消す

  app.appendChild(createHeader());
  app.appendChild(createNewPostForm());
  app.appendChild(createFilterTabs());
  app.appendChild(createPostList());
}

// ヘッダーを作る
function createHeader() {
  const totalLikes = state.posts.reduce((sum, post) => sum + post.likes, 0);

  const header = document.createElement("div");
  header.className = "header";
  header.innerHTML = `
    <h1>ミニ SNS</h1>
    <div class="header-stats">
      <span>投稿数: <strong>${state.posts.length}</strong> 件</span>
      <span>総いいね: <strong>${totalLikes}</strong> 件</span>
    </div>
  `;
  return header;
}

// 新規投稿フォームを作る
function createNewPostForm() {
  const form = document.createElement("div");
  form.className = "new-post-form";
  form.innerHTML = `
    <h3>新しい投稿</h3>
    <textarea id="new-post-text" rows="3" placeholder="いま何してる?"></textarea>
    <br><br>
    <button type="submit" id="post-submit-btn">投稿する</button>
  `;

  // ボタンにイベントリスナーを追加
  form.querySelector("#post-submit-btn").addEventListener("click", () => {
    addPost();
  });

  return form;
}

// フィルタータブを作る
function createFilterTabs() {
  const tabs = document.createElement("div");
  tabs.className = "filter-tabs";

  const filters = [
    { value: "all", label: "すべて" },
    { value: "liked", label: "いいね済み" },
  ];

  filters.forEach(({ value, label }) => {
    const tab = document.createElement("button");
    tab.className = `filter-tab ${state.filter === value ? "active" : ""}`;
    tab.textContent = label;
    tab.addEventListener("click", () => {
      state.filter = value;  // 状態を更新
      render();              // 再描画
    });
    tabs.appendChild(tab);
  });

  return tabs;
}

// 投稿リストを作る
function createPostList() {
  const list = document.createElement("div");
  list.id = "post-list";

  // フィルタリング
  const filteredPosts = state.posts.filter(post => {
    if (state.filter === "liked") return post.likedByMe;
    return true;
  });

  if (filteredPosts.length === 0) {
    list.innerHTML = "<p>表示する投稿がありません。</p>";
    return list;
  }

  filteredPosts.forEach(post => {
    list.appendChild(createPostElement(post));
  });

  return list;
}

// 1件の投稿要素を作る
function createPostElement(post) {
  const postEl = document.createElement("div");
  postEl.className = "post";
  postEl.dataset.postId = post.id;  // 後で見つけるために ID を保存

  // 著者とテキスト
  const author = document.createElement("div");
  author.className = "post-author";
  author.textContent = post.author;
  postEl.appendChild(author);

  const text = document.createElement("div");
  text.className = "post-text";
  text.textContent = post.text;
  postEl.appendChild(text);

  // アクションボタン
  const actions = document.createElement("div");
  actions.className = "post-actions";

  const likeBtn = document.createElement("button");
  likeBtn.className = `like-btn ${post.likedByMe ? "liked" : ""}`;
  likeBtn.textContent = `${post.likes}`;
  likeBtn.addEventListener("click", () => {
    toggleLike(post.id);
  });
  actions.appendChild(likeBtn);
  postEl.appendChild(actions);

  // コメントセクション
  const commentsSection = document.createElement("div");
  commentsSection.className = "comments";

  post.comments.forEach(comment => {
    const commentEl = document.createElement("div");
    commentEl.className = "comment";
    commentEl.innerHTML = `<strong>${comment.author}</strong>: ${comment.text}`;
    commentsSection.appendChild(commentEl);
  });

  // コメント入力フォーム
  const commentForm = document.createElement("div");
  commentForm.className = "comment-form";
  commentForm.innerHTML = `
    <input type="text" placeholder="コメントを入力..." class="comment-input" />
    <button type="submit" class="comment-submit">送信</button>
  `;
  commentForm.querySelector(".comment-submit").addEventListener("click", () => {
    const input = commentForm.querySelector(".comment-input");
    addComment(post.id, input.value);
  });
  commentsSection.appendChild(commentForm);

  postEl.appendChild(commentsSection);
  return postEl;
}

状態変更関数の実装

// ===============================
// 状態変更関数(Action)
// ===============================

// 新しい投稿を追加する
function addPost() {
  const textarea = document.getElementById("new-post-text");
  const text = textarea.value.trim();

  if (!text) {
    alert("投稿内容を入力してください");
    return;
  }

  // state を更新
  state.posts.unshift({
    id: state.nextId++,
    author: state.currentUser,
    text: text,
    likes: 0,
    likedByMe: false,
    comments: []
  });

  // 全部再描画
  render();
}

// いいねをトグルする
function toggleLike(postId) {
  const post = state.posts.find(p => p.id === postId);
  if (!post) return;

  if (post.likedByMe) {
    post.likes--;
    post.likedByMe = false;
  } else {
    post.likes++;
    post.likedByMe = true;
  }

  // 全部再描画
  render();
}

// コメントを追加する
function addComment(postId, text) {
  if (!text.trim()) return;

  const post = state.posts.find(p => p.id === postId);
  if (!post) return;

  post.comments.push({
    author: state.currentUser,
    text: text.trim()
  });

  // 全部再描画
  render();
}

// 初回レンダリング
render();

動きます。いいねもコメントも投稿もできます。しかし……よく見ると、深刻な問題がいくつかあります。


8.3 問題 1: 部分的な更新ができない

toggleLike() が呼ばれるたびに、render() が実行されます。render()app.innerHTML = ""全部消してから作り直します

これが引き起こす問題を考えてみましょう。

スクロール位置がリセットされる

100件の投稿が並んでいて、ユーザーが下にスクロールして50件目を読んでいたとします。そこで誰かの投稿にいいねをすると……画面の一番上に戻ってしまいます。

入力中のテキストが消える

これが特に致命的です。

⚠️ バグ再現: 入力中に別の投稿をいいね

次の手順でバグを再現できます。

  1. ある投稿のコメント入力欄に「コメントを書き始める」
  2. 入力中に、別の投稿の「いいね」ボタンをクリック
  3. render() が呼ばれてすべての DOM が作り直される
  4. 入力していたコメントが消える

これは非常に悪いユーザー体験です。現実のアプリでこれが起きたら、ユーザーは怒ってアプリを閉じるでしょう。

アニメーションが中断される

ボタンにフェードインアニメーションをつけていたとして、アニメーション中に再描画が走ると、アニメーションが最初からやり直しになります。

「全部消して作り直す」パターンの限界

前のチャプターの Todo アプリでは、このパターンでなんとかなりました。しかし、ユーザーが入力中のデータや、現在のスクロール位置など「DOM が持っている状態」が複数あるアプリでは、このパターンは通用しません。

では、全部再描画するのではなく、変更された部分だけを更新すればよいでしょうか? それが次の問題です。


8.4 問題 2: 複数の場所を更新し忘れる

「全部再描画」をやめて、変更された部分だけを手動で更新しようとすると、別の問題が起きます。

いいね数は複数の場所に表示される

toggleLike() を修正して、全体再描画をやめ、該当部分だけを更新するようにしてみましょう。

// 「賢い」更新を試みる(でも罠がある)
function toggleLikeSmart(postId) {
  const post = state.posts.find(p => p.id === postId);
  if (!post) return;

  if (post.likedByMe) {
    post.likes--;
    post.likedByMe = false;
  } else {
    post.likes++;
    post.likedByMe = true;
  }

  // ---- DOM の手動更新 ----

  // 1. 該当投稿のいいねボタンを更新
  const postEl = document.querySelector(`[data-post-id="${postId}"]`);
  if (postEl) {
    const likeBtn = postEl.querySelector(".like-btn");
    likeBtn.textContent = `${post.likes}`;
    likeBtn.className = `like-btn ${post.likedByMe ? "liked" : ""}`;
  }

  // 2. ヘッダーの総いいね数を更新
  // ← ここを忘れると、ヘッダーの数字がズレたまま!
  const totalLikes = state.posts.reduce((sum, p) => sum + p.likes, 0);
  // ヘッダーの「総いいね」テキストを更新するには、どう実装する?
  // ヘッダーの中から正しい要素を見つけて…
  const headerStats = document.querySelector(".header-stats");
  if (headerStats) {
    const spans = headerStats.querySelectorAll("span");
    // spans[1] が「総いいね」だと知っていないといけない!
    spans[1].innerHTML = `総いいね: <strong>${totalLikes}</strong> 件`;
  }

  // 3. フィルターが "liked" の場合、リストも更新が必要
  if (state.filter === "liked") {
    // post.likedByMe が false になった投稿を非表示にしなければならない
    if (!post.likedByMe) {
      postEl.remove();
    }
  }
}

このコードには問題があります。

更新が必要な箇所一覧

この小さなアプリだけで、「いいねをトグルしたとき」に更新が必要な箇所は:

  1. いいねボタンのテキストとスタイル
  2. ヘッダーの「総いいね数」
  3. フィルターが「いいね済み」の場合の表示/非表示
  4. (ページタイトルに数字を表示している場合)ブラウザタブのタイトル

現実のアプリには、これの 100 倍の依存関係があります。どこかを更新し忘れると、「数字がズレている」「チェックがついているのに実際はオフ」といった、見つけにくいバグが生まれます。

⚠️ 「更新し忘れ」は最も多いバグのひとつ

UI の不整合は、ユーザーが「このアプリは信頼できない」と感じる原因です。「いいね数が 5 なのに、ヘッダーには 4 と表示されている」……こういったバグを手動の DOM 更新で完全に防ぐのは、非常に困難です。


8.5 問題 3: DOM の状態とデータの不一致

より深刻な問題があります。DOM と JavaScript のデータが「ズレる」ことです。

ダブルクリック問題

ユーザーがいいねボタンを素早く2回クリックしたとします。

// ユーザーが素早く2回クリックした場合の問題
function toggleLike(postId) {
  const post = state.posts.find(p => p.id === postId);

  // 1回目のクリック: likedByMe = false → true に変更
  // 2回目のクリック: まだ render() が完了していない間に呼ばれる
  // この時点で post.likedByMe はすでに true に変わっている
  // → 2回クリックしたのに、1回クリックとして扱われる

  post.likedByMe = !post.likedByMe;
  post.likes = post.likedByMe ? post.likes + 1 : post.likes - 1;

  render(); // 非同期的な処理がある場合、この render が2回走ることがある
}

非同期更新との競合

いいねをサーバーに送信する処理を追加すると、さらに複雑になります。

async function toggleLikeWithServer(postId) {
  const post = state.posts.find(p => p.id === postId);

  // まず楽観的に UI を更新(ユーザー体験のため)
  post.likedByMe = !post.likedByMe;
  post.likes = post.likedByMe ? post.likes + 1 : post.likes - 1;
  render();

  try {
    // サーバーに送信(時間がかかる)
    const response = await fetch(`/api/posts/${postId}/like`, {
      method: "POST"
    });
    const data = await response.json();

    // サーバーの最新データで上書き
    post.likes = data.likes;
    post.likedByMe = data.likedByMe;

    // ここで render() を呼ぶと...
    // この 100ms の間に、ユーザーがコメント入力を始めていたら?
    render(); // 入力中のコメントが消える!
  } catch (error) {
    // 失敗した場合、元に戻す
    post.likedByMe = !post.likedByMe;
    post.likes = post.likedByMe ? post.likes + 1 : post.likes - 1;
    render(); // またコメントが消える!
  }
}

楽観的更新(Optimistic Update)を実装しようとすると、このように複雑な競合問題が発生します。これをバニラ JS で完璧に管理するのは、非常に困難です。


8.6 問題 4: コードの肥大化

ここまで実装してきたコードを振り返ってみましょう。このミニ SNS の JavaScript ファイルは、すでに 200行以上 になっています。

機能はまだほとんど追加していないのにです。

DOM 操作の行数を数えてみよう

// このアプリで使っている DOM 操作を数えてみると...

// getElementById / querySelector の呼び出し: 15回以上
document.getElementById("app")
document.getElementById("new-post-text")
document.querySelector(`[data-post-id="${postId}"]`)
document.querySelector(".like-btn")
document.querySelector(".header-stats")
// ... まだまだ続く

// createElement の呼び出し: 20回以上
document.createElement("div")
document.createElement("button")
document.createElement("input")
// ...

// addEventListener の呼び出し: 10回以上
likeBtn.addEventListener("click", ...)
commentSubmit.addEventListener("click", ...)
filterTab.addEventListener("click", ...)
// ...

// innerHTML / textContent の直接書き換え: 10回以上
header.innerHTML = `...`
likeBtn.textContent = `${post.likes}`
spans[1].innerHTML = `総いいね: <strong>${totalLikes}</strong> 件`
// ...

機能を追加するたびに、これらの行が増えていきます。そして、どれか一つの修正が別の場所を壊す「もぐら叩き」が始まります。

📝 Facebook が React を作った理由

Facebook が React を開発し始めた 2011〜2012年、Facebook のコードはまさにこの問題を抱えていました。

「通知数バッジ」の数字が 0 になっているのに、通知があるように見える——このバグが何ヶ月も直せなかったそうです。なぜなら、UI の複数の場所が「通知数」に依存しており、一方を直すともう一方が壊れるという状態だったからです。

現実の SNS アプリは、このミニ SNS の 100 倍以上複雑です。Facebookの Facebook が React を開発した理由が、少し理解できたのではないでしょうか?


8.7 命令的 UI vs 宣言的 UI

ここで、根本的な問題を整理しましょう。私たちが今まで書いてきたコードは「命令的(Imperative)」なコードです。React が採用しているのは「宣言的(Declarative)」なアプローチです。

命令的 UI(今まで書いてきた方法)

「何をするか」の手順を 1 ステップずつ命令するコード。

// 命令的アプローチ: いいねボタンがクリックされたとき
// 「何をするか」を手順書のように書く

function handleLikeClick(postId) {
  // 手順 1: state の likes を変更する
  const post = state.posts.find(p => p.id === postId);
  post.likedByMe = !post.likedByMe;
  post.likes = post.likedByMe ? post.likes + 1 : post.likes - 1;

  // 手順 2: 投稿の DOM 要素を querySelector で探す
  const postEl = document.querySelector(`[data-post-id="${postId}"]`);

  // 手順 3: その中のいいね数 span を探してテキストを更新する
  const likeCount = postEl.querySelector(".like-count");
  likeCount.textContent = post.likes;

  // 手順 4: ボタンのクラスを付け替える
  const likeBtn = postEl.querySelector(".like-btn");
  if (post.likedByMe) {
    likeBtn.classList.add("liked");
  } else {
    likeBtn.classList.remove("liked");
  }

  // 手順 5: ヘッダーの総いいね数を再計算して更新する
  const totalLikes = state.posts.reduce((sum, p) => sum + p.likes, 0);
  const totalLikesEl = document.querySelector(".total-likes");
  totalLikesEl.textContent = totalLikes;

  // 手順 6: フィルターが "liked" なら、表示を切り替える
  if (state.filter === "liked" && !post.likedByMe) {
    postEl.style.display = "none";
  } else {
    postEl.style.display = "block";
  }

  // 手順 7: もし他にもこの post.likes を表示している場所があれば...
  // ← 忘れるとバグになる!
}

このコードは「どうやって画面を変えるか」という手順を書いています。手順が増えるほど、忘れや間違いが起きやすくなります。

宣言的 UI(React のやり方)

「どんな状態のとき、どう見えるべきか」を書くコード。

// 宣言的アプローチ(参考: React コード)
// まだ完全に理解しなくて OK です。「こう書ける」というイメージだけ掴んでください。

// 投稿コンポーネント: 「このデータを渡したら、こう見える」を定義するだけ
function Post({ post, onLike }) {
  return (
    <div className="post">
      <div className="post-author">{post.author}</div>
      <div className="post-text">{post.text}</div>

      {/* ボタンの見た目は state に基づいて自動で決まる */}
      <button
        className={`like-btn ${post.likedByMe ? "liked" : ""}`}
        onClick={() => onLike(post.id)}
      >
{post.likes}
      </button>

      <div className="comments">
        {post.comments.map((comment, i) => (
          <div key={i} className="comment">
            <strong>{comment.author}</strong>: {comment.text}
          </div>
        ))}
      </div>
    </div>
  );
}

// ヘッダー: state が変われば自動で再描画される
function Header({ posts }) {
  const totalLikes = posts.reduce((sum, p) => sum + p.likes, 0);
  return (
    <div className="header">
      <h1>ミニ SNS</h1>
      <div className="header-stats">
        <span>投稿数: <strong>{posts.length}</strong></span>
        <span>総いいね: <strong>{totalLikes}</strong></span>
      </div>
    </div>
  );
}
💡 宣言的 UI の核心

宣言的 UI では「この状態のとき、UI はこう見える」を書くだけです。

状態(State)が変われば、フレームワーク(React)が「前の状態と何が変わったか」を計算して、必要な DOM だけを最小限に更新してくれます。

開発者は「どう変えるか」という手順を書く必要がなくなります。「変わったらどう見えるか」だけを書けばよいのです。

考え方の転換

命令的宣言的
「何をするか」の手順を書く「どう見えるか」を書く
DOM を直接操作するフレームワークが DOM を管理する
状態の変化に合わせて手動で更新状態を更新すれば UI が自動で追従
「どこを変えるか」を覚えておく必要があるデータだけを変えればよい
バグが見つけにくいデータの流れが明確で追跡しやすい

8.8 React が解決すること

ここまでの問題を踏まえて、React が具体的に何を解決するかをまとめます。

1. 自動再レンダリング

React では、setState() を呼ぶだけで UI が自動的に更新されます。どこの DOM を更新するかを考える必要がありません。

2. 効率的な更新(仮想 DOM)

React は変更前と変更後の「仮想的な DOM ツリー」を比較し、実際に変わった部分だけを DOM に反映します(差分更新)。「全部消して作り直す」問題と「部分的な手動更新」問題を、同時に解決します。

3. コンポーネント分割

UI を独立した「コンポーネント」に分割できます。PostComponent, CommentComponent, LikeButton などが、それぞれ自分のことだけを知っていればよくなります。

4. 一方向データフロー

データは親から子へ一方向に流れます。データがどこから来てどこへ行くかが明確で、バグの原因を追跡しやすくなります。

同じ機能を React で書くと

// ミニ SNS を React で書いた場合(参考)
// このコードをまだ理解する必要はありません。
// 「同じ機能がこれだけ短く書けるか」だけを見てください。

import { useState } from "react";

const initialPosts = [
  { id: 1, author: "鈴木花子", text: "今日はいい天気!", likes: 3, likedByMe: false, comments: [] },
  { id: 2, author: "佐藤次郎", text: "新しいカフェを見つけた", likes: 1, likedByMe: true, comments: [] },
];

// いいねボタンコンポーネント(再利用可能)
function LikeButton({ post, onLike }) {
  return (
    <button
      className={`like-btn ${post.likedByMe ? "liked" : ""}`}
      onClick={() => onLike(post.id)}
    >
{post.likes}
    </button>
  );
}

// 投稿コンポーネント(再利用可能)
function Post({ post, onLike, onComment }) {
  const [commentText, setCommentText] = useState(""); // コメント入力状態

  const handleSubmit = () => {
    if (commentText.trim()) {
      onComment(post.id, commentText);
      setCommentText(""); // 送信後にクリア
    }
  };

  return (
    <div className="post">
      <div className="post-author">{post.author}</div>
      <div className="post-text">{post.text}</div>
      <LikeButton post={post} onLike={onLike} />
      <div className="comments">
        {post.comments.map((c, i) => (
          <div key={i}><strong>{c.author}</strong>: {c.text}</div>
        ))}
        {/* コメント入力中の値が消えない! */}
        <input
          value={commentText}
          onChange={e => setCommentText(e.target.value)}
          placeholder="コメントを入力..."
        />
        <button onClick={handleSubmit}>送信</button>
      </div>
    </div>
  );
}

// メインアプリ
function App() {
  const [posts, setPosts] = useState(initialPosts);
  const [filter, setFilter] = useState("all");
  const [newPostText, setNewPostText] = useState("");

  // いいねをトグル(state を更新するだけ。DOM は React が自動で更新)
  const toggleLike = (postId) => {
    setPosts(posts.map(post =>
      post.id === postId
        ? { ...post, likedByMe: !post.likedByMe, likes: post.likedByMe ? post.likes - 1 : post.likes + 1 }
        : post
    ));
  };

  const addComment = (postId, text) => {
    setPosts(posts.map(post =>
      post.id === postId
        ? { ...post, comments: [...post.comments, { author: "田中太郎", text }] }
        : post
    ));
  };

  const addPost = () => {
    if (!newPostText.trim()) return;
    setPosts([{ id: Date.now(), author: "田中太郎", text: newPostText, likes: 0, likedByMe: false, comments: [] }, ...posts]);
    setNewPostText("");
  };

  const totalLikes = posts.reduce((sum, p) => sum + p.likes, 0);
  const filteredPosts = filter === "liked" ? posts.filter(p => p.likedByMe) : posts;

  return (
    <div>
      {/* ヘッダー: totalLikes が変わると自動で再描画 */}
      <div className="header">
        <h1>ミニ SNS</h1>
        <span>投稿数: {posts.length}</span>
        <span>総いいね: {totalLikes}</span>
      </div>

      <textarea value={newPostText} onChange={e => setNewPostText(e.target.value)} />
      <button onClick={addPost}>投稿する</button>

      <button onClick={() => setFilter("all")}>すべて</button>
      <button onClick={() => setFilter("liked")}>いいね済み</button>

      {filteredPosts.map(post => (
        <Post key={post.id} post={post} onLike={toggleLike} onComment={addComment} />
      ))}
    </div>
  );
}
📝 このコードについて

このコードをまだ理解する必要はありません。JSX(HTML のような構文)、useState.map() を使ったレンダリングなど、次の React コースで全てを学びます。

ただ、バニラ JS の 200 行以上のコードが、React では 80行以下 で書けることを感じてください。しかも、「コメント入力中にいいねしても入力が消えない」「ヘッダーの総いいねは自動で更新される」——これらの問題が、React では自然に解決されています。


✍ 演習: ブックマーク機能を追加してみよう(バニラ JS)

バニラ JS で書いたミニ SNS に、「ブックマーク」機能を追加してみましょう。

要件:

  • 各投稿に「ブックマーク」トグルボタンを追加(★/☆)
  • ヘッダーに「ブックマーク: X 件」を表示
  • フィルタータブに「ブックマーク済み」を追加
  • state には bookmarkedByMe: boolean フィールドを追加

手順:

  1. state.posts の各オブジェクトに bookmarkedByMe: false を追加
  2. createPostElement() 関数にブックマークボタンを追加
  3. toggleBookmark(postId) 関数を実装
  4. createHeader() に「ブックマーク数」を追加
  5. createFilterTabs() に「ブックマーク済み」タブを追加

やってみると気づくこと:

  • ブックマークボタンをクリックするとコメント入力が消える
  • ヘッダーの「ブックマーク数」を更新し忘れると数字がズレる
  • フィルタータブとの組み合わせが複雑になる
  • querySelector の呼び出しがさらに増える

この「辛さ」こそが、React を学ぶ動機です。この機能を React で書いたら、何行になるか想像しながらやってみましょう。


8.9 まとめ

このチャプターで、バニラ JS での状態管理の限界を体験しました。

直面した問題

  1. 全部再描画: 全体を消して作り直すと、スクロール位置や入力中のデータが失われる
  2. 更新し忘れ: 複数の場所に同じデータが表示されるとき、全部を更新し忘れる
  3. DOM との不一致: 非同期処理や高速クリックで、データと表示がズレる
  4. コードの肥大化: 機能が増えるほど、querySelectoraddEventListener の呼び出しが爆発的に増える

根本的な原因

これらはすべて、「命令的 UI」 というアプローチから来ています。DOM をどう変えるかという手順を、開発者が全部管理しなければならないことが問題の本質です。

React の解決策

React は**「宣言的 UI」** というアプローチで、これらの問題を根本的に解決します。

次のチャプターでは、React を学ぶための最後の準備として、ES Modules と npm、そして Vite について学びます。そして、いよいよ React コースへ進みましょう。

「状態管理の壁」を体験した今のあなたは、React を学ぶ準備が整っています。