🦔

TanStack Routerを使用してフォームを作ってみた

に公開

はじめに

TanStackは、React をはじめとする JavaScript フレームワークでよく使われる高品質なライブラリ群の集合体です。

Create React Appが非推奨であるため、個人学習でTanStack Routerをインストールして使用してみました。

https://zenn.dev/sonicmoov/articles/deprecated-create-react-app

今回紹介する TanStack Router v1 は、Search Paramをデフォルトで型安全に扱えるのが特徴。

この記事では、TanStack Routerを使って、

  • 検索フォームからデータを入力
  • 入力した値をクエリパラメータとして遷移先に渡す
  • 遷移先でその値を取得・表示する

というシンプルなフォームを実装しました。

完成したもの

作ったのは以下のような簡単なアンケートフォームです。

  • 名前、年齢、性別、コメントの4項目
  • 入力内容を /result ページに遷移して表示
  • 入力内容はURLクエリ(search params)で渡す

TanStack Router の useNavigate() で search を指定して遷移し、useSearch() で受け取るという構成です。

入力フォームページ 入力後 結果表示ページ

使用技術

  • React 18+
  • TanStack Router v1
  • TypeScript
  • MUI (Material UI)

フォーム実装コード(Form.tsx)

src/components/templates/Form.tsx
// src/components/templates/Form.tsx
const Form = () => {
  const [form, setForm] = useState({
    name: "",
    age: "",
    gender: "",
    comment: "",
  });

  const handleChange = (
    e:
      | SelectChangeEvent<string>
      | ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ) => {
    const { name, value } = e.target;
    setForm({ ...form, [name]: value });
  };

  const navigate = useNavigate();
  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();
    navigate({
      to: "/result",
      search: form,
    });
  };

  return (
    <Container>
      <Box
        component="form"
        sx={{
          paddingTop: 4,
          paddingBottom: 4,
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
        }}
        onSubmit={handleSubmit}
      >
        <Typography variant="h1" sx={{ color: "black", fontSize: 24 }}>
          フォーム提出
        </Typography>

        <Box sx={{ width: "100%", mt: 2 }}>
          <FormLabel
            component="legend"
            sx={{
              textAlign: "left",
              display: "inline-block",
              width: "100%",
              color: "black",
            }}
          >
            名前
          </FormLabel>
          <TextField
            name="name"
            label="名前"
            variant="outlined"
            value={form.name}
            onChange={handleChange}
            fullWidth
          />
        </Box>

        <Box sx={{ width: "100%", mt: 2 }}>
          <InputLabel id="demo-simple-select-label" sx={{ color: "black" }}>
            年齢
          </InputLabel>
          <Select
            labelId="demo-simple-select-label"
            id="demo-simple-select"
            name="age"
            label="年齢"
            value={form.age}
            onChange={handleChange}
            fullWidth
          >
            <MenuItem value="10代">10代</MenuItem>
            <MenuItem value="20代">20代</MenuItem>
            <MenuItem value="30代">30代</MenuItem>
          </Select>
        </Box>

        <Box sx={{ width: "100%", mt: 2 }}>
          <FormLabel
            component="legend"
            sx={{
              textAlign: "left",
              display: "inline-block",
              width: "100%",
              color: "black",
            }}
          >
            性別
          </FormLabel>
          <RadioGroup
            row
            name="gender"
            value={form.gender}
            onChange={handleChange}
          >
            <FormControlLabel
              sx={{ color: "black" }}
              value="女性"
              control={<Radio />}
              label="女性"
            />
            <FormControlLabel
              sx={{ color: "black" }}
              value="男性"
              control={<Radio />}
              label="男性"
            />
            <FormControlLabel
              sx={{ color: "black" }}
              value="その他"
              control={<Radio />}
              label="その他"
            />
          </RadioGroup>
        </Box>
        <TextField
          name="comment"
          label="コメント"
          placeholder="コメントを入力してください"
          multiline
          rows={4}
          value={form.comment}
          onChange={handleChange}
          fullWidth
          margin="normal"
        />

        <Button type="submit" variant="contained" color="primary" fullWidth>
          送信
        </Button>
      </Box>
    </Container>
  );
};

export default Form;

結果表示ページ(Result.tsx)

src/components/templates/Result.tsx
const Result = () => {
  const search = useSearch({ from: "/result" });

  return (
    <Container>
      <Box
        sx={{
          paddingTop: 4,
          paddingBottom: 4,
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
        }}
      >
        <Typography variant="h1" sx={{ color: "black", fontSize: 24 }}>
          検索結果
        </Typography>

        <Box sx={{ width: "100%", mt: 2, color: "black" }}>
          名前: {search.name}
        </Box>
        <Box sx={{ width: "100%", mt: 2, color: "black" }}>
          年齢: {search.age}
        </Box>
        <Box sx={{ width: "100%", mt: 2, color: "black" }}>
          性別: {search.gender}
        </Box>
        <Box sx={{ width: "100%", mt: 2, color: "black" }}>
          コメント: {search.comment}
        </Box>
      </Box>
    </Container>
  );
};

export default Result;

型安全を担保する validateSearch

src/routes/result.tsx
type SearchParams = {
  name: string;
  age: string;
  gender: string;
  comment: string;
};

export const Route = createFileRoute("/result")({
  validateSearch: (search: Record<string, unknown>): SearchParams => {
    return {
      name: String(search.name ?? ""),
      age: String(search.age ?? ""),
      gender: String(search.gender ?? ""),
      comment: String(search.comment ?? ""),
    };
  },
  component: ResultComponent,
});

function ResultComponent() {
  return (
    <div className="bg-white">
      <Result />
    </div>
  );
}

型安全にSearch Paramを管理する点については下記の記事を参考にしました。

https://tech.buysell-technologies.com/entry/adventcalendar2024-12-13

これにより、useSearch() を使うだけで search.name などの型が推論され、補完も効きます。

他ライブラリとの違い

ライブラリ Search Paramsの型安全 状態管理 学習コスト
TanStack Router validateSearch により型安全 状態管理と密結合せず使える 中程度
React Router ❌ 自前で型ガード or Zod等を使う必要あり useSearchParams() は文字列扱い
SWR ❌ クエリには型安全なし(主にfetch用) fetch時のキャッシュが強み

ポイント:
TanStack Router はルーティングと search param の管理が一体となっており、そのまま型安全に扱える点が強力です。

つまずいたこと

  • useSearch()を使うには、事前にvalidateSearch()を定義しておかないと型エラーになる
  • RadioやSelectなど、Formのname属性の設定を忘れると値が更新されない

良かったところ

  • validateSearch() による search paramsの型安全性
  • フォーム送信後に navigate() で状態遷移+URL更新がスムーズ
  • ルーティングと状態管理が統一されていて読みやすい

改善点・注意点

  • ドキュメントがまだ少なめ(React Routerより情報が少ない)
  • 導入コストがやや高め(TypeScriptに慣れていないと辛い)
  • SWRやReact Queryとの役割分担が最初はやや混乱しやすい

まとめ

TanStack Routerは、フォームや検索機能など「URLと状態が強く結びつくUI」に非常に向いていると感じました。

型安全でURL連動型の開発がしたい場合、React Routerよりも一歩進んだ体験ができます。今後はさらに他の機能も使用し、理解を深めたいと思います。

Discussion