本文へスキップ
データを送る

Part 9 · state を共有する

54 / 64

データを送る

取ってくるのとは違う。二重送信と、送ったあとの画面

前の章までは取ってくる話でした。 こんどは送る話です。

取ってくるのは何度やっても構いません。 ですが送るのは、そうはいきません。 2 回送れば 2 回注文されます。

この違いが、書き方を変えます。

送るときに書き足すもの

Part 0 でやった fetch に、3 つ足すだけです。

await fetch("/api/messages", {
  method: "POST",                                // 1. 送ると宣言する
  headers: { "Content-Type": "application/json" }, // 2. 形式を伝える
  body: JSON.stringify({ text }),                // 3. 中身を文字列にして積む
});
  • method … 何も書かないと「取ってくる」 (GET)になります
  • headers … 「JSON を送ります」という申告。 これがないと、サーバーが中身を読めないことがあります
  • body … オブジェクトはそのままでは送れません。JSON.stringify で文字列に変えます

send-form.tsx

"use client";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";

// Part 4 でやった形。真偽値を並べず、1 つの state にまとめる
type Status = "idle" | "sending" | "done" | "error";

export function SendForm() {
  const [text, setText] = useState("");
  const [status, setStatus] = useState<Status>("idle");
  const [message, setMessage] = useState("");

  const send = async () => {
    setStatus("sending");
    setMessage("");

    try {
      const response = await fetch("/api/messages", {
        // 取ってくるときと違い、送るときは書くことが増える
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text }),
      });

      const data = await response.json();

      if (!response.ok) {
        // サーバーが返してきた理由をそのまま見せる
        throw new Error(data.message ?? "送信に失敗しました");
      }

      setStatus("done");
      setMessage(`送信しました(id: ${data.id})`);
      setText("");
    } catch (error) {
      setStatus("error");
      setMessage(error instanceof Error ? error.message : "送信に失敗しました");
    }
  };

  return (
    <div className="flex flex-col gap-3">
      <Input
        placeholder="ひとこと(「エラー」と入れると失敗します)"
        aria-label="ひとこと(「エラー」と入れると失敗します)"
        value={text}
        onChange={(event) => setText(event.target.value)}
      />

      {/* 送信中は押せなくする。これが二重送信を防ぐ */}
      <Button size="sm" onClick={send} disabled={status === "sending"}>
        {status === "sending" ? "送信中…" : "送信"}
      </Button>

      {message && (
        <p
          className={
            status === "error" ? "text-sm text-destructive" : "text-sm text-emerald-700 dark:text-emerald-400"
          }
        >
          {message}
        </p>
      )}
    </div>
  );
}

送信中を持つ

送信には時間がかかります。 その間何も起きていないように見えると、 人はもう一度押します。

Part 4 でやった形をそのまま使います。

type Status = "idle" | "sending" | "done" | "error";

真偽値を並べないのは、「送信中なのに成功と出ている」を作れなくするためでした。 通信のように途中で失敗しうる処理では、 この形がとくに効きます。

送って、結果を出す直した例

「エラー」という文字を入れると失敗します

3 つ試してみてください。

  • 普通に送る … ボタンが「送信中…」になって押せなくなります
  • 空のまま送る … サーバーが断ってきます(400)
  • 「エラー」と入れて送る … サーバー側で失敗します(500)

どれもアプリは壊れません。失敗も「起こりうること」として最初から書いてあるからです。

send-form.tsx

"use client";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";

// Part 4 でやった形。真偽値を並べず、1 つの state にまとめる
type Status = "idle" | "sending" | "done" | "error";

export function SendForm() {
  const [text, setText] = useState("");
  const [status, setStatus] = useState<Status>("idle");
  const [message, setMessage] = useState("");

  const send = async () => {
    setStatus("sending");
    setMessage("");

    try {
      const response = await fetch("/api/messages", {
        // 取ってくるときと違い、送るときは書くことが増える
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text }),
      });

      const data = await response.json();

      if (!response.ok) {
        // サーバーが返してきた理由をそのまま見せる
        throw new Error(data.message ?? "送信に失敗しました");
      }

      setStatus("done");
      setMessage(`送信しました(id: ${data.id})`);
      setText("");
    } catch (error) {
      setStatus("error");
      setMessage(error instanceof Error ? error.message : "送信に失敗しました");
    }
  };

  return (
    <div className="flex flex-col gap-3">
      <Input
        placeholder="ひとこと(「エラー」と入れると失敗します)"
        aria-label="ひとこと(「エラー」と入れると失敗します)"
        value={text}
        onChange={(event) => setText(event.target.value)}
      />

      {/* 送信中は押せなくする。これが二重送信を防ぐ */}
      <Button size="sm" onClick={send} disabled={status === "sending"}>
        {status === "sending" ? "送信中…" : "送信"}
      </Button>

      {message && (
        <p
          className={
            status === "error" ? "text-sm text-destructive" : "text-sm text-emerald-700 dark:text-emerald-400"
          }
        >
          {message}
        </p>
      )}
    </div>
  );
}

二重送信を止める

