Gemcook Tech Blog
📱

React Native (Expo) + TanStack QueryでTodoアプリを作ってみた

に公開

はじめに

React / React Native (Expo) でAPI通信を行うとき、useEffectuseStateでデータ取得・更新を管理していると、ローディングやエラーハンドリングなどの処理が複雑になりがちです。
そこで登場するのがTanStack Queryです。
このライブラリを使うと、API通信の状態管理を「宣言的」に扱えるようになります。
TanStack QueryはReact / React Nativeどちらでも使えますが、今回の記事では、React Native (Expo) を使ったシンプルなTodoアプリを例に、TanStack Queryの基本的な使い方を紹介します。

TanStack Queryとは?

TanStack Queryは、ReactおよびReact NativeでAPI通信をシンプルかつ効率的に扱うための非同期状態管理ライブラリです。
以前は「React Query」と呼ばれていましたが、React以外のフレームワーク (Vue、Angular、Svelteなど) にも対応したことで「TanStack Query」として再構築されました。

公式ドキュメント
https://tanstack.com/query/latest

useEffect+useStateを使った場合

API通信を行うときは次のようなコードを書くことが多いと思います。

import { useEffect, useState } from "react";
import { Text, View } from "react-native";

type Todo = {
  id: number;
  title: string;
};

export const TodoList = () => {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const fetchTodos = async () => {
      try {
        const response = await fetch("https://your-endpoint/todos");
        if (!response.ok) {
          throw new Error("Response was not ok");
        }
        const data: Todo[] = await response.json();
        setTodos(data);
      } catch (error) {
        setError(error as Error);
      } finally {
        setLoading(false);
      }
    };

    fetchTodos();
  }, []);

  if (loading) return <Text>Loading…</Text>;
  if (error) return <Text>{error.message}</Text>;

  return (
    <View>
      {todos.map((todo) => (
        <View key={todo.id}>
          <Text>{todo.title}</Text>
        </View>
      ))}
    </View>
  );
};

TanStack Queryを使った場合

TanStack Queryを使うと、同じ処理を以下のようにシンプルに書けます。

import { useQuery } from "@tanstack/react-query";
import { Text, View } from "react-native";

type Todo = {
  id: number;
  title: string;
};

const fetchTodos = async (): Promise<Todo[]> => {
  const response = await fetch("https://your-endpoint/todos");
  if (!response.ok) {
    throw new Error("Response was not ok");
  }
  return response.json();
};

export const TodoList = () => {
  const { data, error, isLoading } = useQuery<Todo[], Error>({
    queryKey: ["todos"],
    queryFn: fetchTodos,
  });

  if (isLoading) return <Text>Loading…</Text>;
  if (error) return <Text>{error.message}</Text>;

  return (
    <View>
      {data.map((todo: Todo) => (
        <View key={todo.id}>
          <Text>{todo.title}</Text>
        </View>
      ))}
    </View>
  );
};

比較して分かる通りTanStack Queryを使用することで非同期の状態 (ローディング中やエラーなど) を簡単に管理できるようになりました。TanStack Queryの大きな利点は、こうした非同期状態を自分で一から実装する必要がなくなる点です。

TanStack Queryの特徴

1. データキャッシュと再利用

TanStack Queryは、取得したデータをキャッシュに保存します。
同じキー (queryKey) のデータを再度呼び出すときは、キャッシュから即座に返しつつ、最新データを再取得します。
これにより、UXが向上し、APIの負荷も軽減されます。
キャッシュしたデータはJavaScript内のメモリ内に保存されるため、アプリが終了・再起動された際にキャッシュがクリアされます。

2. ローディング・エラー状態などが明確

isLoading, isFetching, isError, isSuccess など、状態が全てフラグ化されています。
複数の状態を組み合わせてUIを制御しやすく、複雑な条件分岐が減ります。

3. 自動再フェッチとリトライ

ネットワーク復帰時やウィンドウ再フォーカス時に自動的に再フェッチすることが可能です。
一時的な通信エラーが起きても自動でリトライされます。

