react+Supabase

Reactのチュートリアル記事です。先日までご紹介した、名刺アプリ Supabase連携(前編・後編)の拡張編と題して、後編までに開発したものを、更にブラッシュアップさせてみたいと思います。
具体的には、以下内容です。

  • IDのローカルストレージへの格納、カスタムフックの利用
  • スキル選択肢の、Supabaseテーブルからの読込とセット
  • Supabaseの複数テーブル結合
  • useContextによるstate集約管理

これまでの記事は以下です。

はじめに

本記事は、Reactの初学者向けのチュートリアルコンテンツです。初学者向けとは言え、データの格納や認証にBaaS(Backend as a Service)であるSupabaseを利用したものとなっており、バックエンド処理も入ってきてますので、難易度はやや高いかも知れません。
Reactコードの記述、Supabaseの認証処理の実装、及び実際にアプリケーションをデプロイしてユーザーが使えるリリースまでを行えます。

React環境は、Vite+TypeScript
CSS+UIツールとしては、Chakra UI、アイコンとして、React Icons
BaaSとしてSupabase、ホスティングサービスはGoogleのFirebaseを利用しています。
冒頭に記載の通り、[React] チュートリアル 名刺アプリ Supabase連携(前編・後編)の続編となりますので、後編時点のコンポーネント、コードからスタートします。

アプリの構造は下図のイメージです。

1. IDデータの格納とカスタムフック

最初は、IDデータのローカルストレージへの保管と読込機能の作成を行います。現時点では、Cardsページでリロードを実施すると、cardDataステートが初期化されてしまう(空になる)為、名刺情報の中身が何もない状態で表示されてしまいます。これを防止する為、入力したID情報をローカルストレージに格納し、Cardsコンポーネントのマウント時にローカルストレージのデータをチェックし、保管されていればデータを読み込む処理を追加します。

1.1 カスタムフックの作成

この機能の実装は、カスタムフックで行いたいと思います。カスタムフックは、コンポーネントの複雑化を防ぐ、Viewとロジックを分離することが出来る、機能を再利用できる等のメリットがあります。

まず、/src配下に新たにhooksというフォルダを作成します。そのフォルダの中に、useLocalStorage.tsと言うファイルを作成します。

カスタムフックの名前は、useで始める必要があります。React はカスタムフックもルールを違反してるかどうかを自動でチェックしますが、この命名規則を守らないとカスタムフックかどうか判別できなくなりチェックもできなくなってしまいます。

useLocalStorage.tsに以下コードを記載します。下記はコード全文です。

// /src/hooks/useLocalStorage.ts

type UseLocalStorage = () => {
    setItemWithExpiry: (key: string, value: string, ttl: number) => void
    getItemWithExpiry: (key: string) => {
        value: string;
        expiry: number;
    } | null
}

export const useLocalStorage: UseLocalStorage = () => {

    const setItemWithExpiry = (key: string, value: string, ttl: number) => {
        const now = new Date();

        // 保存するオブジェクトに、有効期限を追加
        const item = {
            value: value,
            expiry: now.getTime() + ttl, // 現在時刻から有効期限分加算
        };

        localStorage.setItem(key, JSON.stringify(item));
    };

    // データを取得する関数
    const getItemWithExpiry = (key: string) => {
        const itemStr = localStorage.getItem(key);

        // キーが存在しない場合
        if (!itemStr) {
            return null;
        }

        const item: {
            value: string;
            expiry: number;
        } = JSON.parse(itemStr);
        const now = new Date();

        // 有効期限が切れている場合
        if (now.getTime() > item.expiry) {
            localStorage.removeItem(key);
            return null;
        }
        return item;
    };

    return {
        setItemWithExpiry,
        getItemWithExpiry
    }

}

解説していきます。
まず、冒頭の型定義です。

// /src/hooks/useLocalStorage.ts

type UseLocalStorage = () => {//関数としての型定義 (() => {})
    setItemWithExpiry: (key: string, value: string, ttl: number) => void //関数型、及び引数、key, value, ttl の型定義
    getItemWithExpiry: (key: string) => {//関数型、及び引数 key, オブジェクト value,expiry の型定義、データが無い場合もあるので、nullも定義
        value: string;
        expiry: number;
    } | null
}

useLocalStorageの型をUseLocalStorageとして定義しています。
これは関数に対する型定義となりますので、() => {} の形で記載しています。
その下に、ローカルストレージにデータを格納する関数setItemWithExpiryと、ローカルストレージからデータを取得する関数、getItemWithExpiryの関数型定義と、それぞれの引数、オブジェクトの型を定義しています。

続いてコンポーネントの中身、それぞれの関数の処理についてです。

// /src/hooks/useLocalStorage.ts

export const useLocalStorage: UseLocalStorage = () => {//useLocalStorageをUseLocalStorageの型を持つコンポーネントとして定義、エクスポート

    //ローカルストレージにデータ格納を行う関数
    const setItemWithExpiry = (key: string, value: string, ttl: number) => {
        const now = new Date();//現在の時間を取得

        // 保存するオブジェクトに、有効期限を追加
        const item = {
            value: value,
            expiry: now.getTime() + ttl, // 現在時刻から有効期限分加算
        };

        localStorage.setItem(key, JSON.stringify(item));//ローカルストレージに、keyの名前で、itemをJSON形式で格納
    };

    // ローカルストレージからデータを取得する関数
    const getItemWithExpiry = (key: string) => {
        const itemStr = localStorage.getItem(key);//keyの名前の値をローカルストレージより取得

        // キーが存在しない場合
        if (!itemStr) {
            return null;//nullを返し処理終了
        }

        const item: {//データ取得できた場合は、JSON形式から、value、expiryの名前で値をitemに格納
            value: string;
            expiry: number;
        } = JSON.parse(itemStr);

        const now = new Date();//現在の時間を取得

        // 有効期限が切れている場合
        if (now.getTime() > item.expiry) {
            localStorage.removeItem(key);//ローカルストレージよりデータを削除
            return null;//nullを返し処理終了
        }
        return item;//期限切れてなければ、itemをリターン
    };

    return {//setItemWithExpiryとgetItemWithExpiryをリターン
        setItemWithExpiry,
        getItemWithExpiry
    }

}

冒頭、コンポーネントの定義の箇所は、useLocalStorageコンポーネントを先に型定義した、UseLocalStorageの型を持つコンポーネントとして定義、エクスポート(export)しています。export定義することにより、このコンポーネントを他のコンポーネントから利用出来るようになります。

続いて、ローカルストレージへのデータ格納処理を行う、setItemWithExpiryの処理です。
今回、ローカルストレージへのID情報格納については、有効期限を設けるようにしています。
このため、現在の日時と時刻を取得し、expiryとして、ttlを引数とし、加算した有効期限をセットしています。ローカルストレージへのデータ保管は、JavaScriptの関数、Storage: setItem() メソッドを利用しています。

処理の流れは以下の通りです。

  • 現在の時刻を取得
  • 保存するデータをvalueとし、有効期限をexpiryとして、取得した現在時刻にttlを加算した日時をitemにセット
  • localStorage.setItemにて、keyの名前でデータ(value、expiry)をローカルストレージに、JSON形式で格納

次に、ローカルストレージからデータを取得する、getItemWithExpiryです。ここでは、データを取得の上、setItemWithExpiryによるデータ格納時に設けた有効期限のチェックを行っています。有効期限が切れていれば、ローカルストレージのデータを削除しています。ローカルストレージの取得、削除は、先の格納時と同様、JavaScriptのStorageインタフェースを利用してます。getItemとremoveItemです。

処理の流れは以下の通りです。

  • keyの名前の値をローカルストレージより取得
  • データが存在しない場合は、処理終了
  • データが存在する場合は、JSON形式のデータを、value、expiryの名前でitemに格納
  • 現在の時刻を取得し、有効期限(expiry)と比較
  • 有効期限が切れていれば、ローカルストレージデータを削除
  • 期限が有効であれば、itemをリターン

最後に、定義したローカルストレージ処理関数の、setItemWithExpiryとgetItemWithExpiryをリターンしています。これにより、useLocalStorageを利用することで、他コンポーネントから、setItemWithExpiryとgetItemWithExpiryを実行できます。

1.2 カスタムフックの実装

それでは作成したカスタムフックを利用し、ローカルストレージへのデータ格納・取得の処理を追加していきます。
まず、App.tsxに処理を追加します。以下、App.tsxの内容を記載します。追加分含めたコード全体です。

// /src/App.tsx

import { useEffect, useState } from "react";//useEffectのインポート追加
import { Route, Routes, useNavigate } from "react-router-dom"
import { useToast } from "@chakra-ui/react";
import Home from "./components/Home"
import Cards from "./components/Cards";
import { CardData } from "./cardData";
import { supabase } from "./supabaseClient";
import Register from "./components/Register";
import { useLocalStorage } from "./hooks/useLocalStorage";//カスタムフックuseLocalStorageのインポート