disabled の 1 行を外すと、どうなるか。

止めていないとうまくいかない例

連打してみる

送られた回数: 0

押した回数だけ送られます。これが注文ボタンなら、そのぶん注文が入ります。

<Button onClick={send} disabled={status === "sending"}>

直すのはこの 1 行です。送信中は押せなくする。status をきちんと持っていれば、書くのはこれだけです。

double-send.tsx

"use client";

import { Button } from "@/components/ui/button";
import { useState } from "react";

export function DoubleSend() {
  const [sent, setSent] = useState<number[]>([]);

  // 送信中かどうかを持っていない。だから何度でも押せてしまう
  const send = async () => {
    const response = await fetch("/api/messages", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ text: "注文" }),
    });
    const data = await response.json();

    setSent((current) => [...current, data.id]);
  };

  return (
    <div className="flex flex-col gap-3">
      <Button size="sm" variant="outline" onClick={send}>
        注文する(連打してみる)
      </Button>

      <p className="text-sm">
        送られた回数: <strong>{sent.length}</strong>
      </p>
    </div>
  );
}

送ったあと、画面をどうするか

送信が成功したら、たいてい画面も変わるべきです。 投稿したなら一覧に増えているはずです。

ここで、前の章の話が効いてきます。

// ✕ 送ったデータを、自分で一覧に足す
setMessages([...messages, newMessage]);

// ◯ サーバーに取り直させる
mutate();

前の章の「取ってきたデータを state に写さない」と 同じ理由です。自分で足すと、サーバーが実際にどう保存したかを知らないまま画面を作ることになります。

id の採番、並び順、サーバー側で足された項目。ずれる要素はいくらでもあります。取り直せば、必ず本物と一致します。

send-form.tsx

"use client";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";

// Part 4 でやった形。真偽値を並べず、1 つの state にまとめる
type Status = "idle" | "sending" | "done" | "error";

export function SendForm() {
  const [text, setText] = useState("");
  const [status, setStatus] = useState<Status>("idle");
  const [message, setMessage] = useState("");

  const send = async () => {
    setStatus("sending");
    setMessage("");

    try {
      const response = await fetch("/api/messages", {
        // 取ってくるときと違い、送るときは書くことが増える
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text }),
      });

      const data = await response.json();

      if (!response.ok) {
        // サーバーが返してきた理由をそのまま見せる
        throw new Error(data.message ?? "送信に失敗しました");
      }

      setStatus("done");
      setMessage(`送信しました(id: ${data.id})`);
      setText("");
    } catch (error) {
      setStatus("error");
      setMessage(error instanceof Error ? error.message : "送信に失敗しました");
    }
  };

  return (
    <div className="flex flex-col gap-3">
      <Input
        placeholder="ひとこと(「エラー」と入れると失敗します)"
        aria-label="ひとこと(「エラー」と入れると失敗します)"
        value={text}
        onChange={(event) => setText(event.target.value)}
      />

      {/* 送信中は押せなくする。これが二重送信を防ぐ */}
      <Button size="sm" onClick={send} disabled={status === "sending"}>
        {status === "sending" ? "送信中…" : "送信"}
      </Button>

      {message && (
        <p
          className={
            status === "error" ? "text-sm text-destructive" : "text-sm text-emerald-700 dark:text-emerald-400"
          }
        >
          {message}
        </p>
      )}
    </div>
  );
}

待たせたくないとき(楽観的更新)

「送る → 待つ → 取り直す」は正しいのですが、そのあいだ画面が止まって見えます。 0.8 秒でも、連続で操作すると気になります。

チャットの送信や「いいね」のように、ほぼ失敗しない操作では、別のやり方をします。

const { data, mutate } = useSWR("/api/messages", fetcher);

// 先に画面を更新してしまい、あとから本物と合わせる
await mutate(
  // 送る処理。返り値はキャッシュに使わない
  fetch("/api/messages", { method: "POST", ... }),
  {
    // 送信中のあいだ、こう見せておく
    optimisticData: [...data, { id: "仮", text }],
    // POST の返り値で一覧を上書きしない
    populateCache: false,
    // 代わりに、終わったら取り直す
    revalidate: true,
  },
);

成功したことにして、先に画面を書き換えます。通信はそのうしろで走ります。 これを楽観的更新と呼びます。 「たぶん成功するだろう」と見込んで進めるからです。

  • 成功したとき 取り直した本物で、そっと置き換わる。 利用者から見れば、最初から成功していたのと同じ
  • 失敗したとき 仮の表示は自動で元に戻ります(既定の動きです)。 ただし戻したことを利用者に伝えるのは自分の仕事

send-form.tsx

"use client";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";

// Part 4 でやった形。真偽値を並べず、1 つの state にまとめる
type Status = "idle" | "sending" | "done" | "error";