4. 更新処理 (POST / PUT / DELETE) も簡潔

データの追加・更新・削除には useMutation を使います。
完了後に invalidateQueries を呼ぶだけで一覧データを再取得できるため、「データ更新 → UI反映」の流れを自動化できます。

アプリ概要

今回は、TanStack Queryの導入を学ぶことが目的なので、完了フラグなどは省略しタイトルだけを持つTodoアプリを作成します。

環境

項目 バージョン
OS Mac OS Sequoia 15.7.1
node 22.20.0
React 19.1.0
React Native 0.81.5
Expo 54.0.23
TanStack Query 5.90.8
TypeScript 5.9.2

機能

  • Todoの全件取得 (GET)
  • Todoの追加 (POST)
  • Todoの編集 (PUT)
  • Todoの削除 (DELETE)

ディレクトリ構成

.
├── app
│   ├── _layout.tsx         # Providerを配置する
│   └── index.tsx           # トップページ
└── src
    ├── api
    │   └── todos.ts        # API通信関数
    ├── hooks
    │   └── useTodos.ts     # TanStack Queryのロジック
    ├── components
    │   └── TodoList        # UIコンポーネント
    │       ├── index.tsx
    │       └── style.ts
    └── types
        └── todo.ts         # Todoの型定義

プロジェクトのセットアップ

まずはExpoプロジェクトを作成します。

npx create-expo-app@latest todo-app

https://docs.expo.dev/get-started/create-a-project/

必要なライブラリをインストールします。

npm install @tanstack/react-query

https://tanstack.com/query/latest/docs/framework/react/installation

以下のコマンドでプロジェクトを起動し、すぐに動作確認できます。

npm run start

このコマンドを実行すると、Expoが開き、アプリを起動するためのメニューが表示されます。
表示されたQRコードをExpo Goで読み取ると、スマートフォン上で動作を確認できます。
また、エミュレーターでも動作確認できますが、本記事ではエミュレーターの初期設定手順は割愛します。

上記のように表示されれば成功です。

QueryClientのセットアップ

TanStack Queryを使うために、QueryClient をアプリ全体に提供します。
app/_layout.tsx にProvider をセットします。

app/_layout.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Slot } from "expo-router";
import { SafeAreaView } from "react-native-safe-area-context";

const queryClient = new QueryClient();

export default function RootLayout() {
  return (
    <QueryClientProvider client={queryClient}>
      <SafeAreaView>
        <Slot />
      </SafeAreaView>
    </QueryClientProvider>
  );
}

これで、TanStack Queryを使用するための準備は完了です。

ついでに、app/index.tsx も修正しておきます。
こちらは、Todoの一覧と新規追加用のフォームを表示するファイルになりますが、一旦Hello Worldを表示させておきます。

app/index.tsx
import { Text } from "react-native";

export default function TodosMainPageRoute() {
  return <Text>Hello World</Text>;
}

上記のようになっていればOKです。

Todoの型を定義

src/types/todo.ts にTodoの型を定義します。

src/types/todo.ts
export type Todo = {
  id: number;
  title: string;
};

API通信の関数を実装

src/api/todos.ts にTodoのCRUD処理を行うための関数を実装していきます。

全件取得

Todoを全件取得するための関数です。

src/api/todos.ts
import type { Todo } from "../types/todo";

const API_ENDPOINT = "https://your-endpoint/todos";

export const getTodos = async (): Promise<Todo[]> => {
  const response = await fetch(API_ENDPOINT);
  if (!response.ok) throw new Error("Failed to fetch todos");
  return response.json();
};

追加

Todoを追加するための関数です。

src/api/todos.ts
export const createTodo = async (title: string): Promise<Todo> => {
  const response = await fetch(API_ENDPOINT, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ title }),
  });
  if (!response.ok) throw new Error("Failed to create todo");
  return response.json();
};

更新

Todoを更新するための関数です。