function App() {
  const [userid, setUserid] = useState<string>();
  const [cardData, setCardData] = useState<CardData[]>([]);
  const [newCard, setNewCard] = useState<CardData>({
    user_id: '',
    name: '',
    description: '',
    github_id: '',
    qiita_id: '',
    x_id: '',
    skill_id: 0,
    skills: '',
  });
  const [loading, setLoading] = useState<boolean>(false);
  const toast = useToast()
  const navigate = useNavigate()
  const { setItemWithExpiry, getItemWithExpiry } = useLocalStorage()//ローカルストレージ用処理、カスタムフックuseLocalStorageの定義追加

  useEffect(() => {//マウント時ローカルストレージからuseridを読込
    const localUserid = getItemWithExpiry('namecard-userid');
    if (localUserid) {
      console.log('localUserid', localUserid.value)
      setUserid(localUserid.value)
    }
  }, [])

  // Supabaseからデータを取得する関数
  const selectDb = async (): Promise<boolean> => {
    setLoading(true);

    // Step 1: usersテーブルからuser_idを取得
    const { data: userData, error: userError } = await supabase
      .from('users')
      .select('*')
      .eq('user_id', userid);

    if (userError || !userData || userData.length === 0) {
      toast({
        title: 'IDが見つかりません',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error fetching user data:', userError);
      setLoading(false);
      return false;
    }
    console.log('step1', userData)

    // Step 2: user_skillテーブルから該当するskill_idを取得
    const { data: userSkillData, error: userSkillError } = await supabase
      .from('user_skill')
      .select('skill_id')
      .eq('user_id', userid);

    if (userSkillError || !userSkillData || userSkillData.length === 0) {
      toast({
        title: 'Skill IDが見つかりません',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error fetching user skills:', userSkillError);
      setLoading(false);
      return false;
    }
    console.log('step2', userSkillData)
    const skill_ids = userSkillData.map((skill) => skill.skill_id);

    // Step 3: skillsテーブルからnameを取得
    const { data: skillsData, error: skillsError } = await supabase
      .from('skills')
      .select('name')
      .eq('id', skill_ids);

    if (skillsError || !skillsData || skillsData.length === 0) {
      toast({
        title: 'skillsが見つかりません',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error fetching skills:', skillsError);
      setLoading(false);
      return false;
    }

    console.log('step3', skillsData)
    const skillsString = skillsData.map(skill => skill.name);

    // Step4:結果をcombinedDataに集約
    const combinedData = userData.map((user) => ({
      ...user, skill_id: skill_ids, skills: skillsString,
    }));

    console.log('step4', combinedData)
    setCardData(combinedData);
    toast({
      title: 'データを取得しました',
      position: 'top',
      status: 'success',
      duration: 2000,
      isClosable: true,
    })
    setLoading(false);
    return true;  // データ取得に成功した場合はtrueを返す
  };

  //DBへの新規データ登録
  // Step 1: usersへの登録
  const insertDb = async (card: CardData): Promise<boolean> => {
    setLoading(true);
    const { data: userData, error: userError } = await supabase
      .from('users')
      .insert([
        {
          user_id: card.user_id,
          name: card.name,
          description: card.description,
          github_id: card.github_id,
          qiita_id: card.qiita_id,
          x_id: card.x_id,
        },
      ])
      .select();
    if (userError || !userData) {
      console.error('Error insert data:', userError);
      toast({
        title: 'ユーザ登録が失敗しました',
        description: `${userError}`,
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      setLoading(false);
      return false;
    }
    console.log('insertDb step1', userData, userError)
    // Step 2: user_skillテーブルへの登録
    const { data: userSkillData, error: userSkillError } = await supabase
      .from('user_skill')
      .insert([
        {
          user_id: card.user_id,
          skill_id: card.skill_id,
        },
      ])
      .select();
    if (userSkillError || !userSkillData) {
      console.error('Error insert data:', userSkillError);
      toast({
        title: 'ユーザ登録が失敗しました',
        description: `${userSkillError}`,
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      setLoading(false);
      return false;
    }
    setLoading(false);
    toast({
      title: 'ユーザ登録に成功しました',
      position: 'top',
      status: 'success',
      duration: 2000,
      isClosable: true,
    })
    console.log('iinsertDb step2', userSkillData);
    return true;
  }

  //登録ユーザの重複チェック
  const userCheckDb = async (card: CardData): Promise<boolean> => {
    setLoading(true);
    const { data: userData, error: userError } = await supabase
      .from('users')
      .select('*')
      .eq('user_id', card.user_id);

    if (userData && userData.length > 0) {
      toast({
        title: '既にIDが使われています',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('既にIDが使われています:', userError, userData);
      setLoading(false);
      return false;
    }

    setLoading(false);
    return true;
  }

  //DBの更新処理
  const updateDb = async (card: CardData): Promise<boolean> => {
    setLoading(true);
    //step1:usersテーブルの更新
    const { data: userData, error: userError } = await supabase
      .from('users')
      .update({
        name: card.name,
        description: card.description,
        github_id: card.github_id,
        qiita_id: card.qiita_id,
        x_id: card.x_id,
      })
      .eq('user_id', card.user_id)
      .select();
    if (userError || !userData || userData.length === 0) {
      toast({
        title: 'IDが見つかりません',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error fetching user data:', userError);
      setLoading(false);
      return false;
    }
    console.log('dbUpdate step1', userData)

    // Step 2: user_skillテーブルへの登録、user_skillテーブルは値が変わってないデータをupdateするとエラーになるため、値が同一かどうかをチェック
    // Step 2-1: 現在のskill_idを取得
    const { data: currentUserSkillData, error: currentUserSkillError } = await supabase
      .from('user_skill')
      .select('skill_id')
      .eq('user_id', card.user_id);
    console.log('dbUpdate step2-1', currentUserSkillData)

    if (currentUserSkillError || !currentUserSkillData) {
      toast({
        title: '現在の Skill が取得できません',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error fetching current user skills:', currentUserSkillError);
      setLoading(false);
      return false;
    }

    const currentSkillIds = currentUserSkillData.map((skill) => skill.skill_id);
    console.log('dbUpdate step2-1比較', currentUserSkillData, currentSkillIds, card.skill_id)
    const newSkillIds = Array.isArray(card.skill_id) ? card.skill_id : [card.skill_id]; // 配列でない場合に配列化

    // 現在の skill_id 配列と新しい skill_id 配列が異なる場合のみ更新
    const isDifferent = newSkillIds.some((id: number) => !currentSkillIds.includes(id));

    if (isDifferent) {
      const { data: userSkillData, error: userSkillError } = await supabase
        .from('user_skill')
        .update({
          skill_id: card.skill_id,
        })
        .eq('user_id', card.user_id)
        .select();

      if (userSkillError || !userSkillData || userSkillData.length === 0) {
        toast({
          title: 'Skill が見つかりません',
          position: 'top',
          status: 'error',
          duration: 2000,
          isClosable: true,
        })
        console.error('Error updating user skills:', userSkillError);
        setLoading(false);
        return false;
      }
      console.log('dbUpdate step2', userSkillData);
    } else {
      console.log('Skill ID は変更されていないため、更新しません');
    }

    setLoading(false);
    toast({
      title: '更新が完了しました',
      position: 'top',
      status: 'success',
      duration: 2000,
      isClosable: true,
    })
    return true;
  }

  //DBの削除処理
  const deleteDb = async (userid: string): Promise<boolean> => {
    setLoading(true);

    // Step 1: usersテーブルからuser_idを削除
    const { error: userError } = await supabase
      .from('users')
      .delete()
      .eq('user_id', userid);
    if (userError) {
      toast({
        title: '削除が失敗しました',
        description: `${userError}`,
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error delete data:', userError);
      setLoading(false);
      return false;
    }
    // Step 2: user_skillテーブルからuser_idを削除
    const { error: userSkillError } = await supabase
      .from('user_skill')
      .delete()
      .eq('user_id', userid);
    if (userError) {
      toast({
        title: '削除が失敗しました',
        description: `${userSkillError}`,
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error delete data:', userSkillError);
      setLoading(false);
      return false;
    }
    setLoading(false);
    toast({
      title: 'データを削除しました',
      position: 'top',
      status: 'success',
      duration: 2000,
      isClosable: true,
    })
    return true;
  }

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setUserid(e.target.value)
  }

  const handleSearch = async () => {
    if (userid) {
      const success = await selectDb();
      if (success) {
        const ttl = 1000 * 3600 * 24 * 7//有効期限の設定。単位はミリ秒、7日間
        setItemWithExpiry('namecard-userid', userid, ttl);//ローカルストレージ保存処理、7日間
        navigate(`/card/${userid}`);
      }
    } else {
      toast({
        title: 'IDが見つかりません',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
    }
  };

  return (
    <>
      <Routes>
        <Route path="/" element={<Home userid={userid || ''} loading={loading} handleInputChange={handleInputChange} handleSearch={handleSearch} />} />
        <Route path='/card/:userid' element={<Cards cardData={cardData} setCardData={setCardData} loading={loading} setLoading={setLoading} updateDb={updateDb} selectDb={selectDb} deleteDb={deleteDb} />
        } />
        <Route path='/card/register' element={<Register loading={loading} newCard={newCard} setNewCard={setNewCard} setUserid={setUserid} insertDb={insertDb} userCheckDb={userCheckDb} />} />
      </Routes>
    </>
  )
}

export default App

追加・修正した箇所を説明していきます。

// /src/App.tsx

import { useEffect, useState } from "react";//useEffectのインポート追加
import { Route, Routes, useNavigate } from "react-router-dom"
import { useToast } from "@chakra-ui/react";
import Home from "./components/Home"
import Cards from "./components/Cards";
import { CardData } from "./cardData";
import { supabase } from "./supabaseClient";
import Register from "./components/Register";
import { useLocalStorage } from "./hooks/useLocalStorage";//カスタムフックuseLocalStorageのインポート

まずは、冒頭のインポートの箇所です。
新たにuseEffectのインポートを追加しています。これは、コンポーネント読み込み時にローカルストレージの取得を行うためです。
また、作成した、カスタムフックuseLocalStorageのインポートを追加します。

次に、コンポーネント定義のトップレベルの箇所です。

// /src/App.tsx

function App() {
    const [userid, setUserid] = useState<string>();
    const [cardData, setCardData] = useState<CardData[]>([]);
    const [newCard, setNewCard] = useState<CardData>({
      user_id: '',
      name: '',
      description: '',
      github_id: '',
      qiita_id: '',
      x_id: '',
      skill_id: 0,
      skills: '',
    });
    const [loading, setLoading] = useState<boolean>(false);
    const toast = useToast()
    const navigate = useNavigate()
    const { setItemWithExpiry, getItemWithExpiry } = useLocalStorage()//ローカルストレージ用処理、カスタムフックuseLocalStorageの定義追加
  
    useEffect(() => {//マウント時ローカルストレージからuseridを読込
      const localUserid = getItemWithExpiry('namecard-userid');
      if (localUserid) {
        console.log('localUserid', localUserid.value)
        setUserid(localUserid.value)
      }
    }, [])

const { setItemWithExpiry, getItemWithExpiry } = useLocalStorage() で、useLocalStorageの定義を追加しています。setItemWithExpiry, getItemWithExpiry双方の関数を定義しています。

その下の、useEffectでコンポーネントマウント時に、ローカルストレージから、「namecard-userid」と言う名前のデータを取得し、定数localUseridに格納しています。そして、setUseridで、useridステートに、localUserid.valueをセットしています。
この処理で、ローカルストレージにデータがある場合は、IDを入力しなくても、自動でuseridがセットされます。

続いて、DBデータを取得する、handleSearchの箇所です。

// /src/App.tsx

const handleSearch = async () => {
    if (userid) {
        const success = await selectDb();
        if (success) {
            const ttl = 1000 * 3600 * 24 * 7//有効期限の設定。単位はミリ秒、7日間
            setItemWithExpiry('namecard-userid', userid, ttl);//ローカルストレージ保存処理、7日間
            navigate(`/card/${userid}`);
        }
    } else {
        toast({
            title: 'IDが見つかりません',
            position: 'top',
            status: 'error',
            duration: 2000,
            isClosable: true,
        })
    }
};

新たに、DBデータの所得に成功したID情報をローカルストレージに保存する処理を追加しています。カスタムフックのuseLocalStorage、setItemWithExpiryによる処理です。有効期限については、7日間で設定しました。上記コメント通り、単位はミリ秒となりますので、7日間の計算をしています。
セットした有効期限で、ローカルストレージに「namecard-userid」と言うキー名で保存しています。

次に、Card.tsxにuseLocalStorageを適用していきます。以下、追加・修正分も含めたCard.tsxのコード全体です。

// /src/components/Cards.tsx
import { useEffect, useState } from "react";//useState, useEffectインポート追加
import { Link, useNavigate } from "react-router-dom";
import { Box, Button, Card, CardBody, CardHeader, Flex, Heading, Icon, Text } from "@chakra-ui/react";
import { CardData } from "../cardData";
import { FaGithub } from "react-icons/fa";
import { SiQiita } from "react-icons/si";
import { FaXTwitter } from "react-icons/fa6";
import Edit from "./Edit";
import Delete from "./Delete";
import { useLocalStorage } from "../hooks/useLocalStorage";//ローカルストレージ読込の為のカスタムフック、インポート

type CardsProps = {
    cardData: CardData[];
    setCardData: React.Dispatch<React.SetStateAction<CardData[]>>
    loading: boolean;
    setLoading: React.Dispatch<React.SetStateAction<boolean>>;
    updateDb: (card: CardData) => Promise<boolean>;
    selectDb: (user_id: string) => Promise<boolean>;
    deleteDb: (user_id: string) => Promise<boolean>;
}

const Cards: React.FC<CardsProps> = ({ cardData, setCardData, loading, setLoading, updateDb, selectDb, deleteDb }) => {
    const [storedUserid, setStoredUserid] = useState('')//ローカルストレージのID格納のためのローカルステート
    const navigate = useNavigate();
    const { getItemWithExpiry } = useLocalStorage()//ローカルストレージ処理機能の定義、カスタムフック

    //リロードすると、初期化されるので、cardDataが空っぽだったら、
    useEffect(() => {//マウント時ローカルストレージからIDを読込
        if (!cardData || cardData.length === 0) {
            const localUserid = getItemWithExpiry('namecard-userid');
            if (localUserid) {
                setStoredUserid(localUserid.value)
            }
            else {//ローカルストレージにデータがなければ、'/'に移動
                navigate('/');
            }
        }
    }, [])
    useEffect(() => {//useridセット時、DBデータ読込
        if (storedUserid) {
            selectDb(storedUserid)
        }
    }, [storedUserid])

    return (
        <Flex alignItems='center' justify='center' p={5}>
            <Card maxW='400px'>
                <CardHeader>
                    <Heading size='md' textAlign='center'>Name Card App</Heading>
                </CardHeader>
                <CardBody>
                    {/* ////cardData の配列を map で処理 */}
                    {cardData.map((card, index) => (
                        <div key={index}>
                            <Box borderWidth='1px' borderRadius='lg' p={5}>
                                <Heading size='sm' textTransform='uppercase'>
                                    ID
                                </Heading>
                                <Text pb='2' fontSize='sm'>{card.user_id}</Text>
                                <Heading size='sm' textTransform='uppercase'>
                                    名前
                                </Heading>
                                <Text pb='2' fontSize='sm'>{card.name}</Text>
                                <Heading size='sm' textTransform='uppercase'>
                                    自己紹介
                                </Heading>
                                <Text pb='2'
                                    dangerouslySetInnerHTML={{ __html: card.description }}
                                />
                                <Heading size='sm' textTransform='uppercase'>
                                    好きな技術
                                </Heading>
                                <Text pb='2' fontSize='sm'>{card.skills}</Text>
                                <Flex wrap='nowrap' justifyContent='center' width='100%' >
                                    <Link to={`https://github.com/${card.github_id}`} target='_blank'>
                                        <Icon
                                            as={FaGithub}
                                            fontSize="24px"
                                            margin={1}
                                            _hover={{ transform: 'scale(1.2)', transition: 'transform 0.3s' }}
                                        />
                                    </Link>
                                    <Link to={`https://qiita.com/${card.qiita_id}`} target='_blank'>
                                        <Icon
                                            as={SiQiita}
                                            fontSize="24px"
                                            margin={1}
                                            _hover={{ transform: 'scale(1.2)', transition: 'transform 0.3s' }}
                                        />
                                    </Link>
                                    <Link to={`https://x.com/${card.x_id}`} target='_blank'>
                                        <Icon
                                            as={FaXTwitter}
                                            fontSize="24px"
                                            margin={1}
                                            _hover={{ transform: 'scale(1.2)', transition: 'transform 0.3s' }}
                                        />
                                    </Link>
                                </Flex>
                            </Box>
                        </div>
                    ))}
                    <Box mt={4} textAlign='center'>
                        <Edit loading={loading} setLoading={setLoading} cardData={cardData} setCardData={setCardData} updateDb={updateDb} selectDb={selectDb} />
                        <Delete loading={loading} setLoading={setLoading} cardData={cardData} setCardData={setCardData} deleteDb={deleteDb}
                        />
                        <Button
                            colorScheme='gray'
                            variant='outline'
                            onClick={() => {
                                setCardData([]);
                                navigate('/');
                            }
                            }>戻る</Button>
                    </Box>
                </CardBody>
            </Card>
        </Flex>
    )
}

export default Cards;

説明していきます。まず冒頭のインポート箇所です。

// /src/components/Cards.tsx
import { useEffect, useState } from "react";//useState, useEffectインポート追加
import { Link, useNavigate } from "react-router-dom";
import { Box, Button, Card, CardBody, CardHeader, Flex, Heading, Icon, Text } from "@chakra-ui/react";
import { CardData } from "../cardData";
import { FaGithub } from "react-icons/fa";
import { SiQiita } from "react-icons/si";
import { FaXTwitter } from "react-icons/fa6";
import Edit from "./Edit";
import Delete from "./Delete";
import { useLocalStorage } from "../hooks/useLocalStorage";//ローカルストレージ読込の為のカスタムフック、インポート

新たにローカルステートを設けるのと、App.tsxと同様、useEffectによるコンポーネントマウント時にローカルストレージのデータ取得を行うため、useState, useEffectのインポートを追加してます。
また、作成した、カスタムフックuseLocalStorageのインポートを追加します。

続いて、コンポーネント定義の箇所です。

// /src/components/Cards.tsx

const Cards: React.FC<CardsProps> = ({ cardData, setCardData, loading, setLoading, updateDb, selectDb, deleteDb }) => {
    const [storedUserid, setStoredUserid] = useState('')//ローカルストレージのID格納のためのローカルステート
    const navigate = useNavigate();
    const { getItemWithExpiry } = useLocalStorage()//ローカルストレージ処理機能の定義、カスタムフック

    //リロードすると、初期化されるので、cardDataが空っぽだったら、
    useEffect(() => {//マウント時ローカルストレージからIDを読込
        if (!cardData || cardData.length === 0) {
            const localUserid = getItemWithExpiry('namecard-userid');
            if (localUserid) {
                setStoredUserid(localUserid.value)
            }
            else {//ローカルストレージにデータがなければ、'/'に移動
                navigate('/');
            }
        }
    }, [])
    //useridセット時、DBデータ読込
        if (storedUserid) {
            selectDb(storedUserid)
        }
    }, [storedUserid])

新たに、ローカルストレージから取得したデータを格納するステート、storedUseridをセットしています。また、App.tsxと同様、カスタムフックについて、const { setItemWithExpiry, getItemWithExpiry } = useLocalStorage() で、useLocalStorageの定義を追加しています。setItemWithExpiry, getItemWithExpiry双方の関数を定義しています。

続いて、useEffectの処理ですが、2つ定義しています。
1つ目が、コンポーネントが読み込まれた時に、処理を行うもので、リロード等でcardData(Supabaseのテーブルから取得したデータを格納するステート)が空の場合、getItemWithExpiryにて、ローカルストレージからデータを取得してます。データが取得できれば、setStoredUseridで、ロカールストレージのデータをstoredUseridに格納しています。
ローカルストレージにデータがない場合は、ID入力画面である’/’(Homeコンポーネント)に遷移させています。

次のuseEffectですが、useEffect(() => {….}, [storedUserid]) とあるように、storedUseridが変化した際に動作させています。処理としては、storedUseridがセットされたら、storedUseridをキーにselectDb(Supabaseのテーブルデータ取得)を実行しています。

ここまでの修正・追加により、ID入力時はその値をローカルストレージに保存、App.tsxまたはCard.tsxのリロード時は、ローカルストレージのデータ取得を行い、その値に基づいて、Supabaseのテーブルデータ取得・表示が出来るようになります。

ブラウザで右クリック > 検証 と選択し、下に表示されるエリアで、アプリケーション > ローカルストレージと選択して、中身を確認してみると(これはChromeでの操作です。他のブラウザでは違うかも知れませんが似たような操作・構造だと思います)、「namecard-userid」の名前でデータが保存されていることが分かります。

下図のように、Cards画面でリロードを行うと、IDをローカルストレージから取得し、テーブルデータを再取得して表示する処理となります。

2. スキル選択肢のSupabaseからの取得

次のブラッシュアップは、スキル選択肢のSupabaseテーブルからの取得です。現時点は、新規登録のRegister、及びカード情報編集のEditコンポーネントにおける、「好きな技術」の選択肢は、固定値を設定しています。<select>における、<option>の値です。

これを、Supabaseのskillsテーブルから選択肢を動的に取得し<option>にセットするように変更したいと思います。

2.1 型定義の追加

これに当たり、まずは、型定義ファイル、cardsData.tsに、skillsテーブルのデータの型定義を追加します。

// /src/cardData.ts

export type CardData = {
    user_id: string,
    name: string,
    description: string,
    github_id?: string,
    qiita_id?: string,
    x_id?: string,
    skill_id: number,
    skills: string
}

export type SkillData = {//skillデータ読み込みの為、型定義追加
    id: number,
    name: string
}

上記の通り、新たにskillデータの型定義の為、type SkillDataを追加します。CardDataと同様に、exportの形式で指定します。この型定義を各コンポーネントから利用します。

2.2 Appの追加・修正

次に、App.tsxの追加・修正を行います。Supabaseのskillsテーブルのデータを取得する関数の追加、及びそれに関わる、型定義、StateやPropsの追加です。
まずは、App.tsxのコード全体を掲載します。

// /src/App.tsx

import { useEffect, useState } from "react";
import { Route, Routes, useNavigate } from "react-router-dom"
import { useToast } from "@chakra-ui/react";
import Home from "./components/Home"
import Cards from "./components/Cards";
import { CardData, SkillData } from "./cardData";//SkillDataインポート追加
import { supabase } from "./supabaseClient";
import Register from "./components/Register";
import { useLocalStorage } from "./hooks/useLocalStorage";

function App() {
  const [userid, setUserid] = useState<string>();
  const [cardData, setCardData] = useState<CardData[]>([]);
  const [newCard, setNewCard] = useState<CardData>({
    user_id: '',
    name: '',
    description: '',
    github_id: '',
    qiita_id: '',
    x_id: '',
    skill_id: 0,
    skills: '',
  });
  const [loading, setLoading] = useState<boolean>(false);
  const [skillData, setSkillData] = useState<SkillData[]>([])//skill table読み込み用ステート追加
  const toast = useToast()
  const navigate = useNavigate()
  const { setItemWithExpiry, getItemWithExpiry } = useLocalStorage()

  useEffect(() => {
    const localUserid = getItemWithExpiry('namecard-userid');
    if (localUserid) {
      console.log('localUserid', localUserid.value)
      setUserid(localUserid.value)
    }
  }, [])

  // Supabaseからデータを取得する関数
  const selectDb = async (): Promise<boolean> => {
    setLoading(true);

    // Step 1: usersテーブルからuser_idを取得
    const { data: userData, error: userError } = await supabase
      .from('users')
      .select('*')
      .eq('user_id', userid);

    if (userError || !userData || userData.length === 0) {
      toast({
        title: 'IDが見つかりません',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error fetching user data:', userError);
      setLoading(false);
      return false;
    }
    console.log('step1', userData)

    // Step 2: user_skillテーブルから該当するskill_idを取得
    const { data: userSkillData, error: userSkillError } = await supabase
      .from('user_skill')
      .select('skill_id')
      .eq('user_id', userid);

    if (userSkillError || !userSkillData || userSkillData.length === 0) {
      toast({
        title: 'Skill IDが見つかりません',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error fetching user skills:', userSkillError);
      setLoading(false);
      return false;
    }
    console.log('step2', userSkillData)
    const skill_ids = userSkillData.map((skill) => skill.skill_id);

    // Step 3: skillsテーブルからnameを取得
    const { data: skillsData, error: skillsError } = await supabase
      .from('skills')
      .select('name')
      .eq('id', skill_ids);

    if (skillsError || !skillsData || skillsData.length === 0) {
      toast({
        title: 'skillsが見つかりません',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error fetching skills:', skillsError);
      setLoading(false);
      return false;
    }

    console.log('step3', skillsData)
    const skillsString = skillsData.map(skill => skill.name);

    // Step4:結果をcombinedDataに集約
    const combinedData = userData.map((user) => ({
      ...user, skill_id: skill_ids, skills: skillsString,
    }));

    console.log('step4', combinedData)
    setCardData(combinedData);
    toast({
      title: 'データを取得しました',
      position: 'top',
      status: 'success',
      duration: 2000,
      isClosable: true,
    })
    setLoading(false);
    return true;  // データ取得に成功した場合はtrueを返す
  };

  //DBへの新規データ登録
  // Step 1: usersへの登録
  const insertDb = async (card: CardData): Promise<boolean> => {
    setLoading(true);
    const { data: userData, error: userError } = await supabase
      .from('users')
      .insert([
        {
          user_id: card.user_id,
          name: card.name,
          description: card.description,
          github_id: card.github_id,
          qiita_id: card.qiita_id,
          x_id: card.x_id,
        },
      ])
      .select();
    if (userError || !userData) {
      console.error('Error insert data:', userError);
      toast({
        title: 'ユーザ登録が失敗しました',
        description: `${userError}`,
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      setLoading(false);
      return false;
    }
    console.log('insertDb step1', userData, userError)
    // Step 2: user_skillテーブルへの登録
    const { data: userSkillData, error: userSkillError } = await supabase
      .from('user_skill')
      .insert([
        {
          user_id: card.user_id,
          skill_id: card.skill_id,
        },
      ])
      .select();
    if (userSkillError || !userSkillData) {
      console.error('Error insert data:', userSkillError);
      toast({
        title: 'ユーザ登録が失敗しました',
        description: `${userSkillError}`,
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      setLoading(false);
      return false;
    }
    setLoading(false);
    toast({
      title: 'ユーザ登録に成功しました',
      position: 'top',
      status: 'success',
      duration: 2000,
      isClosable: true,
    })
    console.log('iinsertDb step2', userSkillData);
    return true;
  }

  //登録ユーザの重複チェック
  const userCheckDb = async (card: CardData): Promise<boolean> => {
    setLoading(true);
    const { data: userData, error: userError } = await supabase
      .from('users')
      .select('*')
      .eq('user_id', card.user_id);

    if (userData && userData.length > 0) {
      toast({
        title: '既にIDが使われています',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('既にIDが使われています:', userError, userData);
      setLoading(false);
      return false;
    }

    setLoading(false);
    return true;
  }

  //DBの更新処理
  const updateDb = async (card: CardData): Promise<boolean> => {
    setLoading(true);
    //step1:usersテーブルの更新
    const { data: userData, error: userError } = await supabase
      .from('users')
      .update({
        name: card.name,
        description: card.description,
        github_id: card.github_id,
        qiita_id: card.qiita_id,
        x_id: card.x_id,
      })
      .eq('user_id', card.user_id)
      .select();
    if (userError || !userData || userData.length === 0) {
      toast({
        title: 'IDが見つかりません',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error fetching user data:', userError);
      setLoading(false);
      return false;
    }
    console.log('dbUpdate step1', userData)

    // Step 2: user_skillテーブルへの登録、user_skillテーブルは値が変わってないデータをupdateするとエラーになるため、値が同一かどうかをチェック
    // Step 2-1: 現在のskill_idを取得
    const { data: currentUserSkillData, error: currentUserSkillError } = await supabase
      .from('user_skill')
      .select('skill_id')
      .eq('user_id', card.user_id);
    console.log('dbUpdate step2-1', currentUserSkillData)

    if (currentUserSkillError || !currentUserSkillData) {
      toast({
        title: '現在の Skill が取得できません',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error fetching current user skills:', currentUserSkillError);
      setLoading(false);
      return false;
    }

    const currentSkillIds = currentUserSkillData.map((skill) => skill.skill_id);
    console.log('dbUpdate step2-1比較', currentUserSkillData, currentSkillIds, card.skill_id)
    const newSkillIds = Array.isArray(card.skill_id) ? card.skill_id : [card.skill_id]; // 配列でない場合に配列化

    // 現在の skill_id 配列と新しい skill_id 配列が異なる場合のみ更新
    const isDifferent = newSkillIds.some((id: number) => !currentSkillIds.includes(id));

    if (isDifferent) {
      const { data: userSkillData, error: userSkillError } = await supabase
        .from('user_skill')
        .update({
          skill_id: card.skill_id,
        })
        .eq('user_id', card.user_id)
        .select();

      if (userSkillError || !userSkillData || userSkillData.length === 0) {
        toast({
          title: 'Skill が見つかりません',
          position: 'top',
          status: 'error',
          duration: 2000,
          isClosable: true,
        })
        console.error('Error updating user skills:', userSkillError);
        setLoading(false);
        return false;
      }
      console.log('dbUpdate step2', userSkillData);
    } else {
      console.log('Skill ID は変更されていないため、更新しません');
    }

    setLoading(false);
    toast({
      title: '更新が完了しました',
      position: 'top',
      status: 'success',
      duration: 2000,
      isClosable: true,
    })
    return true;
  }

  //DBの削除処理
  const deleteDb = async (userid: string): Promise<boolean> => {
    setLoading(true);

    // Step 1: usersテーブルからuser_idを削除
    const { error: userError } = await supabase
      .from('users')
      .delete()
      .eq('user_id', userid);
    if (userError) {
      toast({
        title: '削除が失敗しました',
        description: `${userError}`,
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error delete data:', userError);
      setLoading(false);
      return false;
    }
    // Step 2: user_skillテーブルからuser_idを削除
    const { error: userSkillError } = await supabase
      .from('user_skill')
      .delete()
      .eq('user_id', userid);
    if (userError) {
      toast({
        title: '削除が失敗しました',
        description: `${userSkillError}`,
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error delete data:', userSkillError);
      setLoading(false);
      return false;
    }
    setLoading(false);
    toast({
      title: 'データを削除しました',
      position: 'top',
      status: 'success',
      duration: 2000,
      isClosable: true,
    })
    return true;
  }


  // skills table読み込み
  const selectSkillDb = async (): Promise<boolean> => {
    setLoading(true);

    const { data: userData, error: userError } = await supabase
      .from('skills')
      .select('*')

    if (userError || !userData || userData.length === 0) {
      toast({
        title: 'IDが見つかりません',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error fetching user data:', userError);
      setLoading(false);
      return false;
    }
    setSkillData(userData);
    setLoading(false);
    return true;
  }

  //skills tableのskillDataステートへのセットの為、useEffectを使用、selectSkillDb()によるステートへのセットを確実に実施する為、async/await処理
  useEffect(() => {
    const fetchSkillData = async () => {
      try {
        await selectSkillDb();
      } catch (error) {
        toast({
          title: '予期せぬエラーが発生しました',
          position: 'top',
          status: 'error',
          duration: 2000,
          isClosable: true,
        })
      }
      console.log('skillData1', skillData);
    };

    fetchSkillData();
  }, []);


  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setUserid(e.target.value)
  }

  const handleSearch = async () => {
    if (userid) {
      const success = await selectDb();
      if (success) {
        const ttl = 1000 * 3600 * 24 * 7
        setItemWithExpiry('namecard-userid', userid, ttl);
        navigate(`/card/${userid}`);
      }
    } else {
      toast({
        title: 'IDが見つかりません',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
    }
  };

  return (
    <>
      <Routes>
        <Route path="/" element={<Home userid={userid || ''} loading={loading} handleInputChange={handleInputChange} handleSearch={handleSearch} />} />
        <Route path='/card/:userid' element={<Cards cardData={cardData} setCardData={setCardData} loading={loading} setLoading={setLoading} updateDb={updateDb} selectDb={selectDb} deleteDb={deleteDb} skillData={skillData} />//skillData追加
        } />
        <Route path='/card/register' element={<Register loading={loading} newCard={newCard} setNewCard={setNewCard} setUserid={setUserid} insertDb={insertDb} userCheckDb={userCheckDb} skillData={skillData} />//skillData追加
        } />
      </Routes>
    </>
  )
}

export default App

追加箇所について解説していきます。
まず、冒頭のインポート箇所です。

// /src/App.tsx

import { useEffect, useState } from "react";
import { Route, Routes, useNavigate } from "react-router-dom"
import { useToast } from "@chakra-ui/react";
import Home from "./components/Home"
import Cards from "./components/Cards";
import { CardData, SkillData } from "./cardData";//SkillDataインポート追加
import { supabase } from "./supabaseClient";
import Register from "./components/Register";
import { useLocalStorage } from "./hooks/useLocalStorage";

cardDataより新たに定義した、SkillDataの型定義をインポートしています。
続いて、コンポーネントのトップレベルの箇所です。

// /src/App.tsx

function App() {
    const [userid, setUserid] = useState<string>();
    const [cardData, setCardData] = useState<CardData[]>([]);
    const [newCard, setNewCard] = useState<CardData>({
      user_id: '',
      name: '',
      description: '',
      github_id: '',
      qiita_id: '',
      x_id: '',
      skill_id: 0,
      skills: '',
    });
    const [loading, setLoading] = useState<boolean>(false);
    const [skillData, setSkillData] = useState<SkillData[]>([])//skills table読み込み用ステート追加
    const toast = useToast()
    const navigate = useNavigate()
    const { setItemWithExpiry, getItemWithExpiry } = useLocalStorage()

useStateについて、新たに、Supabaseのskillsテーブルデータをセットするステート、skillData, setSkillDataを定義しています。このステートの型は、SkillDataの型を持つ配列となります。

次に、Supabaseのskillsテーブルデータ取得処理の箇所です。

// /src/App.tsx

// skills table読み込み
const selectSkillDb = async (): Promise<boolean> => {//非同期通信async/awaitで実装。結果成否を明確にするためboolean型で定義
    setLoading(true);//ローディング状態をセット

    const { data: userData, error: userError } = await supabase//supabaseクライアント機能で、skillsテーブルのデータを取得
        .from('skills')
        .select('*')

    if (userError || !userData || userData.length === 0) {
        toast({//エラー発生、または、取得したuserDataが空であればChakura UIのトーストでエラー表示
            title: 'IDが見つかりません',
            position: 'top',
            status: 'error',
            duration: 2000,
            isClosable: true,
        })
        console.error('Error fetching user data:', userError);
        setLoading(false);//ローディング状態を解除
        return false;//falseをリターン
    }
    //処理が正常に行われれば
    setSkillData(userData);//ステートskillDataに、userDataをセット
    setLoading(false);//ローディング状態を解除
    return true;//trueをリターン
}

//skills tableのskillDataステートへのセットの為、useEffectを使用
useEffect(() => {
    const fetchSkillData = async () => {//selectSkillDb()によるステートへのセットを確実に実施する為、async/await処理
        try {
            await selectSkillDb();//awaitで、selectSkillDb(実行
        } catch (error) {
            toast({//エラー発生であればChakura UIのトーストでエラー表示
                title: '予期せぬエラーが発生しました',
                position: 'top',
                status: 'error',
                duration: 2000,
                isClosable: true,
            })
        }
        console.log('skillData1', skillData);//正常終了であれば、skillDataをコンソール出力
    };

    fetchSkillData();//上記定義したfetchSkillDataを実行
}, []);//コンポーネントマウント時のみ実行

新たに、skillsテーブルからデータを取得する関数、selectSkillDbを定義しています。これは、チュートリアル前編で作成した、selectDbと同じような構造のものです。結果成否を明確にするためboolean型で定義しています。
処理としては、ローディング状態をセット、supabaseクライアント機能でskillsテーブルのデータを取得、エラーの場合はChakra UIのToast機能でエラー表示、正常終了の場合はsetSkillDataでskillDataをセット、ローディング解除と言う流れです。

次に、useEffectによる処理を入れています。
これはページを新たに開いた場合やリロードした場合など、コンポーネントがマウントされた時のみ実行される形にしています。内容は、先に定義したselectSkillDbを実行するものですが、selectSkillDbによるステートセットを確実に実施する為、const fetchSkillData = async () => { } の形でasync/await処理としています。

続いてJSXの箇所です。

// /src/App.tsx

return (
    <>
      <Routes>
        <Route path="/" element={<Home userid={userid || ''} loading={loading} handleInputChange={handleInputChange} handleSearch={handleSearch} />} />
        <Route path='/card/:userid' element={<Cards cardData={cardData} setCardData={setCardData} loading={loading} setLoading={setLoading} updateDb={updateDb} selectDb={selectDb} deleteDb={deleteDb} skillData={skillData} />//skillData追加
        } />
        <Route path='/card/register' element={<Register loading={loading} newCard={newCard} setNewCard={setNewCard} setUserid={setUserid} insertDb={insertDb} userCheckDb={userCheckDb} skillData={skillData} />//skillData追加
        } />
      </Routes>
    </>
  )

Cardsコンポーネントと、Registerコンポーネント、双方にPorpsとしてskillDataを追加しています。
Cardsコンポーネントに追加しているのは、Cardsを経由してEditコンポーネントにPropsを渡すためです。

2.3 Registerの追加・修正

続いて、skillsテーブルからの選択肢取得による、新規登録時のRegister.tsxの追加・修正です。
以下は、Register.tsxコード全文です。

// /src/components/Register.tsx
import { useNavigate } from "react-router-dom";
import { Box, Button, Card, CardBody, CardHeader, Flex, Heading, Input, Select, Text, Textarea, useToast } from "@chakra-ui/react";
import { CardData, SkillData } from "../cardData";//CardData型定義のインポート,skillDataの型定義追加

type RegisterProps = {
    loading: boolean;
    newCard: CardData;
    setNewCard: React.Dispatch<React.SetStateAction<CardData>>
    setUserid: React.Dispatch<React.SetStateAction<string | undefined>>
    insertDb: (card: CardData) => Promise<boolean>
    userCheckDb: (card: CardData) => Promise<boolean>
    skillData: SkillData[];//skillDataの型定義追加
};

const Register: React.FC<RegisterProps> = ({ loading, newCard, setNewCard, setUserid, insertDb, userCheckDb, skillData }) => {//skillDataの追加
    const toast = useToast()
    const navigate = useNavigate();

    const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        const { name, value } = e.target;
        setNewCard({
            ...newCard,
            [name]: value,
        });
    };

    const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
        const { name, value } = e.target;
        setNewCard({
            ...newCard,
            [name]: value,
        });
    };

    const handleRegister = async () => {
        if (newCard.user_id && newCard.name && newCard.description && newCard.skill_id) {
            const isUserIdUnique = await userCheckDb(newCard);
            if (isUserIdUnique) {
                await insertDb(newCard);
                setUserid(newCard.user_id);
                setNewCard({
                    user_id: '',
                    name: '',
                    description: '',
                    github_id: '',
                    qiita_id: '',
                    x_id: '',
                    skill_id: 0,
                    skills: '',
                });
                navigate('/');
            }
        } else {
            toast({
                title: '*の必須項目を入力してください',
                position: 'top',
                status: 'error',
                duration: 2000,
                isClosable: true,
            })
        }
    };

    return (
        <>
            <Flex alignItems='center' justify='center' p={5}>
                <Card maxW='400px'>
                    <CardHeader>
                        <Heading size='md' textAlign='center'>カード登録</Heading>
                    </CardHeader>
                    <CardBody>
                        <Text>ID名*</Text>
                        <Input
                            autoFocus
                            placeholder='ご希望のID名を入力'
                            name='user_id'
                            value={newCard.user_id}
                            onChange={handleInputChange}
                        />
                        <Text mt='3'>お名前*</Text>
                        <Input
                            placeholder='お名前を入力'
                            name='name'
                            value={newCard.name}
                            onChange={handleInputChange}
                        />
                        <Text mt='3'>自己紹介*</Text>
                        <Textarea
                            placeholder='<h1>HTMLタグも入力できます</h1>'
                            name='description'
                            value={newCard.description}
                            onChange={handleTextareaChange}
                        />
                        <Text mt='3'>好きな技術*</Text>
                        <Select
                            placeholder='選択してください'
                            name='skill_id'
                            value={newCard.skill_id.toString()} // 数値を文字列に変換して表示
                            onChange={(e) => setNewCard({ ...newCard, skill_id: Number(e.target.value) })}
                        >
                            {/*追加ここから*/}
                            {skillData.map((skill) => (//skillDataをmapで展開し、optionをセット
                                <option key={skill.id} value={String(skill.id)}>{skill.name}</option>
                            ))}
                            {/*追加ここまで*/}
                            {/*削除
                            <option value='1'>React</option>
                            <option value='2'>TypeScript</option>
                            <option value='3'>GitHub</option>
                            */}
                        </Select>
                        <Text mt='3'>GitHub ID</Text>
                        <Input
                            name='github_id'
                            value={newCard.github_id}
                            onChange={handleInputChange}
                        />
                        <Text mt='3'>Qiita ID</Text>
                        <Input
                            name='qiita_id'
                            value={newCard.qiita_id}
                            onChange={handleInputChange}
                        />
                        <Text mt='3'>X(Twitter) ID</Text>
                        <Input
                            name='x_id'
                            value={newCard.x_id}
                            onChange={handleInputChange}
                        />
                        <Box mt={4} textAlign='center'>
                            <Button
                                isLoading={loading}
                                loadingText='Loading'
                                colorScheme='blue'
                                spinnerPlacement='start'
                                mr='5'
                                onClick={handleRegister}
                            >登録</Button>
                            <Button
                                colorScheme='gray'
                                variant='outline'
                                onClick={() => {
                                    navigate('/');
                                }
                                }>戻る</Button>
                        </Box>
                    </CardBody>
                </Card>
            </Flex >
        </>
    )
}
export default Register;

解説していきます。
まずは、冒頭のインポート及び型定義の箇所です。

// /src/components/Register.tsx
import { useNavigate } from "react-router-dom";
import { Box, Button, Card, CardBody, CardHeader, Flex, Heading, Input, Select, Text, Textarea, useToast } from "@chakra-ui/react";
import { CardData, SkillData } from "../cardData";//CardData型定義のインポート,skillDataの型定義追加

type RegisterProps = {
    loading: boolean;
    newCard: CardData;
    setNewCard: React.Dispatch<React.SetStateAction<CardData>>
    setUserid: React.Dispatch<React.SetStateAction<string | undefined>>
    insertDb: (card: CardData) => Promise<boolean>
    userCheckDb: (card: CardData) => Promise<boolean>
    skillData: SkillData[];//skillDataの型定義追加
};

インポートの箇所は、cardData.tsから、新たにskillDataの型定義を追加しています。
また、型定義は、skillData: SkillData[] として、skillDataの型定義を追加しています。

続いて、JSXの箇所です。<Select>の箇所が変更となります。

// /src/components/Register.tsx

<Select
placeholder='選択してください'
name='skill_id'
value={newCard.skill_id.toString()} // 数値を文字列に変換して表示
onChange={(e) => setNewCard({ ...newCard, skill_id: Number(e.target.value) })}
>
{/*追加ここから*/}
{skillData.map((skill) => (//skillDataをmapで展開し、optionをセット
    <option key={skill.id} value={String(skill.id)}>{skill.name}</option>//valueを文字列(String)としてセット
))}
{/*追加ここまで*/}
{/*削除
<option value='1'>React</option>
<option value='2'>TypeScript</option>
<option value='3'>GitHub</option>
*/}
</Select>

まず、これまで固定値を設定していた、<option..>….</option>の箇所は削除します。新たにskillDataをmapで回し、<option key={skill.id} value={String(skill.id)}>{skill.name}</option>
で、skillDataのデータを<option>の値にセットします。

2.4 Cardsの追加・修正

続いて、Cardsコンポーネントについてです。Registerの他に、今回変更対象となる<select>を設けてるのはEditコンポーネントですが、EditはCards経由でPropsを受け取るため、Cardsについても新たなインポートと型定義、Propsの定義が必要となります。
以下は、変更後のCards.tsxコード全文です。

// /src/components/Cards.tsx
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Box, Button, Card, CardBody, CardHeader, Flex, Heading, Icon, Text } from "@chakra-ui/react";
import { CardData, SkillData } from "../cardData";//skillDataの型定義追加
import { FaGithub } from "react-icons/fa";
import { SiQiita } from "react-icons/si";
import { FaXTwitter } from "react-icons/fa6";
import Edit from "./Edit";
import Delete from "./Delete";
import { useLocalStorage } from "../hooks/useLocalStorage";

type CardsProps = {
    cardData: CardData[];
    setCardData: React.Dispatch<React.SetStateAction<CardData[]>>
    loading: boolean;
    setLoading: React.Dispatch<React.SetStateAction<boolean>>;
    updateDb: (card: CardData) => Promise<boolean>;
    selectDb: (user_id: string) => Promise<boolean>;
    deleteDb: (user_id: string) => Promise<boolean>;
    skillData: SkillData[];//skillDataの型定義追加
}

const Cards: React.FC<CardsProps> = ({ cardData, setCardData, loading, setLoading, updateDb, selectDb, deleteDb, skillData }) => {//skillDataの追加
    const [storedUserid, setStoredUserid] = useState('')
    const navigate = useNavigate();
    const { getItemWithExpiry } = useLocalStorage()

    useEffect(() => {
        if (!cardData || cardData.length === 0) {
            const localUserid = getItemWithExpiry('namecard-userid');
            if (localUserid) {
                setStoredUserid(localUserid.value)
            }
            else {
                navigate('/');
            }
        }
    }, [])
    useEffect(() => {
        if (storedUserid) {
            selectDb(storedUserid)
        }
    }, [storedUserid])

    return (
        <Flex alignItems='center' justify='center' p={5}>
            <Card maxW='400px'>
                <CardHeader>
                    <Heading size='md' textAlign='center'>Name Card App</Heading>
                </CardHeader>
                <CardBody>
                    {cardData.map((card, index) => (
                        <div key={index}>
                            <Box borderWidth='1px' borderRadius='lg' p={5}>
                                <Heading size='sm' textTransform='uppercase'>
                                    ID
                                </Heading>
                                <Text pb='2' fontSize='sm'>{card.user_id}</Text>
                                <Heading size='sm' textTransform='uppercase'>
                                    名前
                                </Heading>
                                <Text pb='2' fontSize='sm'>{card.name}</Text>
                                <Heading size='sm' textTransform='uppercase'>
                                    自己紹介
                                </Heading>
                                <Text pb='2'
                                    dangerouslySetInnerHTML={{ __html: card.description }}
                                />
                                <Heading size='sm' textTransform='uppercase'>
                                    好きな技術
                                </Heading>
                                <Text pb='2' fontSize='sm'>{card.skills}</Text>
                                <Flex wrap='nowrap' justifyContent='center' width='100%' >
                                    <Link to={`https://github.com/${card.github_id}`} target='_blank'>
                                        <Icon
                                            as={FaGithub}
                                            fontSize="24px"
                                            margin={1}
                                            _hover={{ transform: 'scale(1.2)', transition: 'transform 0.3s' }}
                                        />
                                    </Link>
                                    <Link to={`https://qiita.com/${card.qiita_id}`} target='_blank'>
                                        <Icon
                                            as={SiQiita}
                                            fontSize="24px"
                                            margin={1}
                                            _hover={{ transform: 'scale(1.2)', transition: 'transform 0.3s' }}
                                        />
                                    </Link>
                                    <Link to={`https://x.com/${card.x_id}`} target='_blank'>
                                        <Icon
                                            as={FaXTwitter}
                                            fontSize="24px"
                                            margin={1}
                                            _hover={{ transform: 'scale(1.2)', transition: 'transform 0.3s' }}
                                        />
                                    </Link>
                                </Flex>
                            </Box>
                        </div>
                    ))}
                    <Box mt={4} textAlign='center'>
                        <Edit loading={loading} setLoading={setLoading} cardData={cardData} setCardData={setCardData} updateDb={updateDb} selectDb={selectDb} skillData={skillData}//skillData追加
                        />
                        <Delete loading={loading} setLoading={setLoading} cardData={cardData} setCardData={setCardData} deleteDb={deleteDb}
                        />
                        <Button
                            colorScheme='gray'
                            variant='outline'
                            onClick={() => {
                                setCardData([]);
                                navigate('/');
                            }
                            }>戻る</Button>
                    </Box>
                </CardBody>
            </Card>
        </Flex>
    )
}

export default Cards;

ここでは特にコード個別の箇所での説明は行いませんが、冒頭のインポート、型定義、Propsと、後半のJSXのEditコンポーネントの配置の箇所で、skillData用の定義を追加しています。

2.5 Editの追加・修正

では、最後にEdit.tsxの追加・修正です。
変更内容はRegister.tsxと同じです。
以下、Edit.tsxの内容です(コード全文です)。

// /src/components/Edit.tsx
import { Button, FormControl, FormLabel, Input, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Select, Textarea, useDisclosure, useToast } from "@chakra-ui/react";
import { useRef, useState, useEffect } from "react";
import { CardData, SkillData } from "../cardData";//skillDataの型定義追加

type EditProps = {
    cardData: CardData[];
    setCardData: React.Dispatch<React.SetStateAction<CardData[]>>
    loading: boolean;
    setLoading: React.Dispatch<React.SetStateAction<boolean>>
    updateDb: (card: CardData) => Promise<boolean>;
    selectDb: (user_id: string) => Promise<boolean>;
    skillData: SkillData[];//skillDataの型定義追加
}

const Edit: React.FC<EditProps> = ({ cardData, loading, updateDb, selectDb, skillData }) => {//skillDataの追加
    const { isOpen, onOpen, onClose } = useDisclosure()
    const initialRef = useRef(null)
    const finalRef = useRef(null)
    const [editCard, setEditCard] = useState<CardData>(
        {
            user_id: '',
            name: '',
            description: '',
            github_id: '',
            qiita_id: '',
            x_id: '',
            skill_id: 0,
            skills: ''
        });
    const toast = useToast()

    useEffect(() => {
        if (cardData && cardData.length > 0) {
            setEditCard(cardData[0]);
        }
    }, [cardData]);

    const handleOpen = () => {
        if (cardData && cardData.length > 0) {
            setEditCard(cardData[0]);
        }
        onOpen();
    };

    const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        const { name, value } = e.target;
        setEditCard({
            ...editCard,
            [name]: value,
        });
    };

    const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
        const { name, value } = e.target;
        setEditCard({
            ...editCard,
            [name]: value,
        });
    };

    const handleUpdate = async () => {
        if (editCard.name && editCard.description && editCard.skill_id) {
            const isUpdate = await updateDb(editCard);
            if (isUpdate) {
                await selectDb(editCard.user_id);
            }
            else {
                toast({
                    title: '更新に失敗しました',
                    position: 'top',
                    status: 'error',
                    duration: 2000,
                    isClosable: true,
                })
            }
            console.log('editCard', editCard);
            if (!loading) {
                setTimeout(() => {
                    onClose();
                }, 500);
            }
        }
        else {
            toast({
                title: '*の必須項目を入力してください',
                position: 'top',
                status: 'error',
                duration: 2000,
                isClosable: true,
            })
        }
    }

    return (
        <>
            <Button
                loadingText='Loading'
                colorScheme='blue'
                spinnerPlacement='start'
                mr='3'
                onClick={handleOpen}
            >編集
            </Button>
            <Modal
                initialFocusRef={initialRef}
                finalFocusRef={finalRef}
                isOpen={isOpen}
                onClose={onClose}
            >
                <ModalOverlay />
                <ModalContent>
                    <ModalHeader>Name Card 編集</ModalHeader>
                    <ModalCloseButton />
                    <ModalBody pb={6}>
                        <FormControl mb='3'>
                            <FormLabel>ID変更できません)</FormLabel>
                            <Input
                                placeholder='IDは変更できません'
                                value={editCard.user_id || ''}
                                isDisabled
                            />
                        </FormControl>
                        <FormControl mb='3'>
                            <FormLabel>お名前*</FormLabel>
                            <Input
                                ref={initialRef}
                                placeholder='お名前'
                                name="name"
                                value={editCard.name}
                                onChange={handleInputChange}
                            />
                        </FormControl>
                        <FormControl mb='3'>
                            <FormLabel>自己紹介*</FormLabel>
                            <Textarea
                                placeholder='<h1>HTMLタグも入力できます</h1>'
                                name='description'
                                value={editCard.description}
                                onChange={handleTextareaChange}
                            />
                        </FormControl>
                        <FormControl mb='3'>
                            <FormLabel>好きな技術*</FormLabel>
                            <Select
                                name='skill_id'
                                value={String(editCard.skill_id)} // 数値を文字列に変換
                                onChange={(e) => setEditCard({ ...editCard, skill_id: Number(e.target.value) })}//数値に変換し直してセット
                                placeholder='選択してください'
                            >
                                {/*追加ここから*/}
                                {skillData.map((skill) => (//skillDataをmapで展開し、optionをセット
                                    <option key={skill.id} value={String(skill.id)}>{skill.name}</option>
                                ))}
                                {/*追加ここまで*/}
                                {/*削除
                            <option value='1'>React</option>
                            <option value='2'>TypeScript</option>
                            <option value='3'>GitHub</option>
                            */}
                            </Select>
                        </FormControl>
                        <FormControl mb='3'>
                            <FormLabel>GitHub ID</FormLabel>
                            <Input
                                name='github_id'
                                value={editCard.github_id}
                                onChange={handleInputChange}
                            />
                        </FormControl>
                        <FormControl mb='3'>
                            <FormLabel>Qiita ID</FormLabel>
                            <Input
                                name='qiita_id'
                                value={editCard.qiita_id}
                                onChange={handleInputChange}
                            />
                        </FormControl>
                        <FormControl mb='3'>
                            <FormLabel>X(Twitter) ID</FormLabel>
                            <Input
                                name='x_id'
                                value={editCard.x_id}
                                onChange={handleInputChange}
                            />
                        </FormControl>
                    </ModalBody>
                    <ModalFooter>
                        <Button
                            isLoading={loading}
                            loadingText='Loading'
                            colorScheme='blue'
                            spinnerPlacement='start'
                            mr={3}
                            onClick={handleUpdate}
                        >
                            データを更新
                        </Button>
                        <Button
                            variant='outline'
                            onClick={onClose}>Cancel</Button>
                    </ModalFooter>
                </ModalContent>
            </Modal>
        </>
    )
}
export default Edit;

冒頭のインポート箇所に、skillDataの型である、SkillDataのインポートを追加しています。
また、type設定にskillDataの型定義を追加、コンポーネントのProps定義にskillDataを追加しています。

JSXの箇所もRegisterと同様、これまで固定値を設定していた、<option..>….</option>の箇所は削除。新たにskillDataをmapで回し、<option key={skill.id} value={String(skill.id)}>{skill.name}</option>
で、skillDataのデータを<option>の値にセットします。

ここまでの変更で、selectのoption固定値を削除したにも関わらず、Register、Editで選択肢が正しく設定されているかと思います。
試しに、Supabaseのskillsテーブルに新しいデータを追加してみます。

そうすると、<select>の選択肢にも追加分のデータが反映されることが分かります。

3. Supabaseの複数テーブル結合

続いて、Supabaseの複数テーブルの結合処理をしてみたいと思います。
ここまでは、複数のテーブルからデータを取得する場合、Setpを分けてテーブルごとにシリアルにデータを取得していました。
これをテーブルの外部結合を実装することで、1回の処理で複数テーブルに跨るデータ取得の処理を実現してみます。

3.1 Supabaseテーブルの設定

まずは、Supabaseのテーブル設定を変更します。
外部結合を設定する対象のテーブルは、user_skillです。
前編で掲載した通り、user_skillテーブルは、usersテーブル及びskillsテーブル双方と連携しています。

このため、user_skillテーブルに対して外部結合を設定してきます。2つの結合を設定します。
Supabaseの管理画面、左メニューのTable Editorから「user_skill」を選択し、Edit Tableをクリックします。

右側に表示される「Update table」から、一番下にある、「Foreign keys」で、「Add foreign key relation」をクリックします。

外部結合の設定画面が表示されますので、1つ目の結合設定を行います。これはusersテーブルに対する設定です。下図のように設定します。user_skillテーブルのuser_idとusesテーブルのuser_idを連携させます。下2つのupdated と removed の際の挙動については、ここでは、共に、「Cascade」を設定してください。最後にこの画面一番下にある、「Save」をクリックして保存します。保存すると画面が閉じます。

続いて2つ目の設定です。再度、「Add foreign key relation」をクリックします。今度は、skillsテーブルに対する設定です。下図のように設定します。user_skillテーブルのskill_idとskillsテーブルのidを連携させます。下2つのupdated と removed の際の挙動については、ここでは、共に、「No action」を設定してください。最後にこの画面一番下にある、「Save」をクリックして保存します。保存すると画面が閉じます。

「foreign key relationship」画面が閉じられてる状態で、追加したForegin keysが表示されているのを確認の上、一番下の「Save」ボタンをクリックして設定を保存します(この最後のSaveクリックを忘れがちですのでご注意ください)。

ここまででSupabaseの設定は完了です。

3.2 selectDbの変更

続いて、App.tsxに定義している、Supabaseからのデータ取得関数、selectDbを変更していきます。現時点は、usersテーブル → user_skillテーブル → skillsテーブル とステップを踏んだデータ取得を行っていました。これを一度の処理で必要な情報を取得するように変更していきます。
変更前・後のコードを以下記載します。
まず、変更前です。

// /src/App.tsx

// Supabaseからデータを取得する関数(変更前)
const selectDb = async (): Promise<boolean> => {
    setLoading(true);

    // Step 1: usersテーブルからuser_idを取得
    const { data: userData, error: userError } = await supabase
      .from('users')
      .select('*')
      .eq('user_id', userid);

    if (userError || !userData || userData.length === 0) {
      toast({
        title: 'IDが見つかりません',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error fetching user data:', userError);
      setLoading(false);
      return false;
    }
    console.log('step1', userData)

    // Step 2: user_skillテーブルから該当するskill_idを取得
    const { data: userSkillData, error: userSkillError } = await supabase
      .from('user_skill')
      .select('skill_id')
      .eq('user_id', userid);

    if (userSkillError || !userSkillData || userSkillData.length === 0) {
      toast({
        title: 'Skill IDが見つかりません',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error fetching user skills:', userSkillError);
      setLoading(false);
      return false;
    }
    console.log('step2', userSkillData)
    const skill_ids = userSkillData.map((skill) => skill.skill_id);

    // Step 3: skillsテーブルからnameを取得
    const { data: skillsData, error: skillsError } = await supabase
      .from('skills')
      .select('name')
      .eq('id', skill_ids);

    if (skillsError || !skillsData || skillsData.length === 0) {
      toast({
        title: 'skillsが見つかりません',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error fetching skills:', skillsError);
      setLoading(false);
      return false;
    }
    console.log('step3', skillsData)

    const skillsString = skillsData.map(skill => skill.name);


    // Step4:結果をcombinedDataに集約
    const combinedData = userData.map((user) => ({
      ...user, skill_id: skill_ids, skills: skillsString,
    }));

    console.log('step4', combinedData)
    setCardData(combinedData);
    toast({
      title: 'データを取得しました',
      position: 'top',
      status: 'success',
      duration: 2000,
      isClosable: true,
    })
    setLoading(false);
    return true;  // データ取得に成功した場合はtrueを返す
  };


続いて、変更後のコードです。

// /src/App.tsx

// Supabaseからデータを取得する関数(変更後)
const selectDb = async (): Promise<boolean> => {
    setLoading(true);

    // usersテーブルと関連するuser_skillおよびskillsのデータを結合して取得
    const { data, error } = await supabase
        .from('users')//usersテーブルから各データを取得
        .select(`
          user_id,
          name,
          description,
          github_id,
          qiita_id,
          x_id,
          user_skill (skill_id,skills ( name ))`//user_skillからskill_id、skillsからnameを取得
        )
        .eq('user_id', userid);

    if (error || !data || data.length === 0) {
        toast({
            title: 'IDが見つかりません',
            position: 'top',
            status: 'error',
            duration: 2000,
            isClosable: true,
        })
        console.error('Error fetching data:', error);
        setLoading(false);
        return false;
    }
    console.log('取得データ加工前', data)
    // スキル名は配列の中のオブジェクトとして取得されるため、スキル名をカンマ区切りの文字列に変換
    const userDataWithSkills = data.map(user => ({
        skills: user.user_skill.map(skill => {
            //型エラーが出るため、配列か否かを判定
            if (Array.isArray(skill.skills)) {
                console.log('Array:', skill.skills);
                return skill.skills.map(s => s.name).join(', ');
            } else {
                // 型アサーションで、`skills`がオブジェクトで`name`プロパティを持つことを明示
                console.log('Object:', skill.skills);
                return (skill.skills as { name: string }).name;
            }
        }).join(', '),
        skill_id: user.user_skill[0].skill_id || 0,
        user_id: user.user_id,
        name: user.name,
        description: user.description,
        github_id: user.github_id,
        qiita_id: user.qiita_id,
        x_id: user.x_id,
    })
    );
    console.log('取得データ:', userDataWithSkills,);

    // cardDataにセット
    setCardData(userDataWithSkills);
    toast({
      title: 'データを取得しました',
      position: 'top',
      status: 'success',
      duration: 2000,
      isClosable: true,
    })
    setLoading(false);
    return true;
};

解説していきます。

// /src/App.tsx

    // usersテーブルと関連するuser_skillおよびskillsのデータを結合して取得
    const { data, error } = await supabase
        .from('users')//usersテーブルから各データを取得
        .select(`
          user_id,
          name,
          description,
          github_id,
          qiita_id,
          x_id,
          user_skill (skill_id,skills ( name ))`//user_skillからskill_id、skillsからnameを取得
        )
        .eq('user_id', userid);

select処理で、usersテーブルから各データを取得しています。user_skillテーブルを外部結合しているので、usersテーブルからの取得指定の中で、user_skill (skill_id,skills ( name )) の記載で、user_skillテーブルのskii_id、そして更にuser_skillと結合している、skillsテーブルのnameをこの記述で取得できます。

がややこしいのは、この後です。これで取得されたデータは、console.log(‘取得データ加工前’, data)の指定で、コンソールに出力されますが、下図のような構造になっています。

Supabaseからデータを取得した場合、データは配列で返ってきます(①)。その中身を見ていくと、usersテーブルのデータはオブジェクトとして、フラットに入っていますが、user_skillテーブルのデータは更にオブジェクトを格納した配列で返って来ています(②)。更にその中身を見ると、skillsテーブルのデータがオブジェクト形式で入っています。 {skill_id: 1, skills: {name: React} } と言う構造です(③)。

よって、Supabaseから取得したデータ(data)を後続で以下のように処理しています。

    // /src/App.tsx
    
    // スキル名は配列の中のオブジェクトとして取得されるため、スキル名をカンマ区切りの文字列に変換
    const userDataWithSkills = data.map(user => ({
        skills: user.user_skill.map(skill => {
            //型エラーが出るため、配列か否かを判定
            if (Array.isArray(skill.skills)) {
                console.log('Array:', skill.skills);
                return skill.skills.map(s => s.name).join(', ');
            } else {
                // 型アサーションで、`skills`がオブジェクトで`name`プロパティを持つことを明示
                console.log('Object:', skill.skills);
                return (skill.skills as { name: string }).name;
            }
        }).join(', '),
        skill_id: user.user_skill[0].skill_id || 0,
        user_id: user.user_id,
        name: user.name,
        description: user.description,
        github_id: user.github_id,
        qiita_id: user.qiita_id,
        x_id: user.x_id,
    })
    );
    console.log('取得データ:', userDataWithSkills,);
    
        // cardDataにセット
    setCardData(userDataWithSkills);
    toast({
        title: 'データを取得しました',
        position: 'top',
        status: 'success',
        duration: 2000,
        isClosable: true,
    })
    setLoading(false);
    return true;

取得した、skillsテーブルのデータを入れ子構造から抽出するために、mapを二重にまわして抽出しています。取得するデータがTypeScript側で配列なのかオブジェクトなのか判断できない為、
if (Array.isArray(skill.skills)) {….} else {….} と言う形で、Array(配列)か否かの判定と、配列であれば配列としての処理 ー mapでデータ格納 ー 、オブジェクトであれば、string型としてデータをセットしています。そしてそれを他の項目と共に、userDataWithSkillsに格納しています。
また、同様に1重の配列入れ子となっている、skill_idは、user.user_skill[0](配列最初のデータ)を取得して格納しています。

そして、それぞれのデータを正しい形で格納したuserDataWithSkillsを、setCardDataでcardDataステートに格納しています。consolo.logでuserDataWithSkillの内容をコンソール出力するようにしてますので、確認すると以下のような状態になっていることが分かります。

これで、複数テーブル結合による、selectDbは完成です。
なお、insert, update, delete については、従来通りのものとしてます。参照系以外はステップを踏んで実施するのが良いようです(ChatGPT先生曰く)。Viewの定義などをすればもっと効果的な方法もあるのかも知れないですが、本章はここまでとします。

なお、変更後のApp.tsx全文は以下のとおりです。

// /src/App.tsx

import { useEffect, useState } from "react";
import { Route, Routes, useNavigate } from "react-router-dom"
import { useToast } from "@chakra-ui/react";
import Home from "./components/Home"
import Cards from "./components/Cards";
import { CardData, SkillData } from "./cardData";//SkillDataインポート追加
import { supabase } from "./supabaseClient";
import Register from "./components/Register";
import { useLocalStorage } from "./hooks/useLocalStorage";

function App() {
  const [userid, setUserid] = useState<string>();
  const [cardData, setCardData] = useState<CardData[]>([]);
  const [newCard, setNewCard] = useState<CardData>({
    user_id: '',
    name: '',
    description: '',
    github_id: '',
    qiita_id: '',
    x_id: '',
    skill_id: 0,
    skills: '',
  });
  const [loading, setLoading] = useState<boolean>(false);
  const [skillData, setSkillData] = useState<SkillData[]>([])//skill table読み込み用ステート追加
  const toast = useToast()
  const navigate = useNavigate()
  const { setItemWithExpiry, getItemWithExpiry } = useLocalStorage()

  useEffect(() => {
    const localUserid = getItemWithExpiry('namecard-userid');
    if (localUserid) {
      console.log('localUserid', localUserid.value)
      setUserid(localUserid.value)
    }
  }, [])

  // Supabaseからデータを取得する関数
  const selectDb = async (): Promise<boolean> => {
    setLoading(true);

    // usersテーブルと関連するuser_skillおよびskillsのデータを結合して取得
    const { data, error } = await supabase
      .from('users')
      .select(`
          user_id,
          name,
          description,
          github_id,
          qiita_id,
          x_id,
          user_skill (skill_id,skills ( name ))`//user_skillからskill_id、skillsからnameを取得
      )
      .eq('user_id', userid);

    if (error || !data || data.length === 0) {
      toast({
        title: 'IDが見つかりません',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error fetching data:', error);
      setLoading(false);
      return false;
    }
    console.log('取得データ加工前', data)
    // スキル名は配列の中のオブジェクトとして取得されるため、スキル名をカンマ区切りの文字列に変換
    const userDataWithSkills = data.map(user => ({
      skills: user.user_skill.map(skill => {
        //型エラーが出るため、配列か否かを判定
        if (Array.isArray(skill.skills)) {
          console.log('Array:', skill.skills);
          return skill.skills.map(s => s.name).join(', ');
        } else {
          // 配列でなければ、型アサーションで、`skills`がオブジェクトで`name`プロパティを持つことを明示
          console.log('Object:', skill.skills);
          return (skill.skills as { name: string }).name;
        }
      }).join(', '),
      skill_id: user.user_skill[0].skill_id || 0,
      user_id: user.user_id,
      name: user.name,
      description: user.description,
      github_id: user.github_id,
      qiita_id: user.qiita_id,
      x_id: user.x_id,
    })
    );
    console.log('取得データ:', userDataWithSkills,);

    // cardDataにセット
    setCardData(userDataWithSkills);
    toast({
      title: 'データを取得しました',
      position: 'top',
      status: 'success',
      duration: 2000,
      isClosable: true,
    })
    setLoading(false);
    return true;
  };

  //DBへの新規データ登録
  // Step 1: usersへの登録
  const insertDb = async (card: CardData): Promise<boolean> => {
    setLoading(true);
    const { data: userData, error: userError } = await supabase
      .from('users')
      .insert([
        {
          user_id: card.user_id,
          name: card.name,
          description: card.description,
          github_id: card.github_id,
          qiita_id: card.qiita_id,
          x_id: card.x_id,
        },
      ])
      .select();
    if (userError || !userData) {
      console.error('Error insert data:', userError);
      toast({
        title: 'ユーザ登録が失敗しました',
        description: `${userError}`,
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      setLoading(false);
      return false;
    }
    console.log('insertDb step1', userData, userError)
    // Step 2: user_skillテーブルへの登録
    const { data: userSkillData, error: userSkillError } = await supabase
      .from('user_skill')
      .insert([
        {
          user_id: card.user_id,
          skill_id: card.skill_id,
        },
      ])
      .select();
    if (userSkillError || !userSkillData) {
      console.error('Error insert data:', userSkillError);
      toast({
        title: 'ユーザ登録が失敗しました',
        description: `${userSkillError}`,
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      setLoading(false);
      return false;
    }
    setLoading(false);
    toast({
      title: 'ユーザ登録に成功しました',
      position: 'top',
      status: 'success',
      duration: 2000,
      isClosable: true,
    })
    console.log('iinsertDb step2', userSkillData);
    return true;
  }

  //登録ユーザの重複チェック
  const userCheckDb = async (card: CardData): Promise<boolean> => {
    setLoading(true);
    const { data: userData, error: userError } = await supabase
      .from('users')
      .select('*')
      .eq('user_id', card.user_id);

    if (userData && userData.length > 0) {
      toast({
        title: '既にIDが使われています',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('既にIDが使われています:', userError, userData);
      setLoading(false);
      return false;
    }

    setLoading(false);
    return true;
  }

  //DBの更新処理
  const updateDb = async (card: CardData): Promise<boolean> => {
    setLoading(true);
    //step1:usersテーブルの更新
    const { data: userData, error: userError } = await supabase
      .from('users')
      .update({
        name: card.name,
        description: card.description,
        github_id: card.github_id,
        qiita_id: card.qiita_id,
        x_id: card.x_id,
      })
      .eq('user_id', card.user_id)
      .select();
    if (userError || !userData || userData.length === 0) {
      toast({
        title: 'IDが見つかりません',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error fetching user data:', userError);
      setLoading(false);
      return false;
    }
    console.log('dbUpdate step1', userData)

    // Step 2: user_skillテーブルへの登録、user_skillテーブルは値が変わってないデータをupdateするとエラーになるため、値が同一かどうかをチェック
    // Step 2-1: 現在のskill_idを取得
    const { data: currentUserSkillData, error: currentUserSkillError } = await supabase
      .from('user_skill')
      .select('skill_id')
      .eq('user_id', card.user_id);
    console.log('dbUpdate step2-1', currentUserSkillData)

    if (currentUserSkillError || !currentUserSkillData) {
      toast({
        title: '現在の Skill が取得できません',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error fetching current user skills:', currentUserSkillError);
      setLoading(false);
      return false;
    }

    const currentSkillIds = currentUserSkillData.map((skill) => skill.skill_id);
    console.log('dbUpdate step2-1比較', currentUserSkillData, currentSkillIds, card.skill_id)
    const newSkillIds = Array.isArray(card.skill_id) ? card.skill_id : [card.skill_id]; // 配列でない場合に配列化

    // 現在の skill_id 配列と新しい skill_id 配列が異なる場合のみ更新
    const isDifferent = newSkillIds.some((id: number) => !currentSkillIds.includes(id));

    if (isDifferent) {
      const { data: userSkillData, error: userSkillError } = await supabase
        .from('user_skill')
        .update({
          skill_id: card.skill_id,
        })
        .eq('user_id', card.user_id)
        .select();

      if (userSkillError || !userSkillData || userSkillData.length === 0) {
        toast({
          title: 'Skill が見つかりません',
          position: 'top',
          status: 'error',
          duration: 2000,
          isClosable: true,
        })
        console.error('Error updating user skills:', userSkillError);
        setLoading(false);
        return false;
      }
      console.log('dbUpdate step2', userSkillData);
    } else {
      console.log('Skill ID は変更されていないため、更新しません');
    }

    setLoading(false);
    toast({
      title: '更新が完了しました',
      position: 'top',
      status: 'success',
      duration: 2000,
      isClosable: true,
    })
    return true;
  }

  //DBの削除処理
  const deleteDb = async (userid: string): Promise<boolean> => {
    setLoading(true);

    // Step 1: usersテーブルからuser_idを削除
    const { error: userError } = await supabase
      .from('users')
      .delete()
      .eq('user_id', userid);
    if (userError) {
      toast({
        title: '削除が失敗しました',
        description: `${userError}`,
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error delete data:', userError);
      setLoading(false);
      return false;
    }
    // Step 2: user_skillテーブルからuser_idを削除
    const { error: userSkillError } = await supabase
      .from('user_skill')
      .delete()
      .eq('user_id', userid);
    if (userError) {
      toast({
        title: '削除が失敗しました',
        description: `${userSkillError}`,
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error delete data:', userSkillError);
      setLoading(false);
      return false;
    }
    setLoading(false);
    toast({
      title: 'データを削除しました',
      position: 'top',
      status: 'success',
      duration: 2000,
      isClosable: true,
    })
    return true;
  }


  // skills table読み込み
  const selectSkillDb = async (): Promise<boolean> => {
    setLoading(true);

    const { data: userData, error: userError } = await supabase
      .from('skills')
      .select('*')

    if (userError || !userData || userData.length === 0) {
      toast({
        title: 'IDが見つかりません',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error fetching user data:', userError);
      setLoading(false);
      return false;
    }
    setSkillData(userData);
    setLoading(false);
    return true;
  }

  //skills tableのskillDataステートへのセットの為、useEffectを使用、selectSkillDb()によるステートへのセットを確実に実施する為、async/await処理
  useEffect(() => {
    const fetchSkillData = async () => {
      try {
        await selectSkillDb();
      } catch (error) {
        toast({
          title: '予期せぬエラーが発生しました',
          position: 'top',
          status: 'error',
          duration: 2000,
          isClosable: true,
        })
      }
    };

    fetchSkillData();
  }, []);


  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setUserid(e.target.value)
  }

  const handleSearch = async () => {
    if (userid) {
      const success = await selectDb();
      if (success) {
        const ttl = 1000 * 3600 * 24 * 7
        setItemWithExpiry('namecard-userid', userid, ttl);
        navigate(`/card/${userid}`);
      }
    } else {
      toast({
        title: 'IDが見つかりません',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
    }
  };

  return (
    <>
      <Routes>
        <Route path="/" element={<Home userid={userid || ''} loading={loading} handleInputChange={handleInputChange} handleSearch={handleSearch} />} />
        <Route path='/card/:userid' element={<Cards cardData={cardData} setCardData={setCardData} loading={loading} setLoading={setLoading} updateDb={updateDb} selectDb={selectDb} deleteDb={deleteDb} skillData={skillData} />//skillData追加
        } />
        <Route path='/card/register' element={<Register loading={loading} newCard={newCard} setNewCard={setNewCard} setUserid={setUserid} insertDb={insertDb} userCheckDb={userCheckDb} skillData={skillData} />//skillData追加
        } />
      </Routes>
    </>
  )
}

export default App

4. useContext

最後に、useContextを利用してみたいと思います。
useContextは、State及びPropsを集中管理する仕組みです。扱うコンポーネントや多層構造のコンポーネントが多くなる場合(コンポーネントから更に別のコンポーネントを呼び出すなど)は、App.tsxから渡すPropsの管理が煩雑になります。それを集約管理するものです。

イメージとしては下図のようなイメージです。

通常、Propsは、トップコンポーネント(App.tsx)から子コンポーネントに渡され、更に孫コンポーネントがある場合は、トップコンポーネント → 子コンポーネント → 孫コンポーネントとバケツリレーで渡す必要があります。また子コンポーネント間でのPropsの受け渡しは出来ません。
が、useContextを利用すれば、どのコンポーネントもuseContextのコンポーネントからPropsを受け取る事ができます。

今回の名刺アプリにおいては、例えば、Editコンポーネントは、App → Crads → EditとバケツリレーでPropsを受け取っています。

4.1 Contextファイルの作成

それでは、useContext用のコンポーネントを作成していきます。
srcフォルダの配下に、contextと言うフォルダを作成します。その中にindex.tsxと言うファイルを作成します。

index.tsxに以下コードを記載します。

// /src/context/index.tsx

const ContextProvider = () => {
    return (
        <>
        </>
    )
}
export default ContextProvider

まずは枠のみを記載しています。
続いて、以下の内容を追加します。

// /src/context/index.tsx

import { createContext, ReactNode } from "react"//インポート追加

export const AppContext = createContext({})//createContextの設定
type ContextProviderProps = {//ContextProvider用の型定義
    children: ReactNode
};

const ContextProvider: React.FC<ContextProviderProps> = (props) => {//型定義とprops追加
    return (
        //変更・追加ここから、{props.children}を<AppContext.Provider>で囲む
        <AppContext.Provider value>
            {props.children}
        </AppContext.Provider>
        //変更・追加追加ここまで
    )
}
export default ContextProvider

次に、現在App.tsxに記載している、ステート設定や、DB処理等の関数関係を、ごっそり、このContextProviderに移行します。

下記、App.tsxの全体と移行する箇所です。

// /src/App.tsx

import { useEffect, useState } from "react";
import { Route, Routes, useNavigate } from "react-router-dom"
import { useToast } from "@chakra-ui/react";
import Home from "./components/Home"
import Cards from "./components/Cards";
import { CardData, SkillData } from "./cardData";
import { supabase } from "./supabaseClient";
import Register from "./components/Register";
import { useLocalStorage } from "./hooks/useLocalStorage";

function App() {
/*ContextProviderに移行、ここから
  const [userid, setUserid] = useState<string>();
  const [cardData, setCardData] = useState<CardData[]>([]);
  const [newCard, setNewCard] = useState<CardData>({
    user_id: '',
    name: '',
    description: '',
    github_id: '',
    qiita_id: '',
    x_id: '',
    skill_id: 0,
    skills: '',
  });
  const [loading, setLoading] = useState<boolean>(false);
  const [skillData, setSkillData] = useState<SkillData[]>([])
  const toast = useToast()
  const navigate = useNavigate()
  const { setItemWithExpiry, getItemWithExpiry } = useLocalStorage()

  useEffect(() => {
    const localUserid = getItemWithExpiry('namecard-userid');
    if (localUserid) {
      console.log('localUserid', localUserid.value)
      setUserid(localUserid.value)
    }
  }, [])

  // Supabaseからデータを取得する関数
  const selectDb = async (): Promise<boolean> => {
    setLoading(true);

    // usersテーブルと関連するuser_skillおよびskillsのデータを結合して取得
    const { data, error } = await supabase
      .from('users')
      .select(`
          user_id,
          name,
          description,
          github_id,
          qiita_id,
          x_id,
          user_skill (skill_id,skills ( name ))`//user_skillからskill_id、skillsからnameを取得
      )
      .eq('user_id', userid);

    if (error || !data || data.length === 0) {
      toast({
        title: 'IDが見つかりません',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error fetching data:', error);
      setLoading(false);
      return false;
    }
    console.log('取得データ加工前', data)
    // スキル名は配列の中のオブジェクトとして取得されるため、スキル名をカンマ区切りの文字列に変換
    const userDataWithSkills = data.map(user => ({
      skills: user.user_skill.map(skill => {
        //型エラーが出るため、配列か否かを判定
        if (Array.isArray(skill.skills)) {
          console.log('Array:', skill.skills);
          return skill.skills.map(s => s.name).join(', ');
        } else {
          // 配列でなければ、型アサーションで、`skills`がオブジェクトで`name`プロパティを持つことを明示
          console.log('Object:', skill.skills);
          return (skill.skills as { name: string }).name;
        }
      }).join(', '),
      skill_id: user.user_skill[0].skill_id || 0,
      user_id: user.user_id,
      name: user.name,
      description: user.description,
      github_id: user.github_id,
      qiita_id: user.qiita_id,
      x_id: user.x_id,
    })
    );
    console.log('取得データ:', userDataWithSkills,);

    // cardDataにセット
    setCardData(userDataWithSkills);
    toast({
      title: 'データを取得しました',
      position: 'top',
      status: 'success',
      duration: 2000,
      isClosable: true,
    })
    setLoading(false);
    return true;
  };

  //DBへの新規データ登録
  // Step 1: usersへの登録
  const insertDb = async (card: CardData): Promise<boolean> => {
    setLoading(true);
    const { data: userData, error: userError } = await supabase
      .from('users')
      .insert([
        {
          user_id: card.user_id,
          name: card.name,
          description: card.description,
          github_id: card.github_id,
          qiita_id: card.qiita_id,
          x_id: card.x_id,
        },
      ])
      .select();
    if (userError || !userData) {
      console.error('Error insert data:', userError);
      toast({
        title: 'ユーザ登録が失敗しました',
        description: `${userError}`,
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      setLoading(false);
      return false;
    }
    console.log('insertDb step1', userData, userError)
    // Step 2: user_skillテーブルへの登録
    const { data: userSkillData, error: userSkillError } = await supabase
      .from('user_skill')
      .insert([
        {
          user_id: card.user_id,
          skill_id: card.skill_id,
        },
      ])
      .select();
    if (userSkillError || !userSkillData) {
      console.error('Error insert data:', userSkillError);
      toast({
        title: 'ユーザ登録が失敗しました',
        description: `${userSkillError}`,
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      setLoading(false);
      return false;
    }
    setLoading(false);
    toast({
      title: 'ユーザ登録に成功しました',
      position: 'top',
      status: 'success',
      duration: 2000,
      isClosable: true,
    })
    console.log('iinsertDb step2', userSkillData);
    return true;
  }

  //登録ユーザの重複チェック
  const userCheckDb = async (card: CardData): Promise<boolean> => {
    setLoading(true);
    const { data: userData, error: userError } = await supabase
      .from('users')
      .select('*')
      .eq('user_id', card.user_id);

    if (userData && userData.length > 0) {
      toast({
        title: '既にIDが使われています',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('既にIDが使われています:', userError, userData);
      setLoading(false);
      return false;
    }

    setLoading(false);
    return true;
  }

  //DBの更新処理
  const updateDb = async (card: CardData): Promise<boolean> => {
    setLoading(true);
    //step1:usersテーブルの更新
    const { data: userData, error: userError } = await supabase
      .from('users')
      .update({
        name: card.name,
        description: card.description,
        github_id: card.github_id,
        qiita_id: card.qiita_id,
        x_id: card.x_id,
      })
      .eq('user_id', card.user_id)
      .select();
    if (userError || !userData || userData.length === 0) {
      toast({
        title: 'IDが見つかりません',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error fetching user data:', userError);
      setLoading(false);
      return false;
    }
    console.log('dbUpdate step1', userData)

    // Step 2: user_skillテーブルへの登録、user_skillテーブルは値が変わってないデータをupdateするとエラーになるため、値が同一かどうかをチェック
    // Step 2-1: 現在のskill_idを取得
    const { data: currentUserSkillData, error: currentUserSkillError } = await supabase
      .from('user_skill')
      .select('skill_id')
      .eq('user_id', card.user_id);
    console.log('dbUpdate step2-1', currentUserSkillData)

    if (currentUserSkillError || !currentUserSkillData) {
      toast({
        title: '現在の Skill が取得できません',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error fetching current user skills:', currentUserSkillError);
      setLoading(false);
      return false;
    }

    const currentSkillIds = currentUserSkillData.map((skill) => skill.skill_id);
    console.log('dbUpdate step2-1比較', currentUserSkillData, currentSkillIds, card.skill_id)
    const newSkillIds = Array.isArray(card.skill_id) ? card.skill_id : [card.skill_id]; // 配列でない場合に配列化

    // 現在の skill_id 配列と新しい skill_id 配列が異なる場合のみ更新
    const isDifferent = newSkillIds.some((id: number) => !currentSkillIds.includes(id));

    if (isDifferent) {
      const { data: userSkillData, error: userSkillError } = await supabase
        .from('user_skill')
        .update({
          skill_id: card.skill_id,
        })
        .eq('user_id', card.user_id)
        .select();

      if (userSkillError || !userSkillData || userSkillData.length === 0) {
        toast({
          title: 'Skill が見つかりません',
          position: 'top',
          status: 'error',
          duration: 2000,
          isClosable: true,
        })
        console.error('Error updating user skills:', userSkillError);
        setLoading(false);
        return false;
      }
      console.log('dbUpdate step2', userSkillData);
    } else {
      console.log('Skill ID は変更されていないため、更新しません');
    }

    setLoading(false);
    toast({
      title: '更新が完了しました',
      position: 'top',
      status: 'success',
      duration: 2000,
      isClosable: true,
    })
    return true;
  }

  //DBの削除処理
  const deleteDb = async (userid: string): Promise<boolean> => {
    setLoading(true);

    // Step 1: usersテーブルからuser_idを削除
    const { error: userError } = await supabase
      .from('users')
      .delete()
      .eq('user_id', userid);
    if (userError) {
      toast({
        title: '削除が失敗しました',
        description: `${userError}`,
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error delete data:', userError);
      setLoading(false);
      return false;
    }
    // Step 2: user_skillテーブルからuser_idを削除
    const { error: userSkillError } = await supabase
      .from('user_skill')
      .delete()
      .eq('user_id', userid);
    if (userError) {
      toast({
        title: '削除が失敗しました',
        description: `${userSkillError}`,
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error delete data:', userSkillError);
      setLoading(false);
      return false;
    }
    setLoading(false);
    toast({
      title: 'データを削除しました',
      position: 'top',
      status: 'success',
      duration: 2000,
      isClosable: true,
    })
    return true;
  }


  // skills table読み込み
  const selectSkillDb = async (): Promise<boolean> => {
    setLoading(true);

    const { data: userData, error: userError } = await supabase
      .from('skills')
      .select('*')

    if (userError || !userData || userData.length === 0) {
      toast({
        title: 'IDが見つかりません',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
      console.error('Error fetching user data:', userError);
      setLoading(false);
      return false;
    }
    setSkillData(userData);
    setLoading(false);
    return true;
  }

  //skills tableのskillDataステートへのセットの為、useEffectを使用、selectSkillDb()によるステートへのセットを確実に実施する為、async/await処理
  useEffect(() => {
    const fetchSkillData = async () => {
      try {
        await selectSkillDb();
      } catch (error) {
        toast({
          title: '予期せぬエラーが発生しました',
          position: 'top',
          status: 'error',
          duration: 2000,
          isClosable: true,
        })
      }
    };

    fetchSkillData();
  }, []);


  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setUserid(e.target.value)
  }

  const handleSearch = async () => {
    if (userid) {
      const success = await selectDb();
      if (success) {
        const ttl = 1000 * 3600 * 24 * 7
        setItemWithExpiry('namecard-userid', userid, ttl);
        navigate(`/card/${userid}`);
      }
    } else {
      toast({
        title: 'IDが見つかりません',
        position: 'top',
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
    }
  };
ContextProviderの移行ここまで */
  return (
    <>
      <Routes>
        <Route path="/" element={<Home userid={userid || ''} loading={loading} handleInputChange={handleInputChange} handleSearch={handleSearch} />} />
        <Route path='/card/:userid' element={<Cards cardData={cardData} setCardData={setCardData} loading={loading} setLoading={setLoading} updateDb={updateDb} selectDb={selectDb} deleteDb={deleteDb} skillData={skillData} />//skillData追加
        } />
        <Route path='/card/register' element={<Register loading={loading} newCard={newCard} setNewCard={setNewCard} setUserid={setUserid} insertDb={insertDb} userCheckDb={userCheckDb} skillData={skillData} />//skillData追加
        } />
      </Routes>
    </>
  )
}

export default App

上記、コメントアウトした箇所をごっそり、/src/context/index.tsx に移行します。下記のコードになります。

// /src/context/index.tsx

import { createContext, ReactNode, useEffect, useState } from "react"//インポート追加
import { CardData, SkillData } from "../cardData"//インポート追加
import { useToast } from "@chakra-ui/react"//インポート追加
import { useNavigate } from "react-router-dom"//インポート追加
import { supabase } from "../supabaseClient"//インポート追加
import { useLocalStorage } from "../hooks/useLocalStorage"//インポート追加

export const AppContext = createContext({})
type ContextProviderProps = {
    children: ReactNode
};

const ContextProvider: React.FC<ContextProviderProps> = (props) => {
    //////////// 追加ここから ////////////
    const [userid, setUserid] = useState<string>('');
    const [cardData, setCardData] = useState<CardData[]>([]);
    const [newCard, setNewCard] = useState<CardData>({
        user_id: '',
        name: '',
        description: '',
        github_id: '',
        qiita_id: '',
        x_id: '',
        skill_id: 0,
        skills: '',
    });
    const [loading, setLoading] = useState<boolean>(false);
    const [skillData, setSkillData] = useState<SkillData[]>([])
    const toast = useToast()
    const navigate = useNavigate()
    const { setItemWithExpiry, getItemWithExpiry } = useLocalStorage()

    useEffect(() => {
        const localUserid = getItemWithExpiry('namecard-userid');
        if (localUserid) {
            console.log('localUserid', localUserid.value)
            setUserid(localUserid.value)
        }
    }, [])

    // Supabaseからデータを取得する関数
    const selectDb = async (): Promise<boolean> => {
        setLoading(true);

        // usersテーブルと関連するuser_skillおよびskillsのデータを結合して取得
        const { data, error } = await supabase
            .from('users')
            .select(`
            user_id,
            name,
            description,
            github_id,
            qiita_id,
            x_id,
            user_skill (skill_id,skills ( name ))`//user_skillからskill_id、skillsからnameを取得
            )
            .eq('user_id', userid);

        if (error || !data || data.length === 0) {
            toast({
                title: 'IDが見つかりません',
                position: 'top',
                status: 'error',
                duration: 2000,
                isClosable: true,
            })
            console.error('Error fetching data:', error);
            setLoading(false);
            return false;
        }
        console.log('取得データ加工前', data)
        // スキル名は配列の中のオブジェクトとして取得されるため、スキル名をカンマ区切りの文字列に変換
        const userDataWithSkills = data.map(user => ({
            skills: user.user_skill.map(skill => {
                //型エラーが出るため、配列か否かを判定
                if (Array.isArray(skill.skills)) {
                    console.log('Array:', skill.skills);
                    return skill.skills.map(s => s.name).join(', ');
                } else {
                    // 配列でなければ、型アサーションで、`skills`がオブジェクトで`name`プロパティを持つことを明示
                    console.log('Object:', skill.skills);
                    return (skill.skills as { name: string }).name;
                }
            }).join(', '), 0,
            skill_id: user.user_skill[0].skill_id || 0,
            user_id: user.user_id,
            name: user.name,
            description: user.description,
            github_id: user.github_id,
            qiita_id: user.qiita_id,
            x_id: user.x_id,
        })
        );
        console.log('取得データ:', userDataWithSkills,);

        // cardDataにセット
        setCardData(userDataWithSkills);
        toast({
            title: 'データを取得しました',
            position: 'top',
            status: 'success',
            duration: 2000,
            isClosable: true,
        })
        setLoading(false);
        return true;
    };

    //DBへの新規データ登録
    // Step 1: usersへの登録
    const insertDb = async (card: CardData): Promise<boolean> => {
        setLoading(true);
        const { data: userData, error: userError } = await supabase
            .from('users')
            .insert([
                {
                    user_id: card.user_id,
                    name: card.name,
                    description: card.description,
                    github_id: card.github_id,
                    qiita_id: card.qiita_id,
                    x_id: card.x_id,
                },
            ])
            .select();
        if (userError || !userData) {
            console.error('Error insert data:', userError);
            toast({
                title: 'ユーザ登録が失敗しました',
                description: `${userError}`,
                position: 'top',
                status: 'error',
                duration: 2000,
                isClosable: true,
            })
            setLoading(false);
            return false;
        }
        console.log('insertDb step1', userData, userError)
        // Step 2: user_skillテーブルへの登録
        const { data: userSkillData, error: userSkillError } = await supabase
            .from('user_skill')
            .insert([
                {
                    user_id: card.user_id,
                    skill_id: card.skill_id,
                },
            ])
            .select();
        if (userSkillError || !userSkillData) {
            console.error('Error insert data:', userSkillError);
            toast({
                title: 'ユーザ登録が失敗しました',
                description: `${userSkillError}`,
                position: 'top',
                status: 'error',
                duration: 2000,
                isClosable: true,
            })
            setLoading(false);
            return false;
        }
        setLoading(false);
        toast({
            title: 'ユーザ登録に成功しました'