✍️

【ハンズオン】React Native + Supabaseで作る!認証付きTodoアプリ開発入門

に公開

はじめに

React Nativeの学習を始めたいけれど、何から作れば良いか分からない...。そんなあなたのために、モダンなバックエンドサービス Supabase を利用した「認証付きTodoアプリ」の開発手順を、ゼロから丁寧に解説します。

この記事を終える頃には、あなたは認証機能とデータベースを連携させた、本格的なクロスプラットフォームアプリの作り方をマスターしているでしょう。

この記事で作るもの

  • 認証機能: メールアドレスとパスワードでのサインアップ&ログイン
  • Todo管理: ログインユーザーに紐づいたTodoの登録・一覧表示・更新・削除 (CRUD)
  • リアルタイム更新: データベースの変更がアプリに即座に反映される機能

ステップ0: 開発の前に(前提知識)

  • JavaScript (ES6+): async/await を含め、基本的な文法を理解していること。
  • Reactの基礎: useState, useEffect などの基本的なHooksを理解していること。

ステップ1: 開発環境の準備

まず、Expoを使ったReact Nativeの開発環境を整えます。

  1. Node.jsのインストール: 公式サイトからLTS版をインストールします。

  2. Expo CLIのインストール: ターミナルで npm install -g expo-cli を実行します。

  3. プロジェクト作成: ターミナルで以下のコマンドを実行します。

    expo init my-todo-app --template blank --npm
    cd my-todo-app
    
  4. 開発サーバーの起動: npm start を実行します。

  5. 実機確認: スマートフォンに Expo Go アプリをインストールし、ターミナルに表示されたQRコードを読み取ります。「Open up App.js...」と表示されれば成功です。

ステップ2: Supabaseのセットアップ

次に、アプリのバックエンドとなるSupabaseを準備します。

  1. Supabase公式サイトでサインアップし、新しいプロジェクトを作成します。

  2. テーブルの作成: プロジェクトページの「SQL Editor」に移動し、以下のクエリを実行してtodosテーブルを作成します。

    -- todosテーブルの作成
    create table todos (
      id bigint generated by default as identity primary key,
      user_id uuid references auth.users not null,
      task text check (char_length(task) > 0),
      is_complete boolean default false,
      inserted_at timestamp with time zone default timezone('utc'::text, now()) not null
    );
    
    -- RLS (Row Level Security) の有効化
    alter table todos enable row level security;
    
    -- 自分のTodoのみを閲覧できるポリシー
    create policy "Individuals can view their own todos." on todos
      for select using (auth.uid() = user_id);
    
    -- 自分のTodoのみを追加できるポリシー
    create policy "Individuals can insert their own todos." on todos
      for insert with check (auth.uid() = user_id);
    
    -- 自分のTodoのみを更新できるポリシー
    create policy "Individuals can update their own todos." on todos
      for update using (auth.uid() = user_id);
    
    -- 自分のTodoのみを削除できるポリシー
    create policy "Individuals can delete their own todos." on todos
      for delete using (auth.uid() = user_id);
    

    RLS (Row Level Security) は、「ユーザーは自分自身のデータしか操作できない」という重要なセキュリティルールです。

  3. APIキーの取得: 「Project Settings」>「API」で、Project URLanon public キーを控えておきます。

ステップ3: React NativeにSupabaseを導入

  1. ライブラリのインストール: ターミナルで以下のコマンドを実行します。

    npm install @supabase/supabase-js react-native-url-polyfill @react-native-async-storage/async-storage
    
    • @supabase/supabase-js: Supabaseの公式クライアント
    • react-native-url-polyfill: Expo環境でSupabaseを動作させるために必要
    • @react-native-async-storage/async-storage: ログインセッションを永続化するために必要
  2. Supabaseクライアントの初期化: プロジェクトのルートに lib/supabase.ts ファイルを作成し、以下のように編集します。

    lib/supabase.ts
    import 'react-native-url-polyfill/auto';
    import AsyncStorage from '@react-native-async-storage/async-storage';
    import { createClient } from '@supabase/supabase-js';
    
    const supabaseUrl = 'YOUR_SUPABASE_URL'; // 先ほど控えたURL
    const supabaseAnonKey = 'YOUR_SUPABASE_ANON_KEY'; // 先ほど控えたanonキー
    
    export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
      auth: {
        storage: AsyncStorage,
        autoRefreshToken: true,
        persistSession: true,
        detectSessionInUrl: false,
      },
    });
    

ステップ4: 認証機能の実装