src/api/todos.ts
export const updateTodo = async (todo: Todo): Promise<Todo> => {
  const response = await fetch(`${API_ENDPOINT}/${todo.id}`, {
    method: "PUT",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(todo),
  });
  if (!response.ok) throw new Error("Failed to update todo");
  return response.json();
};

削除

Todoを削除するための関数です。

src/api/todos.ts
export const deleteTodo = async (id: number): Promise<void> => {
  const response = await fetch(`${API_ENDPOINT}/${id}`, {
    method: "DELETE",
  });
  if (!response.ok) throw new Error("Failed to delete todo");
};

完成形

上記のコードを組み合わせた src/api/todos.ts の完成形は以下になります。

完成形のコード
src/api/todos.ts
import type { Todo } from "../types/todo";

const API_ENDPOINT = "https://your-endpoint/todos";

// 全件取得
export const getTodos = async (): Promise<Todo[]> => {
  const response = await fetch(API_ENDPOINT);
  if (!response.ok) throw new Error("Failed to fetch todos");
  return response.json();
};

// 追加
export const createTodo = async (title: string): Promise<Todo> => {
  const response = await fetch(API_ENDPOINT, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ title }),
  });
  if (!response.ok) throw new Error("Failed to create todo");
  return response.json();
};

// 更新
export const updateTodo = async (todo: Todo): Promise<Todo> => {
  const response = await fetch(`${API_ENDPOINT}/${todo.id}`, {
    method: "PUT",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(todo),
  });
  if (!response.ok) throw new Error("Failed to update todo");
  return response.json();
};

// 削除
export const deleteTodo = async (id: number): Promise<void> => {
  const response = await fetch(`${API_ENDPOINT}/${id}`, {
    method: "DELETE",
  });
  if (!response.ok) throw new Error("Failed to delete todo");
};

TanStack Queryを実装

ここでいよいよ、TanStack Queryを使った処理を実装していきます。
Todoの取得・追加・更新・削除といったAPIとのやり取りは、useTodos というカスタムフックにまとめることで、コードをシンプルに保つことができます。

全体のコード

src/hooks/useTodos.ts
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getTodos, createTodo, updateTodo, deleteTodo } from "../api/todos";
import type { Todo } from "../types/todo";

export const useTodos = () => {
  const queryClient = useQueryClient();

  // 全件取得
  const todosQuery = useQuery<Todo[], Error>({
    queryKey: ["todos"],
    queryFn: getTodos,
  });

  // 追加
  const { mutate: createTodoMutation } = useMutation({
    mutationFn: createTodo,
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
  });

  // 更新
  const { mutate: updateTodoMutation } = useMutation({
    mutationFn: updateTodo,
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
  });

  // 削除
  const { mutate: deleteTodoMutation } = useMutation({
    mutationFn: deleteTodo,
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
  });

  return {
    ...todosQuery,
    createTodoMutation,
    updateTodoMutation,
    deleteTodoMutation,
  };
};

データの取得

データの取得には、useQuery というフックを使います。

const { data, isLoading } = useQuery({
  queryKey: ["任意の文字列"], // 配列
  queryFn: fetchFunc, // データ取得の関数
});

useQuery フックには、queryKeyqueryFn を含むオブジェクトを引数として渡します。
queryKey は、データの再取得やキャッシュの管理に使用され、文字列の配列を指定します。
queryFn は、データを取得するための関数です。あらかじめ定義した関数を渡すこともできますし、直接アロー関数を記述して渡すことも可能です。
これにより、データが格納された data やローディング状況を示す isLoading フラグなどを取得することができます。

データの更新

データの追加や変更などを行うには、useMutation というフックを使います。

const mutation = useMutation({
  mutationFn: mutateFunc, // データ更新の関数
});

useMutation フックには、mutationFn を含むオブジェクトを引数として渡します。
mutationFn には、データを更新する関数を渡します。

また、次のように書き換えることで mutation に名前を付けることができます。

const { mutation: createMutate } = useMutation({
  mutationFn: mutateFunc,
});