export function SendForm() {
  const [text, setText] = useState("");
  const [status, setStatus] = useState<Status>("idle");
  const [message, setMessage] = useState("");

  const send = async () => {
    setStatus("sending");
    setMessage("");

    try {
      const response = await fetch("/api/messages", {
        // 取ってくるときと違い、送るときは書くことが増える
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text }),
      });

      const data = await response.json();

      if (!response.ok) {
        // サーバーが返してきた理由をそのまま見せる
        throw new Error(data.message ?? "送信に失敗しました");
      }

      setStatus("done");
      setMessage(`送信しました(id: ${data.id})`);
      setText("");
    } catch (error) {
      setStatus("error");
      setMessage(error instanceof Error ? error.message : "送信に失敗しました");
    }
  };

  return (
    <div className="flex flex-col gap-3">
      <Input
        placeholder="ひとこと(「エラー」と入れると失敗します)"
        aria-label="ひとこと(「エラー」と入れると失敗します)"
        value={text}
        onChange={(event) => setText(event.target.value)}
      />

      {/* 送信中は押せなくする。これが二重送信を防ぐ */}
      <Button size="sm" onClick={send} disabled={status === "sending"}>
        {status === "sending" ? "送信中…" : "送信"}
      </Button>

      {message && (
        <p
          className={
            status === "error" ? "text-sm text-destructive" : "text-sm text-emerald-700 dark:text-emerald-400"
          }
        >
          {message}
        </p>
      )}
    </div>
  );
}

理解できたか確かめる

確認クイズ

fetch でデータを送るとき、body に書くのは?

確認クイズ

二重送信を防ぐのに、画面側の disabled だけで十分?

確認クイズ

投稿が成功したあと、一覧を更新する方法として良いのは?

send-form.tsx

"use client";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";

// Part 4 でやった形。真偽値を並べず、1 つの state にまとめる
type Status = "idle" | "sending" | "done" | "error";

export function SendForm() {
  const [text, setText] = useState("");
  const [status, setStatus] = useState<Status>("idle");
  const [message, setMessage] = useState("");

  const send = async () => {
    setStatus("sending");
    setMessage("");

    try {
      const response = await fetch("/api/messages", {
        // 取ってくるときと違い、送るときは書くことが増える
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text }),
      });

      const data = await response.json();

      if (!response.ok) {
        // サーバーが返してきた理由をそのまま見せる
        throw new Error(data.message ?? "送信に失敗しました");
      }

      setStatus("done");
      setMessage(`送信しました(id: ${data.id})`);
      setText("");
    } catch (error) {
      setStatus("error");
      setMessage(error instanceof Error ? error.message : "送信に失敗しました");
    }
  };

  return (
    <div className="flex flex-col gap-3">
      <Input
        placeholder="ひとこと(「エラー」と入れると失敗します)"
        aria-label="ひとこと(「エラー」と入れると失敗します)"
        value={text}
        onChange={(event) => setText(event.target.value)}
      />

      {/* 送信中は押せなくする。これが二重送信を防ぐ */}
      <Button size="sm" onClick={send} disabled={status === "sending"}>
        {status === "sending" ? "送信中…" : "送信"}
      </Button>

      {message && (
        <p
          className={
            status === "error" ? "text-sm text-destructive" : "text-sm text-emerald-700 dark:text-emerald-400"
          }
        >
          {message}
        </p>
      )}
    </div>
  );
}

この章のまとめ

  • 送るときは method / headers / body の 3 つを足す
  • 送るのは、取ってくるのと違って繰り返せない
  • status を 1 つ持ち、送信中は押せなくする
  • 画面側の対策は親切のため。正しさはサーバー側で守る
  • 送ったあとは自分で足さず、取り直す

send-form.tsx

"use client";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";

// Part 4 でやった形。真偽値を並べず、1 つの state にまとめる
type Status = "idle" | "sending" | "done" | "error";

export function SendForm() {
  const [text, setText] = useState("");
  const [status, setStatus] = useState<Status>("idle");
  const [message, setMessage] = useState("");

  const send = async () => {
    setStatus("sending");
    setMessage("");

    try {
      const response = await fetch("/api/messages", {
        // 取ってくるときと違い、送るときは書くことが増える
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text }),
      });

      const data = await response.json();

      if (!response.ok) {
        // サーバーが返してきた理由をそのまま見せる
        throw new Error(data.message ?? "送信に失敗しました");
      }

      setStatus("done");
      setMessage(`送信しました(id: ${data.id})`);
      setText("");
    } catch (error) {
      setStatus("error");
      setMessage(error instanceof Error ? error.message : "送信に失敗しました");
    }
  };

  return (
    <div className="flex flex-col gap-3">
      <Input
        placeholder="ひとこと(「エラー」と入れると失敗します)"
        aria-label="ひとこと(「エラー」と入れると失敗します)"
        value={text}
        onChange={(event) => setText(event.target.value)}
      />

      {/* 送信中は押せなくする。これが二重送信を防ぐ */}
      <Button size="sm" onClick={send} disabled={status === "sending"}>
        {status === "sending" ? "送信中…" : "送信"}
      </Button>

      {message && (
        <p
          className={
            status === "error" ? "text-sm text-destructive" : "text-sm text-emerald-700 dark:text-emerald-400"
          }
        >
          {message}
        </p>
      )}
    </div>
  );
}