ログイン状態をアプリ全体で管理するために、Context APIを利用します。

  1. AuthProviderの作成: components/AuthProvider.tsx を作成します。

    components/AuthProvider.tsx
    import React, { useState, useEffect, createContext, useContext, PropsWithChildren } from 'react';
    import { Session } from '@supabase/supabase-js';
    import { supabase } from '../lib/supabase';
    
    type AuthContextType = { session: Session | null; };
    
    const AuthContext = createContext<AuthContextType>({ session: null });
    
    export const AuthProvider = ({ children }: PropsWithChildren) => {
      const [session, setSession] = useState<Session | null>(null);
    
      useEffect(() => {
        supabase.auth.getSession().then(({ data: { session } }) => {
          setSession(session);
        });
    
        const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
          setSession(session);
        });
    
        return () => subscription.unsubscribe();
      }, []);
    
      return <AuthContext.Provider value={{ session }}>{children}</AuthContext.Provider>;
    };
    
    export const useAuth = () => useContext(AuthContext);
    
  2. 認証画面の作成: components/Auth.tsx を作成します。

    components/Auth.tsx
    import React, { useState } from 'react';
    import { Alert, StyleSheet, View, TextInput, Button } from 'react-native';
    import { supabase } from '../lib/supabase';
    
    export default function Auth() {
      const [email, setEmail] = useState('');
      const [password, setPassword] = useState('');
      const [loading, setLoading] = useState(false);
    
      async function signInWithEmail() {
        setLoading(true);
        const { error } = await supabase.auth.signInWithPassword({ email, password });
        if (error) Alert.alert(error.message);
        setLoading(false);
      }
    
      async function signUpWithEmail() {
        setLoading(true);
        const { error } = await supabase.auth.signUp({ email, password });
        if (error) Alert.alert(error.message);
        setLoading(false);
      }
    
      return (
        <View style={styles.container}>
          <TextInput label="Email" onChangeText={setEmail} value={email} placeholder="email@address.com" autoCapitalize="none" />
          <TextInput label="Password" onChangeText={setPassword} value={password} secureTextEntry={true} placeholder="Password" autoCapitalize="none" />
          <Button title="Sign in" disabled={loading} onPress={signInWithEmail} />
          <Button title="Sign up" disabled={loading} onPress={signUpWithEmail} />
        </View>
      );
    }
    const styles = StyleSheet.create({ /* ... */ });
    
  3. App.tsxの修正: App.tsx を修正し、認証状態で表示を切り替えます。

    App.tsx
    import React from 'react';
    import { AuthProvider, useAuth } from './components/AuthProvider';
    import Auth from './components/Auth';
    import TodoList from './components/TodoList'; // 次に作成します
    
    const AppContainer = () => {
      const { session } = useAuth();
      return session && session.user ? <TodoList /> : <Auth />;
    };
    
    export default function App() {
      return (
        <AuthProvider>
          <AppContainer />
        </AuthProvider>
      );
    }
    

ステップ5: Todo機能の実装

components/TodoList.tsx を作成し、TodoのCRUD処理を実装します。

components/TodoList.tsx
import React, { useState, useEffect } from 'react';
import { View, TextInput, Button, FlatList, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { supabase } from '../lib/supabase';
import { useAuth } from './AuthProvider';

type Todo = { id: number; task: string; is_complete: boolean; };

export default function TodoList() {
  const { session } = useAuth();
  const [todos, setTodos] = useState<Todo[]>([]);
  const [newTask, setNewTask] = useState('');

  useEffect(() => {
    fetchTodos();
  }, []);

  const fetchTodos = async () => {
    const { data, error } = await supabase.from('todos').select('*').order('id');
    if (error) console.log('error', error);
    else setTodos(data as Todo[]);
  };

  const addTodo = async () => {
    if (!newTask.trim()) return;
    const { data, error } = await supabase.from('todos').insert({ task: newTask, user_id: session!.user.id }).select();
    if (error) console.log('error', error);
    else setTodos([...todos, data[0]]);
    setNewTask('');
  };

  const toggleComplete = async (id: number, is_complete: boolean) => {
    const { error } = await supabase.from('todos').update({ is_complete: !is_complete }).match({ id });
    if (error) console.log('error', error);
    else setTodos(todos.map(todo => todo.id === id ? { ...todo, is_complete: !is_complete } : todo));
  };

  const deleteTodo = async (id: number) => {
    const { error } = await supabase.from('todos').delete().match({ id });
    if (error) console.log('error', error);
    else setTodos(todos.filter(todo => todo.id !== id));
  };

  return (
    <View style={styles.container}>
      <View style={styles.inputContainer}>
        <TextInput onChangeText={setNewTask} value={newTask} placeholder="Add a new task" />
        <Button title="Add" onPress={addTodo} />
      </View>
      <FlatList
        data={todos}
        keyExtractor={(item) => item.id.toString()}
        renderItem={({ item }) => (
          <View style={styles.todoItem}>
            <TouchableOpacity onPress={() => toggleComplete(item.id, item.is_complete)}>
              <Text style={item.is_complete ? styles.completed : {}}>{item.task}</Text>
            </TouchableOpacity>
            <Button title="Delete" onPress={() => deleteTodo(item.id)} color="red" />
          </View>
        )}
      />
      <Button title="Sign Out" onPress={() => supabase.auth.signOut()} />
    </View>
  );
}

const styles = StyleSheet.create({ /* ... */ });

ステップ6: リアルタイム機能で自動更新

TodoList.tsxuseEffect にSupabaseのリアルタイム購読機能を追加します。

components/TodoList.tsx
// ... useEffectの中身を修正
  useEffect(() => {
    fetchTodos();

    const channel = supabase.channel('todos');
    const subscription = channel
      .on('postgres_changes', { event: '*', schema: 'public', table: 'todos' }, (payload) => {
        console.log('Change received!', payload);
        fetchTodos(); // 変更があったらリストを再取得
      })
      .subscribe();

    return () => {
      supabase.removeChannel(channel);
    };
  }, []);
// ...

これで、データベースに変更があると、アプリの表示が自動で更新されるようになりました!

ステップ7: ビルドと公開

開発したアプリは、Expoのクラウドサービス EAS (Expo Application Services) を使ってビルドできます。

npm install -g eas-cli
eas login
eas build:configure
eas build --platform all

これにより、App StoreやGoogle Playに提出するためのファイルが生成されます。

おわりに

このハンズオンでは、React NativeとSupabaseを使って、基本的ながらも本格的な機能を持つTodoアプリを作成しました。認証、データベース操作、リアルタイム更新といった、現代的なアプリに不可欠な要素を体験できたはずです。

ここからさらにUIをリッチにしたり、エラーハンドリングを丁寧にするなど、改善の余地はたくさんあります。ぜひこのアプリをベースに、あなただけのオリジナル機能を追加してみてください!

Discussion