このようにすると、createMutate という関数名で呼び出せるようになり、コードの可読性が上がります。
mutation.mutate() と書くよりも、createMutate() と書いた方が直感的に理解できます。

さらに重要なのが、データ更新後にUIを即時反映させる仕組みです。
useMutation では、onSuccess というコールバックを設定できます。

const mutation = useMutation({
  mutationFn: mutateFunc,
  onSuccess: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
});

この onSuccess は、サーバーへの更新が成功したタイミングで呼ばれます。
ここで queryClient.invalidateQueries を使うことで、["todos"] というクエリを再フェッチし、最新のデータでUIを更新できます。
これにより、ユーザーが操作した結果がすぐに画面に反映されるため、違和感のないUXを実現できます。

UIコンポーネントの実装

ここでは、Todo一覧を表示し、追加・編集・削除ができるUIコンポーネント (TodoList) を実装していきます。
先ほど作成した useTodos カスタムフックを使うことで、UI側ではデータの取得・操作関数の呼び出しだけに集中できます。

src/components/TodoList/index.tsx
import { useTodos } from "@/src/hooks/useTodos";
import { Todo } from "@/src/types/todo";
import { useState } from "react";
import { Button, FlatList, Text, TextInput, View } from "react-native";
import { styles } from "./style";

export const TodoList = () => {
  const {
    data,
    isLoading,
    error,
    createTodoMutation,
    updateTodoMutation,
    deleteTodoMutation,
  } = useTodos();

  const [title, setTitle] = useState("");
  const [editId, setEditId] = useState<number | null>(null);
  const [editTitle, setEditTitle] = useState("");

  if (isLoading) return <Text>Loading…</Text>;
  if (error) return <Text>{error.message}</Text>;

  return (
    <View style={styles.container}>
      <View style={styles.todoForm}>
        <TextInput
          placeholder="タスクを入力"
          value={title}
          onChangeText={setTitle}
          style={styles.textInput}
        />
        <Button
          title="追加"
          color="#2b7fff"
          onPress={() => {
            if (!title) return;
            createTodoMutation(title);
            setTitle("");
          }}
        />
      </View>
      <FlatList
        data={data}
        keyExtractor={(item) => item.id.toString()}
        ItemSeparatorComponent={() => <View style={{ height: 8 }}></View>}
        renderItem={({ item }: { item: Todo }) => (
          <View style={styles.todoListItem}>
            {editId === item.id ? (
              <>
                <TextInput
                  value={editTitle}
                  onChangeText={setEditTitle}
                  style={styles.textInput}
                />
                <Button
                  title="キャンセル"
                  color="#666"
                  onPress={() => {
                    setEditId(null);
                    setEditTitle("");
                  }}
                />
                <Button
                  title="保存"
                  color="#2b7fff"
                  onPress={() => {
                    if (!editTitle) return;
                    updateTodoMutation({ ...item, title: editTitle });
                    setEditId(null);
                    setEditTitle("");
                  }}
                />
              </>
            ) : (
              <>
                <Text style={styles.itemTitle}>{item.title}</Text>
                <Button
                  title="編集"
                  color="#00c951"
                  onPress={() => {
                    setEditId(item.id);
                    setEditTitle(item.title);
                  }}
                />
                <Button
                  title="削除"
                  color="#fb2c36"
                  onPress={() => deleteTodoMutation(item.id)}
                />
              </>
            )}
          </View>
        )}
      />
    </View>
  );
};
src/components/TodoList/style.ts
import { StyleSheet } from "react-native";

export const styles = StyleSheet.create({
  container: {
    paddingVertical: 40,
    paddingHorizontal: 20,
  },
  todoForm: {
    flexDirection: "row",
    gap: 8,
    marginBottom: 30,
  },
  textInput: {
    width: "100%",
    flexShrink: 1,
    backgroundColor: "#fff",
    borderWidth: 1,
    borderColor: "#e0e0e0",
    padding: 10,
  },
  todoListItem: {
    borderColor: "#e0e0e0",
    borderBottomWidth: 1,
    flexDirection: "row",
    alignItems: "center",
    paddingBottom: 8,
    paddingHorizontal: 8,
  },
  itemTitle: {
    width: "100%",
    flexShrink: 1,
    lineHeight: 20,
  },
});

コンポーネントが出来上がったので、Hello Worldを表示させていた app/index.tsx も修正します。

app/index.tsx
- import { Text } from "react-native";
+ import { TodoList } from "@/src/components/TodoList";

export default function TodosMainPageRoute() {
-   return <Text>Hello World</Text>;
+   return <TodoList />;
}

このようになりました。
今回はデータの取得や更新処理などのロジック部分に重きを置いており、スタイリングはシンプルにしていますので、自由にデザインは変えてみてください。

実際に動かしてみる前に、データ取得や更新処理の部分を少しだけ解説しておきます。

全件取得・一覧表示

const { data } = useTodos();

<FlatList
    data={data}
    keyExtractor={(item) => item.id.toString()}
    ItemSeparatorComponent={() => <View style={{ height: 8 }}></View>}
    renderItem={({ item }: { item: Todo }) => (
        // 表示させたい内容
    )}
/>

useQuery で取得したデータを FlatListdata propsに渡すことで、Todoの一覧をリストとして表示することができます。
FlatList の使い方は、React Nativeの公式ドキュメントを参照してください。

追加

<Button
  title="追加"
  color="#2b7fff"
  onPress={() => {
    if (!title) return;
    createTodoMutation(title);
    setTitle("");
  }}
/>

createTodoMutation を呼ぶだけで、新規作成 → データの再取得まで完了します。

更新

<Button
  title="保存"
  color="#2b7fff"
  onPress={() => {
    if (!editTitle) return;
    updateTodoMutation({ ...item, title: editTitle });
    setEditId(null);
    setEditTitle("");
  }}
/>

updateTodoMutation に更新対象のTodoをそのまま渡すだけで更新が完了します。
更新の場合は、更新後のタイトルだけではなく「どのTodoが更新対象なのかという情報 (ID)」も必要なので、Todoをそのまま渡すことに注意しましょう。

削除

<Button
  title="削除"
  color="#fb2c36"
  onPress={() => deleteTodoMutation(item.id)}
/>

deleteTodoMutation にIDを渡すだけで対象のTodoの削除が完了します。

実際に動かしてみる

ここまでで、Todoアプリの実装は一通り完了しました。
最後に、実際にアプリを起動し、一覧表示・追加・更新・削除が正しく動作するか確認してみましょう。

実行方法は以下の通りです。

npm run start

アプリが起動したら、表示されたQRコードをExpo Goで読み取ってください。

一覧表示・追加 更新 削除

これで、Todoアプリの一連の処理 (一覧表示・追加・更新・削除) が問題なく動作することを確認できました。
フロントとサーバーがしっかり連携し、TanStack Queryを通してデータがリアルタイムに更新されていく動きが見られたと思います。

まとめ

今回は、Expo + TanStack Queryを使って、シンプルながら実用的なTodoアプリを構築しました。
TanStack Queryを通して、データ取得・追加・更新・削除といった一連のCRUD処理が最小限のコードで安全に扱えることが体験できたのではないでしょうか。

実装してみて感じたのは、「TanStack Query を使うことで API 通信まわりの面倒ごとがほとんど気にならなくなる」という点です。
キャッシュ、ローディング状態、エラー管理など、普段苦労しがちな部分を自動でカバーしてくれるのはやはり強力ですね。

この記事はチュートリアル的な内容でしたが、フロントとサーバーがどんな流れで連携し、どこに責務を分けて実装すると開発しやすいのか、そのイメージが掴める内容になっていれば嬉しいです。

ここからさらにUIを整えたり、機能を追加したりして、自分のアプリとして進化させてみてください。
最後まで読んでいただき、ありがとうございました。

Gemcook Tech Blog
Gemcook Tech Blog

Discussion