Closed148

Hono, TanStack Query練習

Yug (やぐ)Yug (やぐ)

データベース, ORMなど知識皆無なので、全部担ってくれるSupabaseに頼る

honoのapi書くファイル内でsupabaseのjsコード書いてdbアクセスして、て感じでいけそう

apiエンドポイントがあるとは言えさすがにクライアントルーティングは別で用意する必要がありそうなのでtanstack routerも使うことになると思う

Yug (やぐ)Yug (やぐ)

ディレクトリ構成はこうなりそう

project
├──front
│    └──src    // React
└──back
    └──index.tsx    // Hono & Supabase

frontでcreate vite、backでcreate honoするか

Yug (やぐ)Yug (やぐ)
  • フロントエンド
npm create vite@latest frontend

https://ja.vite.dev/guide/#最初の-vite-プロジェクトを生成する

  • バックエンド
npm create hono@latest backend

https://hono.dev/docs/getting-started/basic#starter

honoのテンプレートはcf pagesで良いか(よくわからんが)

PagesとWorkersは機能的にも似通っていてややこしいのですが、ざっくりとPagesは静的コンテンツ向けのホスティングサービスで、シンプルなデプロイや配信に最適で、Workersは動的な処理やサーバーレスアプリに特化した、より高度なカスタマイズが可能なサービスです。

https://www.spread1.co.jp/wp/post-2727/

バックエンド側でsupabaseインストール

cd backend/
npm i @supabase/supabase-js

https://www.npmjs.com/package/@supabase/supabase-js

Yug (やぐ)Yug (やぐ)

メモ

  • Honoのインスタンス作る時に使用するBindingsって型はなんだ

https://hono.dev/docs/getting-started/cloudflare-pages#use-bindings-in-your-application

  • middlewareっていうのが何なのかわかってない
    • 認証とか?でもtanstackのbeforeLoadでsupabase-js書けば認証できちゃうな
  • jsxをhonoから返せるっぽい(今回それやるかはわからん)
Yug (やぐ)Yug (やぐ)

middlewareは、tanstack routerのbeforeLoadみたいなクライアントルーティングでのpathアクセス前に認証かけたりするやつとは違って、apiエンドポイントアクセス時にそのエンドポイントにおいて何かをreturnする前に認証かけたりするやつっぽい。CORSをhono middlewareで書いたらイメージ掴めた

Yug (やぐ)Yug (やぐ)

c.renderしちゃうとjsxがそのままレンダーされて画面表示されちゃう気がするので、ただレスポンスしてほしいな

レスポンスはどうやってやるんだ?

geminiに聞いた。return c.textでいけるのか

app.get('/', (c) => {
  return c.text('はじめる')
})
Yug (やぐ)Yug (やぐ)

んでそれを受け取るフロント側も初めて書くがやってみよう

  • まずfetchで得る型はPromise<Response>ぽい
  • それをresponseとして受け取ったら、更にresponse.text()までしてやっと生のテキストとして使える

text() は Response インターフェイスのメソッドで、 Response ストリームを取得して完全に読み込みます。文字列で解決するプロミスを返します。

https://developer.mozilla.org/ja/docs/Web/API/Response/text

今回honoからはreturn c.text()でテキストとして返してるから、jsonとして解釈しようとするrespose.json()は使えないのでresponse.text()使うってことか

json() は Response インターフェイスのメソッドで、 Response のストリームを取得して完全に読み取ります。本体のテキストを JSON として解釈した結果で解決するプロミスを返します。

なお、このメソッドは json() という名前であるにもかかわらず、結果は JSON ではありません。入力として JSON を取って解釈し、 JavaScript のオブジェクトを生成します。

https://developer.mozilla.org/ja/docs/Web/API/Response/json

Yug (やぐ)Yug (やぐ)

フロント側でCORSエラーが出たのでHono側でCORSコードを書く必要もありそうだ

用意されてる、見つけた
https://hono.dev/docs/middleware/builtin/cors

originをhttp://localhost:5000/にしてあげるとかでいけそう

あとTanStack Queryとか使って、use()に渡すpromiseをキャッシュしろというのをよく聞くのでその導入もやりたい
キャッシュしないとレンダリングごとに毎回fetchして新しくpromise作っちゃうから非効率とかそういう理由だと思う(そんでそこから下手すると無限レンダリングに繋がったりするのかも?)
https://speakerdeck.com/diggymo/reactnouse-tutehe-nan-6af6d25f-ac0d-4e61-b0db-c11b1737e906?slide=15

Yug (やぐ)Yug (やぐ)

よし、Honoで以下ミドルウェア書いたら画面描画が理想通り機能した

app.use(
  cors({
    origin: 'http://localhost:5000',
  })
)

https://hono.dev/docs/middleware/builtin/cors#usage

だがコンソールにエラーは出ている

まさにこれだな
https://speakerdeck.com/diggymo/reactnouse-tutehe-nan-6af6d25f-ac0d-4e61-b0db-c11b1737e906?slide=15

キャッシュしてないpromiseが見つかったら自動でエラー出して「キャッシュしろ!」て警告を出すReactの仕様だろう

同じくuse()使ってSupabaseのAPI叩いたときは何もエラー出なかった理由は、supabaseのDB APIにはすでに全origin許可のCORS設定が書かれているのだろう

あーやっぱそうっぽい、これだ。なるほど

Yug (やぐ)Yug (やぐ)

とりあえずTanStack Queryを導入してキャッシュしていく

TanStack Query編

npm i @tanstack/react-query

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

npm i @tanstack/react-query-devtools

https://tanstack.com/query/latest/docs/framework/react/devtools#install-and-import-the-devtools

supabaseコードもawait使うような非同期処理なのでbackend/内にもインストールすべきか?

いやでも...

Suspense機能させたい

use()使うしかない

tanstack query導入してキャッシュせねば

という流れなので、クライアント側でSuspenseしちゃえばバックエンドの非同期待ちは拾えるから普通にSuspense機能させることができるのでバックエンドではtanstack query不要か

まぁでもbackend/ではキャッシュできなくなるからちょっと非効率にはなるのかも
なので余裕があったらbackend/でも導入しよう

とりあえずfrontend/で導入していく

Yug (やぐ)Yug (やぐ)

まずこれ読んでReactQueryDevtools書く
https://tanstack.com/query/latest/docs/framework/react/devtools#floating-mode

んでこれ読んでQueryClientProviderとかそのあたり書く
https://tanstack.com/query/latest/docs/framework/react/quick-start

既にtanstack routerのRouterProviderもあるのでその同階層にQueryClientProviderを書けば良いのか親に書けば良いのかわからないが以下を参考にすると親で良さそう
https://zenn.dev/calloc134/articles/6680b272a2c2c5#:~:text=まず TanStack Query を初期化するため

公式にも同じように書かれてたのでやはり親で良さそう(なぜqueryの方が親になるのかは気になるが)
https://tanstack.com/router/v1/docs/framework/react/examples/basic-react-query

<QueryClientProvider client={queryClient}>
  <RouterProvider router={router} />
</QueryClientProvider>,
Yug (やぐ)Yug (やぐ)

useSuspenseQueryというのがあるっぽいがこれ使えばuse()使わずに普通にawait使ってfetchしてもSuspense機能しそう...!!!?めっちゃ助かるぞ
https://zenn.dev/calloc134/articles/6680b272a2c2c5

無限レンダリング回避するためuse()使うコンポーネントとfetch()使うコンポーネント分割しないといけなくていろいろ面倒だったがそれが不要になるのか

いやtanstack query導入したらその分割も不要になる気はするからそれは関係ないかも...
まぁuse()使わずにfetchだけで良くなるのはどっちみち助かる

そしてuseQueryとほぼ同じ使い方っぽい、素晴らしい
https://tanstack.com/query/latest/docs/framework/react/reference/useSuspenseQuery

注意点もあるっぽいので一応メモ
https://zenn.dev/frontendflat/articles/tanstack-suspense-request-waterfall

Yug (やぐ)Yug (やぐ)

useQuery(useSuspenseQuery)では関係ないが、useMutation内でinvalidateQueriesしたい時はqueryClient.invalidateQueriesとやる必要があるのでqueryClientというのが必要になってくる

んでそれはuseQueryClientというのを使えば取得できる

import { useQueryClient } from '@tanstack/react-query'
const queryClient = useQueryClient()

https://tanstack.com/query/latest/docs/framework/react/quick-start

Yug (やぐ)Yug (やぐ)

ディレクトリ設計どうすべきだろうか

ん-これ通りservice/というのを作るとtanstack routerで既にパスとして分離できてるディレクトリと被る気がするなぁ
https://zenn.dev/taisei_13046/books/133e9995b6aadf/viewer/ae97a1#service層の役割を解説

こっち参考にして、普通に-functions/っていうの作ってそこに全部突っ込むか
https://zenn.dev/aishift/articles/ad1744836509dd#tanstack-routerのディレクトリ設計

Yug (やぐ)Yug (やぐ)

とりあえずここ以降を参考にして同じように実装してみる
https://zenn.dev/taisei_13046/books/133e9995b6aadf/viewer/ae97a1#function.ts

できた!!use()使わずにawaitしてるだけなのにSuspense機能してる!useSuspenseQuery凄い!
エラーも消えてる


だが1つ気になる点がある

tanstack queryでisPendingの状態を取得する必要があるのかどうかわからない

tanstack routerの方でpendingComponent: で設定しているので絶対重複してるんだよな
どうすれば良いだろう、どっちを使えば良いんだろう

Yug (やぐ)Yug (やぐ)

試してみたら、isPendingの方は完全に機能していないことがわかった
tanstack routerのpendingComponentしか機能してない

pendingComponentを消してisPendingのコンポーネントだけ表示させようとしたらそもそもpending中は画面真っ白になってSuspense機能してなかった

更に....useSuspenseQueryではなくuseQueryにしてみたのだが、それでもなぜか普通にSuspense機能してる

なぜ??気になるがとりあえず飛ばす。pendingComponentを書けばokということをとりあえず暗記する。そして一応useSuspenseQueryの方を使っていくことにする

Yug (やぐ)Yug (やぐ)

違いはdataの状態だけであって、どっちもSuspenseに対応できているという点は同じなのかもしれない(?)

通常のuseQueryとuseSuspenseQueryの大きな違いはdataがundefinedな状態を考慮する必要がなくなったことです。

https://zenn.dev/aishift/articles/ad1744836509dd#tanstack-queryのsuspense

だからuseSuspenseQuery使うならisPendingもisErrorも返り値として受け取る必要は無くなってdataだけ受け取れば良いっぽい。アツい

Yug (やぐ)Yug (やぐ)

とはいえもはやReactの<Suspense></Suspense>をまったく使用していないのにSuspenseが効いてるのがなぜかわからず混乱してるのだが、そこから考えられることは...

pendingComponentが全部やってくれてる。最強。

ということ。

だがまぁそれだけという訳でもなく、useQuery/useSuspenseQueryを使っていることが前提という部分も大事か。

なぜなら普通にコンポーネント内でawaitするのは当然不可。クライアントコンポーネントはawait禁止なので

んでごり押しでuseEffect内でasync関数作ってその中でawaitしてそれを実行しようとしてもそれはSuspense機能しない。

サスペンスはエフェクトやイベントハンドラ内でデータフェッチが行われた場合にはそれを検出しません。

https://ja.react.dev/reference/react/Suspense#displaying-a-fallback-while-content-is-loading

なのでtanstack queryみたいに隠蔽してくれるやつ使わないと無理だよね、という話はある

だがそれさえ使っちゃえばもう<Suspense>使わずとも勝手にSuspense機能してpendingComponentがfallbackとして表示されるようになる

それでいいのか?<Suspense>の存在意義めっちゃ薄くない?という混乱はあるが、とりあえずめっちゃ楽に書けるという事実は変わらないので助かる

ん-、まぁReactが将来を見越しての話なのかなー?という予想

tanstackとかのライブラリを全く使わずReact単体でもuse()と<Suspense>だけ使えば非同期やらキャッシュやらを完全に対応できるようになるかもしれないという未来があり、そのReactオンリーという栄光のために残してある、ということなのかもしれない(?)

Yug (やぐ)Yug (やぐ)

さて、todos/パスとか本格的に作っていきたい

のでsupabaseでtable作ったりsupabase-jsをhonoの中で書いたりしていくか

Yug (やぐ)Yug (やぐ)

ていうかjsxをhonoから返す予定は無いのでこのrenderer.tsxっていう規定のファイル不要かも?

backend\src\renderer.tsx
import { jsxRenderer } from 'hono/jsx-renderer'

export const renderer = jsxRenderer(({ children }) => {
  return (
    <html>
      <head>
        {/* <link href="/static/style.css" rel="stylesheet" /> */}
      </head>
      <body>{children}</body>
    </html>
  )
})

index.tsxからミドルウェアとして呼び出してるっぽいんだけれども

backend\src\index.tsx
import { renderer } from './renderer'

app.use(renderer)

まぁいいや

Yug (やぐ)Yug (やぐ)

Supabase設定編

この辺り見つつ、supabaseのプロジェクト初期化、接続までやっていく
https://supabase.com/docs/reference/javascript/initializing

backend/に.env作成
https://ja.vite.dev/guide/env-and-mode#env-variables

前ハマったやつ。アクセスポリシー作らないとfetchしてもtableから空の配列しか返ってこないので注意

ここで設定できる

checkは無しで消して、usingの中をtrueにすれば良い

textかvarcharか...違いはあんま無さそうなのでとりあえずvarcharで
https://lets.postgresql.jp/documents/technical/text-processing/1#:~:text=text と varchar の比較

foreign keyも設定し、updateもdeleteもcascade(滝のように流れる=連動する=一心同体)で設定
todos.id -> users.idにした。userが消えたらtodoも全部消えるというロジックが理想なので、userが主従の主であるべき


毎回ちゃんとemail認証かけるの面倒だし1時間に3通までしか送れないのでとりあえずConfirm emailはオフにしとく

あとはsupabase.auth.signUp()すればAuthentication -> usersに勝手に登録される

Yug (やぐ)Yug (やぐ)
  • getSessionはブラウザのローカルセッションを見るだけでサーバーに確認してる訳じゃないので改ざんされてる可能性を否定できないのでgetUserの方が良いらしい

Since the unencoded session data is retrieved from the local storage medium, do not rely on it as a source of trusted data on the server. It could be tampered with by the sender. If you need verified, trustworthy user data, call getUser instead.

const { data, error } = await supabase.auth.getSession()

https://supabase.com/docs/reference/javascript/auth-getsession

まぁクライアントサイドで高速にチェックしたい場合はgetSession使えって感じらしいけど

On the client, you can instead use getSession().session.user for faster results. getSession is insecure on the server.

  • getUserはこれ。こっちはちゃんとサーバーに問い合わせてチェックする
const { data: { user } } = await supabase.auth.getUser()

https://supabase.com/docs/reference/javascript/auth-getuser

ということでgetUser使うか

Yug (やぐ)Yug (やぐ)

いやでも使い分けすべきかも?

  • getUser()でアカウント登録つまりsignupが済んでいるのか確認できる
  • getSession()でログイン中つまりsignin中が確認できる
    • ただ改ざんされる可能性はあるというのが面倒だな(今回は無視するか...)

いや違うな、どっちもセッション確認してログイン中かどうか見てるっぽいのでどっちも役割同じだ

アカウント作成済みかどうかみたいな情報に関してはそもそも不要だし不可能かもしれん

なぜなら、何も入力されてない状態でどうやってアカウント存在確認をするんだという話になるから

メアド打ってくれればそれを基にアカウント作成済みか確認できるが、セキュリティ的にそれは良くなさそうだし、何よりそんな手間をユーザーに求めるなら最初からユーザーに新規登録させてエラーで「既にアカウント登録済みです」って出すのと何ら変わらないのでそれで良くないかという感じがした

なので新規登録ボタンを出すべきかログインボタンを出すべきかというのを自動検出してどっちかだけを画面に出すみたいなことは不可能 & 不要だと判断したのでやらないことにする

Yug (やぐ)Yug (やぐ)

envが読み取れない、初見のエラーだ

Uncaught Error: supabaseUrl is required.
  • VITE_prefixはつけてる
    • import.meta.env.○○で読み取ろうとしてる
  • .envの階層をbackend/でもルートディレクトリでも試した
  • サーバー立てなおした
  • .gitignoreに指定するのも指定しないのも試した

Hono使ってるからhono側のenv設定方法使わないといけないとか?

Yug (やぐ)Yug (やぐ)

あーやっとわかった

frontend/直下に.env設置しないとだった。Viteが入ってるのはfront/のみなのでそのディレクトリ内に入れないといけないということなのだろう

ルートディレクトリでもbackend/でもダメだがfrontend/ならokというところは盲点だった

知見:Viteがインストールされているpackage.jsonの同階層に.envを置く!

Yug (やぐ)Yug (やぐ)

追記

ここの認識がまだ甘かったのでめっちゃ痛い目を見た

んでbackendも同じようにvite入ってるよな・・?とは思う

supabase clientを使いたいファイルと同ディレクトリ内(backend/かfrontend/)の直下(もしくは親ディレクトリの直下でも良い?)に.env置かないとだめだった

なので兄弟ディレクトリ(?)の直下に置いちゃうと読み取れずにinternal server errorになって詰む

Yug (やぐ)Yug (やぐ)

getUserでtrueが取れれば認証は不要にするか

getSissionの方は自動でリフレッシュトークン発行するらしいけどgetUserの方は特に無さそうだが、これはつまり一度新規登録すると一生trueが返ってくるということだろうか

If the session has an expired access token, this method will use the refresh token to get a new session.

セッションに期限切れのアクセストークンがある場合、このメソッドは新しいセッションを取得するためにリフレッシュトークンを使用します。

だとするとややセキュリティ上良くなさそうではあるが(?)、まぁいい

Yug (やぐ)Yug (やぐ)

ぐわー、またNavbarのコンポーネント内でuseEffect, useState使ってログイン状態をawaitで取得してsessionがあったらログアウトボタンを表示するという長いコードを書いてしまっている

ラグが視覚的に顕著だしコードも見づらいからできればuseEffect書きたくないんだよなぁ、わざわざ中にasync関数を作ってそれを実行するというのも面倒だし
んでuseEffect使うならuseStateもセットで使わざるを得なくなりどんどんコード量が増えていく

とにかくこれ良くない気がする

NavbarTop.tsx
import { Link, useNavigate } from "@tanstack/react-router"
import { signOut } from "../auth/-functions/signOut"
import { isLoggedIn } from '~/backend/supabase/auth/isLoggedIn'
import { useEffect, useState } from "react"

export default function NavbarTop() {
  const [session, setSession] = useState<boolean>(false)
  const navigate = useNavigate()

  useEffect(() => {
    (async() => {
      const _session = await isLoggedIn()
      if (_session) {
        setSession(_session)
      }
    })()
  }, [])

  const handleClick = async() => {
    const ok = await signOut()
    if (ok) {
      navigate({ to: '/' })
    }
  }

  return (
    <div>
      <Link to="/">ホーム</Link>
      {session && <button onClick={handleClick}>ログアウト</button>}
    </div>
  )
}

useContextとか使ってグローバルにログイン状態保持するべきなんだろうけども、
現在のコンポーネント階層の設計上できないんだよなぁ

つまりNavbarの親でログイン状態を取得しておけばuseContextのProviderから子であるNavbarやらその他の子にcontextであるログイン状態を渡せるんだけど、Navbarの親は/つまりRootであって、そこではログイン状態取得する必要無いので無駄だし遅くなるから避けたい

だがそうなるとNavbarが自分自身でawait isLoggedIn()して取得するしかない、のでさっきのようなuseEffect & useStateコードを書かねばならない・・

とはいえ/(Root)のbeforeLoadでawait isLoggedIn()してその返り値を認証必要なページ全部に渡すような設計にするのが理想っぽい気はしている

なのでそうしたい、のだが...
beforeLoad: のasync関数でreturnした値をcomponent: に渡す方法がわからない
前挑戦したがダメだった。これは気力ある時にリベンジしたい

うーーーーーむ・・・とりあえず今回はhonoの勉強なのでここは飛ばす
(まぁ機能自体は実現できてるので問題は無い)

Yug (やぐ)Yug (やぐ)

index.tsx内にコンポーネントも一緒に書いちゃうならRoute.useParams()でいけるんだけど、コンポーネントは別ファイルに分離したいので、そうなるとRouteを参照することができない

だがuseParamsの引数fromを入れれば解決するっぽい

const { userId } = useParams({ from: '/todos/$userId/' })

よし、todos/$userIdでuserIdを取得できてることを確認した

Yug (やぐ)Yug (やぐ)

ちなみに何となく"signin"ってわかり辛いよなぁという感があるのでユーザへの表示は"ログイン"にしてる

ユーザに表示されないコード部分はsigninにしてるが(supabaseの用意された関数名もsignInだし)

Yug (やぐ)Yug (やぐ)

新しい知見を得た

throw redirectする際にテンプレートリテラルは使えないっぽい

つまりこれはエラーになる

throw redirect({ to: `/todos/${userId}` })

エラーメッセージ

Type '`/todos/${any}`' is not assignable to type '"/todos/$userId" | "/" | "." | ".." | "/auth"'.ts(2322)

なのでこうしないといけない

throw redirect({
  to: "/todos/$userId",
  params: { userId }
})

こうするとエラーが消えた。へぇ~

claude 3.7

このエラーは、TypeScriptが /todos/${userId} という動的に生成されたパス文字列を、TanStack Router の redirect 関数の to プロパティに受け入れられる型と一致しないと判断しているためです。

この問題を解決するには、以下のいずれかの方法を試してみてください:

  1. redirect 関数に型アサーションを使用する:
import { redirect } from "@tanstack/react-router"
import { getUser } from "~/backend/supabase/auth/getUser"

export const checkSession = async() => {
  const user = await getUser()
  const userId = user?.id

  if (userId) {
    throw redirect({
      to: `/todos/${userId}` as const
    })
  }
}
  1. パスを作成する際にルートパラメータオブジェクトを使用する(推奨):
import { redirect } from "@tanstack/react-router"
import { getUser } from "~/backend/supabase/auth/getUser"

export const checkSession = async() => {
  const user = await getUser()
  const userId = user?.id

  if (userId) {
    throw redirect({
      to: '/todos/$userId',
      params: { userId }
    })
  }
}

TanStack Router では、パスパラメータを文字列内に直接埋め込むのではなく、params オブジェクトを通じて渡すことが推奨されています。これにより型安全性が確保され、このようなエラーを防ぐことができます。

エラーメッセージを見る限り、あなたのルート定義では /todos/$userId というパスが定義されていて、文字列テンプレートリテラルではなく、このような形式で指定する必要があります。

Yug (やぐ)Yug (やぐ)

今mockで/apiにリクエスト送ってそれを受け取るというのをtanstack query使って実装できてるのでそれを参考に書いていく

honoのディレクトリ構成もちゃんとやりたい
https://zenn.dev/yodaka/articles/ad49f29a54ceba

あとsupabaseのディレクトリは自分なりに考えて作っていくか

Yug (やぐ)Yug (やぐ)

supabase-jsやreactのfetch方法を聞いたメモ

claude 3.7

reactのフロントエンドからhonoのAPIを通じてsupabaseからユーザー固有のTODOを取得するシステムを実装しましょう。以下に各部分のコード例を示します。

Honoでのバックエンドコード

import { Hono } from 'hono';
import { createClient } from '@supabase/supabase-js';

// Supabaseクライアントの初期化
const supabaseUrl = process.env.SUPABASE_URL || '';
const supabaseKey = process.env.SUPABASE_ANON_KEY || '';
const supabase = createClient(supabaseUrl, supabaseKey);

// HonoアプリのセットアップとCORSの設定
const app = new Hono();

// TODOを取得するエンドポイント
app.post('/api/todos', async (c) => {
  try {
    // リクエストボディからuserIdを取得
    const { userId } = await c.req.json();
    
    if (!userId) {
      return c.json({ error: 'userId is required' }, 400);
    }

    // Supabaseからユーザー固有のTODOを取得
    const { data, error } = await supabase
      .from('todos')
      .select('*')
      .eq('user_id', userId);
    
    if (error) {
      console.error('Error fetching todos:', error);
      return c.json({ error: error.message }, 500);
    }
    
    return c.json({ todos: data });
  } catch (error) {
    console.error('Unexpected error:', error);
    return c.json({ error: 'Internal server error' }, 500);
  }
});

// 新しいTODOを作成するエンドポイント
app.post('/api/todos/create', async (c) => {
  try {
    const { title, userId } = await c.req.json();
    
    if (!title || !userId) {
      return c.json({ error: 'title and userId are required' }, 400);
    }
    
    const { data, error } = await supabase
      .from('todos')
      .insert([{ title, user_id: userId, completed: false }])
      .select();
    
    if (error) {
      return c.json({ error: error.message }, 500);
    }
    
    return c.json({ todo: data[0] });
  } catch (error) {
    return c.json({ error: 'Internal server error' }, 500);
  }
});

export default app;

Reactでのフロントエンドコード

import { useState, useEffect } from 'react';

interface Todo {
  id: number;
  title: string;
  completed: boolean;
  user_id: string;
}

function TodoApp() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [newTodoTitle, setNewTodoTitle] = useState('');
  const [loading, setLoading] = useState(false);
  
  // 実際のアプリケーションでは認証システムからユーザーIDを取得
  const userId = 'user123'; // 例としてハードコード
  
  // TODOを取得する関数
  const fetchTodos = async () => {
    setLoading(true);
    try {
      const response = await fetch('http://localhost:3000/api/todos', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ userId }),
      });
      
      if (!response.ok) {
        throw new Error('Failed to fetch todos');
      }
      
      const data = await response.json();
      setTodos(data.todos);
    } catch (error) {
      console.error('Error fetching todos:', error);
    } finally {
      setLoading(false);
    }
  };
  
  // 新しいTODOを作成する関数
  const createTodo = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!newTodoTitle.trim()) return;
    
    try {
      const response = await fetch('http://localhost:3000/api/todos/create', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          title: newTodoTitle,
          userId,
        }),
      });
      
      if (!response.ok) {
        throw new Error('Failed to create todo');
      }
      
      const { todo } = await response.json();
      setTodos([...todos, todo]);
      setNewTodoTitle('');
    } catch (error) {
      console.error('Error creating todo:', error);
    }
  };
  
  // コンポーネントがマウントされたらTODOを取得
  useEffect(() => {
    fetchTodos();
  }, []);
  
  return (
    <div className="todo-app">
      <h1>TODOリスト</h1>
      
      <form onSubmit={createTodo}>
        <input
          type="text"
          value={newTodoTitle}
          onChange={(e) => setNewTodoTitle(e.target.value)}
          placeholder="新しいTODOを入力"
        />
        <button type="submit">追加</button>
      </form>
      
      {loading ? (
        <p>読み込み中...</p>
      ) : (
        <ul>
          {todos.map((todo) => (
            <li key={todo.id}>
              {todo.title} - {todo.completed ? '完了' : '未完了'}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

export default TodoApp;

Supabaseのテーブル構造

Supabaseでは、以下のようなテーブル構造を想定しています:

CREATE TABLE todos (
  id SERIAL PRIMARY KEY,
  title TEXT NOT NULL,
  completed BOOLEAN DEFAULT FALSE,
  user_id TEXT NOT NULL,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- ユーザーIDにインデックスを追加してクエリを高速化
CREATE INDEX todos_user_id_idx ON todos (user_id);

実装のポイント

  1. Honoのバックエンド:

    • POSTエンドポイントを使用してuserIdを受け取ります
    • Supabase-jsを使って、そのuserIdに関連するTODOを取得します
    • エラーハンドリングと適切なHTTPステータスコードを返します
  2. Reactのフロントエンド:

    • TODOデータのfetchと状態管理
    • userIdをリクエストボディに含めてAPIにPOSTリクエスト
    • 新しいTODOの作成機能も含んでいます

実際の実装では、認証システムと連携させてユーザーIDを動的に取得する必要があります。また、環境変数の設定や適切なCORS設定も忘れないようにしてください。

何か具体的な質問や機能追加があれば、お知らせください。

Yug (やぐ)Yug (やぐ)

requestの受け取りはc.req.XXXでいろいろできそうだ
https://hono.dev/docs/api/request

今回はidをpath paramsにしているのでc.req.paramを使う感じかな

app.get('/api/todos/:id', async (c) => {
  const id = c.req.param('id')
})

どうせ中でsupabase-jsをawaitで実行することになるからasyncは必須になるだろう

Yug (やぐ)Yug (やぐ)

次にreactからのリクエスト方法を調べる

todos/$userIdのbeforeLoadでuserId取得してそれをリクエストとして送りたい

fetchのオプションでmethod, headers, bodyを設定したりするのかな
んで今回はそれをtanstack query使ってやる必要あるから難しくなりそうだ

Yug (やぐ)Yug (やぐ)

beforeLoadでの返り値(userId)をcomponentに渡すことはできるのかという問題、普通にpropsとして自動で受け取れるようになってる...!?もしそうならめっちゃ助かるぞ

claude 3.7

はい、beforeLoad の返り値を component で受け取ることができます。これはTanStack Routerの機能の一つで、ルート間でデータを共有するのに便利な方法です。

beforeLoad 関数は、コンポーネントがレンダリングされる前に実行され、その返り値はルートのコンポーネントにプロップとして自動的に渡されます。例えば:

// checkSession.ts
export async function checkSession() {
  // セッションチェックのロジック
  const userData = await fetchUserData();
  return { user: userData };
}

// Auth.tsx
function Auth(props) {
  // props.user にアクセスできる (beforeLoadからの返り値)
  const { user } = props;
  
  return (
    <div>
      <h1>ようこそ、{user.name}さん</h1>
      {/* その他のコンポーネントの内容 */}
    </div>
  );
}

あなたのコード例では、checkSession 関数の返り値は Auth コンポーネントで利用できます。また、pendingComponent として指定した AuthPending コンポーネントは beforeLoad の処理中に表示され、エラーが発生した場合は errorComponent として指定した AuthError コンポーネントが表示されます。

この仕組みを使って、認証状態やユーザーデータなどをルートロード時に取得し、それをコンポーネントに直接提供することができます。

試してみる

Yug (やぐ)Yug (やぐ)

というかtodos/$userIdのpathでbeforeLoadするとき、userId自体はpathからそのまま取得できそうだな

んでそのuserIdを基にtodosデータ取得して、それを返却してcomponentにpropsとして渡す、という流れが妥当か

Yug (やぐ)Yug (やぐ)

ん、これもしかして、TanStack Queryでfetchをキャッシュするという行為が不要になるか?

Supabaseのクエリをsupabase-jsで書くわけだがこれは別にfetchでもないしなぁ

まぁでもPromiseを返すという点では同じ。どうすべきだろうか

Yug (やぐ)Yug (やぐ)

ん?なんかいろいろミスってた

react側で全部supabase-jsのクエリ書いたりとかやろうとしてた、honoでクエリは実行せねば

流れ

react側のtanstack queryでhonoの/api/todos/:userIdをfetch

honoでsupabase-jsのクエリ実行してその結果としてのtodosを返却

それをreactが受け取り表示

Yug (やぐ)Yug (やぐ)

なぜかCORSエラーが出だした。origin許可してるのに。なぜ??
(しかもずっといけてたのに急にエラーになった)

Yug (やぐ)Yug (やぐ)

なんかfetch先のurlを相対パスにするか絶対パスにするかでエラーメッセージ変わるんだが、どっちでも良いんじゃなかったっけ?

const res = await fetch(`/api/todos/${userId}`)  // これだとJSONではなくHTMLが返ってくるエラー
const res = await fetch(`http://localhost:5001/api/todos/${userId}`)  // これだとCORSエラー

多分前者はエラー発生してサーバのデフォルトのindex.htmlのhtml返しちゃってるみたいなことだと思うので完全にfetchが機能してないような気はする

だから後者の絶対パスの方が正しくて、ただそこでCORS設定してないからエラーになっちゃってるだけ、て感じではないだろうか(?)

あーやっぱそうっぽい。初歩的なミスだが、ポートが変わるなら絶対パスにしないといけないらしい。相対パスにしないと同じポートを想定した上でリクエスト送っちゃうのか

gemini-2.0-pro-exp-02-05

fetch の引数の URL は、絶対パスでも相対パスでも問題ありません。どちらを使用するかは、状況とコードの構成によって異なります。

絶対パス:

  • 常に http://https:// から始まる完全な URL です。
  • どの場所から呼び出しても、常に同じリソースを指します。
  • 異なるドメインやポート番号のリソースにアクセスする場合に必要です。

相対パス:

  • fetch を呼び出す JavaScript ファイルの場所を基準とした URL です。
  • 同じドメイン内の異なるパスにアクセスする場合に便利です。
  • ./ (現在のディレクトリ)、../ (1つ上のディレクトリ) などを使用してパスを指定します。

http://localhost:5000/todos/$userId から http://localhost:5001/api/todos/:userIdfetch する場合:

この場合は、異なるポート番号 (5000 と 5001) にアクセスするため、絶対パスを使用する必要があります。相対パスでは、ポート番号を変更することができません。

コード例 (絶対パス):

const userId = 123; // 例
fetch(`http://localhost:5001/api/todos/${userId}`)
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error('Error:', error));

補足:

フロントエンドが http://localhost:5000 で動作している時、fetch('/api/todos/123')のようなバックエンドのapiのルートからの相対パスで指定することも可能です。この時、ブラウザは自動的にhttp://localhost:5000/api/todos/123へリクエストを送ります。
異なるポートやドメインにリクエストを送る場合は、明示的に絶対パスを指定する必要があります。
CORS(Cross-Origin Resource Sharing)に注意
異なるオリジン(プロトコル、ドメイン、ポートの組み合わせ)にリクエストを送信する場合、CORS の設定が必要になることがあります。サーバー側で適切な CORS ヘッダーを設定しないと、ブラウザがセキュリティ上の理由でリクエストをブロックすることがあります。

さて、そしたらやはりなぜCORSエラー出てるのかを特定せねば

Yug (やぐ)Yug (やぐ)

あとrendererってやつ必要か?あとで調べたい

import { renderer } from './renderer'
app.use(renderer)
Yug (やぐ)Yug (やぐ)

あー確かに、リクエストやレスポンスが実際にどんなヘッダーのせて送られてるかをchrome devtoolsのnetworkタブ(?)から見てみるか?

CORSエラーはフロントエンドで起きているように見えますが、実際にはサーバー側の設定が原因です。エラーに遭遇した場合は、サーバーのレスポンスヘッダーを確認しましょう。

https://zenn.dev/crebo_tech/articles/article-0015-20241117#よくあるcorsエラーの例

Yug (やぐ)Yug (やぐ)

めちゃくちゃ謎なのが、全オリジン・全パス許可してもだめなこと

index.tsx
app.use('*', cors({
  origin: '*'
}))

tanstack queryを使ってた頃はできてたんだよな

今のやつはまだtanstack query使ってないただの直接fetchなのでそれが原因か?
最初tanstack query使って試した時は普通にできてたしなぁ

ということでさっきと同じようにtanstack queryを使ってfetchしてみる

Yug (やぐ)Yug (やぐ)

ただそうなるとなぜ普通にfetchするのだとorigin許可してもエラーになるのかは気になるが・・

Yug (やぐ)Yug (やぐ)

honoエンドポイントへのfetchと、
honoエンドポイントからsupabaseへのfetch

の2つともtanstack queryでキャッシングしないとだな

Yug (やぐ)Yug (やぐ)

コンポーネントかカスタムフック内でしかuseParams使えないっぽいのでカスタムフック内でuseParams使ってuserId取得してそれをgetTodosの引数に渡してあげることにするか

なのでgetTodosは直接tanstack queryのqueryFnに渡すのではなく高階関数の返り値として使う

useGetTodos.ts
import { useSuspenseQuery } from "@tanstack/react-query"
import { todoKeys } from "../-key/key"
import { getTodos } from "./getTodos"
import { useParams } from "@tanstack/react-router"

export const useGetTodos = () => {
  const { userId } = useParams({ from: '/todos/$userId/' })
  
  const { data } = useSuspenseQuery({
    queryKey: todoKeys.all,
    queryFn: () => getTodos(userId),
  })

  return data
}
Yug (やぐ)Yug (やぐ)

おそらくフロントは完成した(まだCORSエラーは出ている)

次はhono(バックエンド)内のsupabase-jsのfetchでもtanstack query使う

Yug (やぐ)Yug (やぐ)

hono(バックエンド)でのfetchは、コンポーネントとか無いからサスペンスで囲むとかpendingComponentに設定するとかができないの大丈夫なんかな

クライアント側のSuspenseがそれ検知して勝手にサスペンドしてくれるのか?わからん

んでuseSuspenseQueryにすべきか?useQueryで良いか?
まぁ一応useSuspenseQueryにしとくか

Yug (やぐ)Yug (やぐ)

ん、バックエンド側でもtanstack queryインストールしないと使えないな

ていうかコンポーネントでqueryProviderみたいなやつ初期設定しないといけなかったなぁ

function App() {
  return (
    // Provide the client to your App
    <QueryClientProvider client={queryClient}>
      <Todos />
    </QueryClientProvider>
  )
}

https://tanstack.com/query/latest/docs/framework/react/quick-start

うーん、honoでコンポーネントとか使えるのか・・?めんどくさそうだなぁどうしよう

普通にクライアント側のフォルダ内にuseQueryTodosみたいなファイル作っちゃってそれをバックエンドからインポートするだけっていうのはどうだろう、可能だろうか

Yug (やぐ)Yug (やぐ)

できたけどエラーは変わらず。

キャッシュを見たところhonoからのqueryは実行すらされていない?でクライアントからのgetTodosしか走っておらずその途中でエラーが出たのか?

fetchingに3秒くらいなってずっとローディング中で、その後エラーになると同時にinactiveになってた

わからないのでとりあえずqueryなしの単純apiリクエスト/レスポンスを試してみる
(ユーザーテーブルに存在しないユーザーIDを指定しているのも良くないか?)

Yug (やぐ)Yug (やぐ)

だめだ、/testというパスを作って普通にリクエスト送ってもCORSエラーだ

なぜ???

Yug (やぐ)Yug (やぐ)

ん、honoの開発サーバ見てみたら重要そうなエラー出てるな、500エラー

あー、最初と違ってsupabase clientのファイルの場所をクライアント側ではなくバックエンド側に移してしまったかも?それによって読み取れなくなってエラー出てる?


いやでもフロント側で書こうとしたらsupabase-jsインストールしてないからエラーになる
じゃあ別にここはバックエンド側に最初からあったっぽいな

関係なさそう

デプロイしちゃえばVercelに環境変数設定できるので、これは最悪無視でも良さそうな気はする

Yug (やぐ)Yug (やぐ)

そういえばfetchする関数内でtry catchでエラーハンドリングしてなかったからそこのエラーメッセージも見てみるか

うーんまぁ普通にFailed to fetchだなぁ

エラーが出てもめげずに4回fetchしてるっぽく、4回目が失敗したらcatch内に書いといたthrow new Errorがやっと出るようになってる

普通に直接fetchしてみてもやっぱりだめだ、CORSエラー

Yug (やぐ)Yug (やぐ)

なんで最初はできてたんだっけ?何が違う?

こんな単純なものすら受け取れなくなってしまった

index.tsx
app.get('/api/test', (c) => {
  return c.text('テストだよー')
})

CORSでorigin許可するだけではだめで他に直接ヘッダー設定したりとか、リクエスト側でもfetchの際にオプション付けてヘッダー設定したりしないとダメとか?

Yug (やぐ)Yug (やぐ)

geminiに聞く

gemini-2.5-pro-exp-03-25

CORSエラーが発生しているとのことですが、提示されたコードとエラーメッセージを見る限り、いくつかの原因が考えられます。

考えられる原因と対処法

  1. ミドルウェアの適用順序:
    Honoではミドルウェアは記述された順に適用されます。現在のコードでは、renderer ミドルウェアが cors ミドルウェアよりも先にグローバルに適用されています。

    // honoのindex.tsx
    // ...
    app.use(renderer) // ← これが先に適用される
    
    app.use('/api/*', cors({ // ← APIルートに対して後から適用される
        origin: 'http://localhost:5000',
      })
    )
    // ...
    

    もし renderer ミドルウェアがリクエストを処理してレスポンスを返してしまったり、あるいは何らかの理由で処理を中断させたりすると、後続の cors ミドルウェアが実行されず、CORSヘッダーが付与されない可能性があります。
    特に、エラーメッセージに 500 (Internal Server Error) が含まれていることから、renderer ミドルウェアか、あるいはルートハンドラ自体でエラーが発生し、CORSヘッダーが付与される前の段階で処理が失敗している可能性が高いです。

    対処法:

    • CORSミドルウェアを先に適用する: API関連のルートに対するCORS設定は、他のミドルウェア(特にレスポンスを返す可能性のある renderer など)より先に適用するのが一般的です。
    • rendererミドルウェアの適用範囲を見直す: renderer がSSRやHTMLレンダリングのためのミドルウェアであれば、APIエンドポイント (/api/*) には適用せず、HTMLを返す必要のあるルート(例: '*''/' など)にのみ適用するように変更します。

    修正例:

    // honoのindex.tsx (修正案)
    import { Hono } from 'hono'
    import { renderer } from './renderer' // renderer の内容を確認してください
    import { cors } from 'hono/cors'
    
    const app = new Hono()
    
    // 1. CORSミドルウェアをAPIルートに適用
    //    可能な限り早い段階で、しかし対象パスを絞って適用するのが良いでしょう。
    app.use('/api/*', async (c, next) => {
      console.log(`[${new Date().toISOString()}] Applying CORS for: ${c.req.path}`); // ログ追加
      const handler = cors({
        origin: 'http://localhost:5000',
        // 必要に応じて他のオプションも検討
        // allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
        // allowHeaders: ['Content-Type', 'Authorization'],
        // credentials: true, // Cookie認証などを使う場合
      });
      await handler(c, next); // CORSミドルウェアを実行
      // CORSミドルウェアがレスポンスヘッダーを追加したか確認(デバッグ用)
      // console.log('Response headers after CORS:', c.res.headers);
    });
    
    // 2. APIルートの定義
    app.get('/api/test', (c) => {
      console.log(`[${new Date().toISOString()}] Handling /api/test`); // ログ追加
      try {
        // ここでエラーが発生していないか確認
        return c.text('テストだよー from Hono API');
      } catch (error) {
        console.error(`[${new Date().toISOString()}] Error in /api/test handler:`, error);
        // エラー時もレスポンスを返す
        return c.json({ message: 'Internal Server Error' }, 500);
      }
    })
    
    // 他のAPIルートがあればここに追加
    
    // 3. rendererミドルウェアを適用
    //    もしrendererがHTMLレンダリング用なら、APIルート(*)より後に、
    //    具体的なパスや '*' で適用します。
    //    APIルートには不要なはずです。
    // app.use(renderer) // ← グローバル適用をやめる
    app.get('*', renderer) // 例: API以外のすべてのGETリクエストに適用
    
    export default app
    
  2. サーバーサイドでのエラー (500 Internal Server Error):
    コンソールに GET http://localhost:5001/api/test net::ERR_FAILED 500 (Internal Server Error) と表示されています。これは、CORSポリシーチェック以前に、サーバー側でリクエストの処理中にエラーが発生し、ステータスコード500でレスポンスが返された(あるいはレスポンス自体が壊れた)ことを示しています。
    サーバーがエラーレスポンスを返す際、正常なレスポンスに必要なCORSヘッダー(Access-Control-Allow-Origin など)が付与されないため、ブラウザはCORSエラーとして報告します。

    対処法:

    • Honoサーバーを起動しているターミナル(コンソール)のログを確認し、500 Internal Server Error の具体的な原因を特定してください。スタックトレースなどが出力されているはずです。
    • 上記の修正例のように、APIルートハンドラ内に try...catch を追加し、エラーハンドリングを強化します。
    • renderer ミドルウェアの実装を確認し、意図せずAPIルートでエラーを引き起こしていないか確認してください。
  3. CORS設定の詳細:
    単純なGETリクエストであれば origin の指定だけで十分なことが多いですが、念のため他のオプションも確認します。

    • メソッド: GET 以外のメソッド(POST, PUT, DELETE など)や、Content-Type 以外のヘッダー(Authorization など)をリクエストで送信する場合、allowMethodsallowHeaders の設定が必要になることがあります。
    • Credentials: CookieやAuthorizationヘッダーを伴うリクエストの場合、サーバー側で credentials: true を設定し、クライアント側 (fetch) でも credentials: 'include' を指定する必要があります。

    対処法:

    • 必要に応じて cors() のオプションを追加します。(上記の修正例を参照)

デバッグ手順の推奨:

  1. サーバー側のログを確認: Honoサーバーのコンソール出力を最優先で確認し、500エラーの原因を特定・修正します。
  2. ミドルウェアの順序変更: 上記の修正例のように、CORSミドルウェアを先に適用し、renderer の適用範囲を見直します。
  3. シンプルなAPIエンドポイントでテスト: 問題を切り分けるために、他のミドルウェアの影響を受けない非常にシンプルなAPIエンドポイント(例: app.get('/ping', (c) => c.text('pong')))を作成し、それに cors ミドルウェアだけを適用して、クライアントからリクエストを送信してみます。これでCORSが通るか確認します。
  4. curlコマンドで確認: ターミナルから以下のコマンドを実行し、サーバーからの直接のレスポンスヘッダーを確認します。
    curl -v -H "Origin: http://localhost:5000" http://localhost:5001/api/test
    
    レスポンスヘッダーに Access-Control-Allow-Origin: http://localhost:5000 が含まれているか、またステータスコードが500になっていないか確認します。

まずはサーバーサイドの500エラーを解決することが、CORSエラー解消への近道だと思われます。Honoサーバーのログを確認してみてください。

なるほどなー

コンソールに GET http://localhost:5001/api/test net::ERR_FAILED 500 (Internal Server Error) と表示されています。これは、CORSポリシーチェック以前に、サーバー側でリクエストの処理中にエラーが発生し、ステータスコード500でレスポンスが返された(あるいはレスポンス自体が壊れた)ことを示しています。
サーバーがエラーレスポンスを返す際、正常なレスポンスに必要なCORSヘッダー(Access-Control-Allow-Origin など)が付与されないため、ブラウザはCORSエラーとして報告します。

リクエスト中にエラー起きて正常なレスポンス送れなくなったらCORSヘッダーを返せなくなるので、厳密にはCORSエラーではないのにCORSエラーになっちゃうってことか!

てことはさっきのinternal server errorは直さないといけないやつだったかも

Yug (やぐ)Yug (やぐ)

あとこれは初耳だ、勉強になる

CookieやAuthorizationヘッダーを伴うリクエストの場合、サーバー側で credentials: true を設定し、クライアント側 (fetch) でも credentials: 'include' を指定する必要があります。

Yug (やぐ)Yug (やぐ)

サーバーログのエラーのことを伝えたgeminiの返答

gemini-2.5-pro-exp-03-25

サーバーログありがとうございます!エラーの原因が明確になりましたね。

Internal server error: supabaseUrl is required.
      at new SupabaseClient (...)
      at createClient (...)
      at C:\Users\rutob\Downloads\hono-todoapp\backend\supabase\client.ts:6:25

このログは、backend/supabase/client.ts の中で Supabase クライアントを初期化しようとした際に、supabaseUrl が渡されなかった(undefined または空だった)ためにエラーが発生したことを示しています。これが原因でサーバーが 500 エラーを返し、結果としてブラウザ側では CORS ヘッダーがないというエラーに見えていたわけです。

根本的な原因:

Hono サーバー (バックエンド) のコード (backend/supabase/client.ts) が、フロントエンド用のディレクトリ (frontend/.env.local) に置かれた環境変数を読み込めていないためです。

  • import.meta.env: これは Vite が提供する機能で、Vite の開発サーバーやビルドプロセスが .env ファイルなどを読み込み、コード中の import.meta.env.VITE_... を実際の値に置き換えます。
  • Vite の環境変数のスコープ: Vite 開発サーバー (vite コマンド) は、デフォルトでは実行されたディレクトリ (今回の場合は backend ディレクトリ) を基準に .env ファイルを探します。
  • 今回の状況:
    1. Hono サーバーは backend ディレクトリで (おそらく vite コマンドによって) 実行されている。
    2. backend/supabase/client.tsimport.meta.env.VITE_SUPABASE_URL を参照している。
    3. Vite サーバーは backend ディレクトリとその上位ディレクトリで .env ファイルを探すが、frontend/.env.local はその対象外。
    4. 結果として import.meta.env.VITE_SUPABASE_URLundefined になり、Supabase クライアントの初期化でエラーが発生。

解決策:

以下のいずれかの方法で、バックエンドの Hono サーバーが Supabase の環境変数を読み込めるように設定してください。

方法1: バックエンドディレクトリに .env ファイルを置く (推奨度: 高)

  1. backend ディレクトリの直下に .env という名前のファイルを作成します。(例: backend/.env)

  2. そのファイルに、frontend/.env.local と同じ内容をコピーします。VITE_ プレフィックスはそのままにしておいてください (import.meta.env を使っているため)。

    # backend/.env
    VITE_SUPABASE_URL=XXX
    VITE_SUPABASE_API_KEY=XXX
    
  3. Hono/Vite サーバーを再起動します。

これで backend で実行されている Vite サーバーが backend/.env を読み込み、import.meta.env に値が注入されるはずです。

方法2: Vite の設定で環境変数ファイルの場所を指定する

もし環境変数を frontend ディレクトリで一元管理したい場合は、backend の Vite 設定ファイル (backend/vite.config.ts または backend/vite.config.js) で envDir オプションを指定します。

  1. backend/vite.config.ts (なければ作成) を編集します。

  2. defineConfig の中で envDir を設定します。

    // backend/vite.config.ts
    import { defineConfig } from 'vite'
    import honoPlugin from '@hono/vite-dev-server' // Hono用のプラグインなどがあればそのまま
    import path from 'path'
    
    export default defineConfig({
      // Hono/Vite用のプラグイン設定などがあればここに
      plugins: [
        honoPlugin({
          entry: './src/index.tsx' // Honoのエントリーポイントに合わせてください
        })
        // 他のプラグイン...
      ],
      // 環境変数を読み込むディレクトリを frontend ディレクトリに指定
      envDir: path.resolve(__dirname, '../frontend'),
      // .envファイルで VITE_ 以外のプレフィックスも読み込みたい場合 (通常は不要)
      // envPrefix: 'VITE_', // デフォルトは 'VITE_'
    })
    
    • path.resolve(__dirname, '../frontend') は、vite.config.ts があるディレクトリ (backend) から見て一つ上の階層の frontend ディレクトリを指します。
    • @hono/vite-dev-server などのプラグインを使用している場合は、その設定も確認・記述してください。
  3. Hono/Vite サーバーを再起動します。

注意点:

  • サーバーの再起動: .env ファイルの変更や vite.config.ts の変更後は、必ず Hono/Vite サーバーを再起動してください。

  • .env ファイルのコミット: Supabase の API キーなどの機密情報は Git リポジトリにコミットしないように、.gitignore.env.env.* を追加しておきましょう。

  • as string のリスク: import.meta.env.VITE_... as string は、環境変数が未定義の場合にランタイムエラーを引き起こします(今回のように)。より安全にするなら、以下のように存在チェックを行うのがおすすめです。

    // backend/supabase/client.ts (改善案)
    import { createClient } from "@supabase/supabase-js";
    
    const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
    const supabaseApiKey = import.meta.env.VITE_SUPABASE_API_KEY;
    
    if (!supabaseUrl || !supabaseApiKey) {
      // サーバー起動時になるべく早くエラーを検知できるようにする
      throw new Error("Supabase URL and API Key must be defined in environment variables (VITE_SUPABASE_URL, VITE_SUPABASE_API_KEY)");
    }
    
    // ここに来る時点で string であることが保証される (Viteが注入するため)
    export const supabase = createClient(supabaseUrl, supabaseApiKey);
    

まずは方法1を試して、Honoサーバーが正常に起動し、/api/test エンドポイントが 500 エラーではなく正常なレスポンス(テストだよー)を返すようになるか確認してみてください。それができれば、CORS の設定も正しく機能するはずです。

なるほどなぁ~~~

このログは、backend/supabase/client.ts の中で Supabase クライアントを初期化しようとした際に、supabaseUrl が渡されなかった(undefined または空だった)ためにエラーが発生したことを示しています。これが原因でサーバーが 500 エラーを返し、結果としてブラウザ側では CORS ヘッダーがないというエラーに見えていたわけです。


ということでbackend/ 直下にも.env.local作ったら無事直ったーーー!!!

Yug (やぐ)Yug (やぐ)

もう不要だろと思ってフロントエンドの.env.local消したら画面が真っ白になった

フロントとバックの開発サーバのログには何もエラー出ないが、コンソールにはclientのsupabaseのエラーが出力されてる。これ↓

if (!supabaseUrl || !supabaseApiKey) {
  // サーバー起動時になるべく早くエラーを検知できるようにする
  throw new Error("VITE_SUPABASE_URLとVITE_SUPABASE_API_KEYが環境変数内に定義されてないとダメ")
}

んでどうやらフロントからバックのsupabase clientをインポートしてるのがまずいらしい

確かにbackend内には環境変数があるとは言え、インポート元であるfrontend側でそのsupabase clientを解決しようとするのかな、多分
そんで環境変数がfront内に見つからなくてエラー、みたいな流れだと思われる

まぁ確かにフロントで使いたいコードがバックに用意されてるのはディレクトリ設計おかしいだろというツッコミはありそうだ
確かに違和感もあるし

Yug (やぐ)Yug (やぐ)

なのでそのbackendをimportしようとしてるfrontendのコードをbackendに移動しようとしたのだが、tanstack query使ってるからbackendでそれは使えないなぁ、、となった

backend側ではtsqueryをインストールしてないからなのもそうだが、前述の通りtanstack queryはインストールするだけでなくmain.tsx内で(コンポーネントで)セッティングする必要もあるので、それをhono側でやれるんか?みたいな話になる

できるのかもしれないけど、とりあえずバックエンドではtanstack query使わないで普通にfetchしちゃって良いとgeminiさんが言ってたのでとりあえず今回はそれでいく

gemini

おっしゃる通り、useQueryTodos は React のフック (useSuspenseQuery) を使っているので、そのまま Hono のバックエンド (backend/) に移動することはできません。また、QueryClientProvider は React アプリケーションに QueryClient のインスタンスを提供するためのコンポーネントなので、Hono サーバーサイドで直接使うものではありません。

ここで重要なのは、フロントエンドとバックエンドの役割分担を明確にすることです。

  • バックエンド (Hono):
    • データベース (Supabase) と直接やり取りする。
    • ビジネスロジックを実行する。
    • フロントエンドからのリクエストに応じて、データを JSON などの形式で返す API エンドポイントを提供する。
  • フロントエンド (React + TanStack Query):
    • UI を表示し、ユーザー操作を受け付ける。
    • バックエンドが提供する API エンドポイントにリクエストを送信してデータを取得する。
    • 取得したデータを TanStack Query を使ってキャッシュし、状態管理を行う (useQuery, useMutation などを使用)。

現在の問題点:

frontenduseQueryTodos が、バックエンドのデータ取得関数 (backend/supabase/queryTodos) を直接インポートして呼び出してしまっている点です。

正しいアプローチ (より良い方法):

  1. バックエンド (Hono) に API エンドポイントを作成する:

    • backend/supabase/queryTodos.ts は、Supabase からデータを取得するロジックだけを担当します (これは React や TanStack Query に依存しません)。
    • Hono のルーター (backend/src/index.tsx) で、この queryTodos 関数を使って特定のユーザーの Todo を返す API ルート (例: /api/todos/:userId) を作成します。
  2. フロントエンド (React) はその API エンドポイントを叩く:

    • frontend/src/.../useQueryTodos.ts はフロントエンドに残します。
    • queryFn の中で、backend/supabase/queryTodos を直接呼び出す代わりに、先ほど Hono で作成した /api/todos/:userId エンドポイントに対して fetch リクエストを送信するように変更します。

具体的なコード修正例:

1. バックエンド (backend/src/index.tsx) に API エンドポイントを追加:

// backend/src/index.tsx
import { Hono } from 'hono'
import { renderer } from './renderer' // 必要に応じて
import { cors } from 'hono/cors'
import { queryTodos } from '../supabase/queryTodos' // データ取得ロジックをインポート

const app = new Hono()

// CORS設定 (APIルート用) - これは以前の修正通り
app.use('/api/*', cors({
    origin: 'http://localhost:5000', // フロントエンドのオリジン
    // 必要なら他のオプションも
}));

// ★★★ 新しいAPIエンドポイントを追加 ★★★
app.get('/api/todos/:userId', async (c) => {
  const userId = c.req.param('userId');
  console.log(`[API] Fetching todos for userId: ${userId}`);
  try {
    // バックエンドのデータ取得関数を呼び出す
    const todos = await queryTodos(userId);
    // 取得したデータをJSONで返す
    return c.json(todos);
  } catch (error) {
    console.error(`[API Error] Failed to fetch todos for ${userId}:`, error);
    return c.json({ message: 'Failed to fetch todos', error: error.message }, 500);
  }
});

app.get('/api/test', (c) => {
  return c.text('テストだよー from Hono API');
});

// renderer はAPI以外のルートに適用 (必要に応じて)
app.get('*', renderer);

export default app

2. バックエンド (backend/supabase/queryTodos.ts) の確認 (例):
(これは React や TanStack Query に依存しない形になっているはずです)

// backend/supabase/queryTodos.ts
import { supabase } from './client'; // Supabaseクライアント

// Todoの型定義 (例)
export interface Todo {
  id: number;
  user_id: string;
  task: string;
  is_completed: boolean;
  created_at: string;
}

export const queryTodos = async (userId: string): Promise<Todo[]> => {
  console.log(`[DB] Querying todos for userId: ${userId}`);
  if (!userId) {
    throw new Error('User ID is required');
  }
  // Supabaseからデータを取得するロジック
  const { data, error } = await supabase
    .from('todos') // 'todos' は実際のテーブル名に合わせてください
    .select('*')
    .eq('user_id', userId) // user_id カラムで絞り込み
    .order('created_at', { ascending: true });

  if (error) {
    console.error('[DB Error] Supabase query failed:', error);
    throw new Error(`Supabase query failed: ${error.message}`);
  }

  console.log(`[DB] Found ${data?.length ?? 0} todos for userId: ${userId}`);
  return data || []; // データがない場合は空配列を返す
};

3. フロントエンド (frontend/src/routes/todos/$userId/-functions/useQueryTodos.ts) の修正:

// frontend/src/routes/todos/$userId/-functions/useQueryTodos.ts
import { useSuspenseQuery } from "@tanstack/react-query";
import { todoKeys } from "../-key/key"; // Query Key
// import { queryTodos } from "~/backend/supabase/queryTodos"; // ← これは削除!

// ★★★ APIを叩くための新しい関数を作成 (別ファイルでもOK) ★★★
const fetchTodos = async (userId: string): Promise<Todo[]> => { // Todo型は適切に定義・インポート
  console.log(`[Fetch] Fetching todos from API for userId: ${userId}`);
  // Hono の API エンドポイントを叩く
  const res = await fetch(`http://localhost:5001/api/todos/${userId}`); // ポート番号確認

  if (!res.ok) {
    const errorData = await res.json().catch(() => ({ message: 'Failed to parse error response' }));
    console.error(`[Fetch Error] API request failed: ${res.status}`, errorData);
    throw new Error(`API request failed: ${errorData.message || res.statusText}`);
  }
  // JSONレスポンスをパースして返す
  const data: Todo[] = await res.json();
  console.log(`[Fetch] Received ${data.length} todos from API`);
  return data;
};


// ★★★ useQueryTodos フック ★★★
export const useQueryTodos = (userId: string) => {
  const { data } = useSuspenseQuery({
    queryKey: todoKeys.queryTodos(userId), // userId もキーに含めるのが一般的
    // queryFn は API を fetch する関数に変更
    queryFn: () => fetchTodos(userId)
  });

  // data の型が Todo[] になる (fetchTodos の返り値の型)
  return data;
}

// Todo 型の定義 (例 - backend と共有するか、フロントで再定義)
interface Todo {
  id: number;
  user_id: string;
  task: string;
  is_completed: boolean;
  created_at: string;
}

// Query Key の定義 (例 - key.ts ファイル)
// export const todoKeys = {
//   all: ['todos'] as const,
//   queryTodos: (userId: string) => [...todoKeys.all, userId] as const,
// }

回答まとめ:

  • Hono で QueryClientProvider は不要。 これは React フロントエンドのものです。
  • Hono で TanStack Query の React フック (use...) は使えない。 バックエンドは API を提供する役割。
  • より良い方法: バックエンド (Hono) は DB アクセスロジックとそれを公開する API エンドポイントを作成。フロントエンド (React + TanStack Query) はその API を fetch し、useQuery などで状態管理・キャッシュを行う。
  • クライアントとバックエンド両方で使うべきか?:
    • クライアント (React): API フェッチとキャッシュのために @tanstack/react-query使うべき です。
    • バックエンド (Hono): 通常、TanStack Query は不要です。単純な async/await や Supabase クライアントで十分です。

この構成にすることで、フロントエンドとバックエンドの関心事がきれいに分離され、それぞれの役割に集中できます。

これでサスペンスが効かないからuseSuspenseQuery使いたいやらキャッシュしたいやらというニーズが出てきたらまたその時方法を調べるとする

Yug (やぐ)Yug (やぐ)

ということでディレクトリ整理しつつ書いていくか
backend側のtanstack query不要なので全部消しちゃって良かったりとかその辺り

Yug (やぐ)Yug (やぐ)

なんかimportを補完からやるとこうなるけど、これだとエラーになる

import { queryTodos } from 'supabase/CRUD/queryTodos'

普通に相対パスでちゃんと指定してあげるとエラー解消できた

import { queryTodos } from '../supabase/CRUD/queryTodos'
Yug (やぐ)Yug (やぐ)

よし、渡されたuserIdに一致するtodo一覧を取得する処理はできた

んで恐らく必要だと思われるのでfrontend/にもbackend/にも同じ.env.loval配置した

もう後は問題無くどんどん書いていけそうだ、ゴールが見えてきた

Yug (やぐ)Yug (やぐ)

さて、とりあえず土台を書いた。こんな感じの構造にしたい

TodoContainer.tsx
import { useParams } from "@tanstack/react-router"
import { useGetTodos } from "../-functions/useGetTodos"
import { TodoResponse } from "../-types"
import TodoList from "./TodoList"
import TodoForm from "./TodoForm"

export default function Todos() {
  const { userId } = useParams({ from: '/todos/$userId/' })
  const result: TodoResponse = useGetTodos(userId)

  return (
    <div>
      <TodoList todos={result} />
      <TodoForm />
    </div>
  )
}
TodoList.tsx
import { ListProps } from "../-types";

export default function TodoList({ todos }: ListProps) {
  if (typeof todos === 'string') {
    return (
      <h2>まだTodoはありません</h2>
    )
  }

  return (
    <ul>
      {todos.map(todo => (
        <li>{todo.id}: {todo.content}</li>
      ))}
    </ul>
  )
}
TodoForm.tsx
export default function TodoForm() {
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    // setStateをpropsとして受け取ってそれを使用して更新すべき?
    // それともただ更新リクエスト送るだけでtanstack queryの特徴で勝手にTodoListも更新されるみたいなことある?
  }

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    alert('投稿のリクエスト送るよ!')
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" placeholder="新しいTodo" />
      <button type="submit">登録</button>
    </form>
  )
}

ただ、TodoForm内で更新かけた時にその更新をTodoListが検知できないという問題

今まではfetch結果(response)をuseStateのstateとして保持してそれに対応するsetter関数を各コンポーネントにpropsとして渡すという方式で良かったのだが、今回はfetch結果としてのレスポンスをuseStateのstateとして保持できない。なぜならtanstack queryを今回使っており、その実行結果を普通の変数としてしか受け取れないから。

TodoContainer.tsx
// こうしないといけない
const result: TodoResponse = useGetTodos(userId)

// こういう感じにしたいけど当然それはできない。つまりuseStateは使えない
const [result, setResult] = useState<TodoResponse>('')
Yug (やぐ)Yug (やぐ)

あ、useMutation忘れてた。これ使いつつ、中でクエリ(キャッシュ?)を無効化すれば勝手に更新されるらしい

claude 3.7 sonnet

これはTanStack Queryを使ったReactアプリケーションにおけるよくある問題です。TodoFormでtodoを更新しても、TodoListが再レンダリングされない原因は、TodoFormとTodoListの間でQueryClientのキャッシュを共有できていないことにあります。

問題を解決するために、以下のアプローチが有効です:

  1. TodoFormでの更新後にクエリを無効化(invalidate)する
  2. QueryClientの設定でstaleTimeを調整する
  3. TodoFormコンポーネントにuserId情報を渡す

最も一般的な解決策は、TodoFormでのtodo操作後にクエリを無効化することです。以下のように実装を修正しましょう:

// TodoContainer.tsx
import { useParams } from "@tanstack/react-router"
import { useGetTodos } from "../-functions/useGetTodos"
import { TodoResponse } from "../-types"
import TodoList from "./TodoList"
import TodoForm from "./TodoForm"

export default function Todos() {
  const { userId } = useParams({ from: '/todos/$userId/' })
  const result: TodoResponse = useGetTodos(userId)

  return (
    <div>
      <TodoList todos={result} />
      <TodoForm userId={userId} />
    </div>
  )
}
// TodoForm.tsx
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { createTodo /* or updateTodo */ } from '../-functions/api'

export default function TodoForm({ userId }) {
  const queryClient = useQueryClient()
  
  const mutation = useMutation({
    mutationFn: (newTodo) => createTodo(newTodo),
    onSuccess: () => {
      // 重要: キャッシュを無効化して再取得をトリガー
      queryClient.invalidateQueries({ queryKey: ['todos', userId] })
      // フォームのリセットなど他の処理
    },
  })
  
  // フォーム処理と送信ハンドラ
  // ...

  return (
    // フォーム要素
  )
}

また、useGetTodos関数が以下のようにクエリキーを設定していることを確認してください:

// useGetTodos.ts
import { useQuery } from '@tanstack/react-query'
import { fetchTodos } from './api'

export function useGetTodos(userId: string) {
  return useQuery({
    queryKey: ['todos', userId],
    queryFn: () => fetchTodos(userId),
  }).data
}

このように実装することで、TodoFormでtodoを作成または更新した際に、同じクエリキー(['todos', userId])を持つクエリが無効化され、TodoListが自動的に最新データで再レンダリングされます。

最高だ・・やってみよう

こちらも参考にしつつ
https://zenn.dev/taisei_13046/books/133e9995b6aadf/viewer/257b1a
https://zenn.dev/taisei_13046/books/133e9995b6aadf/viewer/d0d84c

まさにこれ

TODOアプリケーションでは基本的に入力フォームとTODO一覧が同一の画面にあります。その場合、新規TODOを追加して直ぐにTODO一覧の表示を更新したいです。しかし、デフォルトの挙動ではTODO追加後にデータを更新してくれません。上の検証環境ではデータ追加後、リロードをすると正しくデータが更新される挙動が確認できます。

そこで特定のクエリを指定して再取得しましょう。TanStack Queryにおけるキャッシュのinvalidate処理とはデータが古くなったことが明らかな場合、直接queryKeyを指定してデータを更新することを意味します。

Yug (やぐ)Yug (やぐ)

ていうかkeyを["todos"]にするんじゃなくて["todos", userId]みたいにしないとか?

いやでもtodosのままで統一して良いかも
そのList全体を毎回更新するという形で良いかと思われるので

Yug (やぐ)Yug (やぐ)

propsでuseStateのsetter関数を渡すなりしなくて良くなるの最高だな

ただinputのe.target.valueは捕捉し続けないといけないのでTodoFormコンポーネント内ではuseState使うのは依然必須か

Yug (やぐ)Yug (やぐ)

fetchのoptionはこの通りにやってみよう
https://zenn.dev/link/comments/01df50c1761560

こんな感じ

updateTodo.ts
import { UpdateTodoProps } from "../-types";

export const updateTodo = async({ user_id, content }: UpdateTodoProps): Promise<void> => {
  const res = await fetch(`http://localhost:5001/api/todos/${user_id}`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      user_id,
      content
    })
  })

  if (!res.ok) {
    throw new Error('TODO新規投稿に失敗しました')
  }
}

他の記事も見てみたけどoptionはこれで良さそう、とりあえず

Yug (やぐ)Yug (やぐ)

ダメだ、画面に何も起こらない

honoの開発サーバのログを見てみると、dbへのクエリでエラーが出てる

Error: DBへのクエリでエラーが発生しました: 
    at queryUpdateTodo (C:/Users/XXX/Downloads/hono-todoapp/backend/supabase/CRUD/queryUpdateTodo.ts:7:11)
    at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
    at async eval (C:/Users/XXX/Downloads/hono-todoapp/backend/src/index.tsx:36:19)
    at async dispatch (file:///C:/Users/XXX/Downloads/hono-todoapp/backend/node_modules/hono/dist/compose.js:22:17)      
    at async cors2 (file:///C:/Users/XXX/Downloads/hono-todoapp/backend/node_modules/hono/dist/middleware/cors/index.js:74:5)
    at async dispatch (file:///C:/Users/XXX/Downloads/hono-todoapp/backend/node_modules/hono/dist/compose.js:22:17)      
    at async file:///C:/Users/XXX/Downloads/hono-todoapp/backend/node_modules/hono/dist/hono-base.js:195:25
    at async getRequestListener.overrideGlobalObjects (file:///C:/Users/XXX/Downloads/hono-todoapp/backend/node_modules/@hono/vite-dev-server/dist/dev-server.js:101:32)
    at async responseViaResponseObject (file:///C:/Users/XXX/Downloads/hono-todoapp/backend/node_modules/@hono/node-server/dist/index.mjs:342:15)

うーん、ログの内容が薄すぎる、というかスタックトレースしか無いやん
わからん

Yug (やぐ)Yug (やぐ)

仮説1

users.idとtodos.user_idはcacadeで紐づけているので、usersテーブルにまだ何も登録してないのにtodosテーブルに登録しようとしたことで不整合を検知しsupabaseが内部でエラーを投げたとか?

あまりにログの内容が薄すぎる(DBへのクエリで500エラーが起きたことしかわからない)からsupabaseの内部エラーとかその辺りの話なんじゃないかなぁという勝手な予想がある

なのでusersテーブルへの登録処理も書いた上で再度試してみるか

Yug (やぐ)Yug (やぐ)

メモ

devtoolsのconsoleに出てたFailed to load resourceっていうエラーメッセージの意味とかも調べる必要はありそう?

Yug (やぐ)Yug (やぐ)

signupとか全部フロント側でsupabaseクエリ叩いちゃってるからそこも新しくエンドポイント作ってhono側で対応しよう

Yug (やぐ)Yug (やぐ)

うわーイベントハンドラ内でtanstack query発火させたいんだけどどうすれば良いんだろう


イベントハンドラ内でfetch発火させたい

use〇〇というtanstack queryを発火させたい

だがuse〇〇はコンポーネットのトップレベルなどでしか使えない(普通の関数内では使えない)

Yug (やぐ)Yug (やぐ)

claude曰く「useMutation使ってその返り値であるmutateをイベントハンドラ内で使っちゃえば良い」とのこと

んで更にその結果がtrueの場合のみもう1回getUserIdという別のfetchも飛ばしたいのだがそれはどうしようという悩み関して、「それはonSuccessでやっちゃえば良い」とのこと

なるほどなぁ

claude 3.7 sonnet

サインイン成功後にユーザーIDを取得し、そのIDを使ってnavigateする処理を実装しましょう。TanStack QueryとTanStack Routerを組み合わせた方法を示します。

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/router';

function SignInForm() {
  const queryClient = useQueryClient();
  const navigate = useNavigate();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  
  // サインイン用のmutation
  const signInMutation = useMutation({
    mutationFn: async (credentials) => {
      const response = await fetch('/api/signin', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(credentials)
      });
      
      if (!response.ok) throw new Error('サインインに失敗しました');
      return response.json();
    },
    onSuccess: async (data) => {
      // サインイン成功時の処理
      // ユーザー情報をキャッシュに設定
      queryClient.setQueryData(['auth'], data);
      
      try {
        // ユーザーIDを取得するための追加リクエスト
        const userResponse = await fetch('/api/me', {
          headers: {
            'Authorization': `Bearer ${data.token}` // 取得したトークンを使用
          }
        });
        
        if (!userResponse.ok) throw new Error('ユーザー情報の取得に失敗しました');
        
        const userData = await userResponse.json();
        // ユーザーデータをキャッシュに設定
        queryClient.setQueryData(['user'], userData);
        
        // 成功アラート
        alert('ログイン成功');
        
        // 取得したユーザーIDを使ってナビゲーション
        navigate({ to: `/todos/${userData.userId}` });
      } catch (error) {
        console.error('ユーザー情報取得エラー:', error);
        // エラー処理
      }
    },
    onError: (error) => {
      // サインインエラー処理
      console.error('サインインエラー:', error);
      // エラーメッセージを表示するなどの処理
    }
  });
  
  // サインインフォーム送信時のイベントハンドラ
  const handleSubmit = (e) => {
    e.preventDefault();
    // mutationを実行してサインイン処理を開始
    signInMutation.mutate({ email, password });
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">メールアドレス</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
        />
      </div>
      <div>
        <label htmlFor="password">パスワード</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
        />
      </div>
      <button 
        type="submit" 
        disabled={signInMutation.isPending}
      >
        {signInMutation.isPending ? 'サインイン中...' : 'サインイン'}
      </button>
      
      {signInMutation.isError && (
        <div style={{ color: 'red' }}>
          {signInMutation.error.message}
        </div>
      )}
    </form>
  );
}

このコードでは、以下のような流れで処理を行っています:

  1. サインインフォームを送信すると、signInMutation.mutate()が実行される
  2. サインインに成功すると、onSuccessコールバックが呼ばれる
  3. onSuccess内で、トークンを使ってユーザー情報を取得するための追加リクエストを送信
  4. ユーザー情報取得に成功したら、ユーザーIDを使って/todos/${userData.userId}へナビゲーション

このアプローチの利点:

  • Reactのフックのルールに違反せず、イベントハンドラから間接的にデータフェッチを実行できる
  • リクエストの順序が保証される(サインイン成功後にユーザー情報取得)
  • エラーハンドリングが各段階で適切に処理される

必要に応じて、この処理をカスタムフックに抽出して再利用性を高めることもできます。

Yug (やぐ)Yug (やぐ)

tanstack queryで覚えねばいけないことがたくさんあるなぁ

queryClient.setQueryDataとかkeyを関数にするか否かとか

キャッシュ自体は一意の定数つけとけば良さそうなんだけど、そのキャッシュの定義時には何も設定していなかった「そのキャッシュの中身そのもの」みたいな概念があるっぽく(まぁそりゃあそうか)、それをセットするのがsetQueryDataの第二引数だと思われる

setQueryData(queryKey, newData)
setQueryData(queryKey, (oldData) => newData)

https://tanstack.com/query/v4/docs/reference/QueryClient/#queryclientsetquerydata

Yug (やぐ)Yug (やぐ)

signIn実装できた気がするので試してみる
https://github.com/clumsy-ug/hono-todoapp/tree/be3c2693e80067b437680d4586d31b2bff6b8b4f


ログイン成功のalertが出てtodos/:userIdのページまで無事に飛ぶのだが、ログアウトボタンが出てこないしホームに戻るとログインやり直しになる。

やはり、なぜかlocalStorageにcookieが保存されてない

あとキャッシュがStaleではなく即Inactiveになっているのも変(todosキャッシュはStaleになるのに)

Yug (やぐ)Yug (やぐ)

これ関係ありそう?アンチパターンを踏んだかも
(v4の説明ではあるので古い?けど)
https://tanstack.com/query/latest/docs/framework/react/guides/migrating-to-react-query-4#onsuccess-is-no-longer-called-from-setquerydata

onSuccess is no longer called from setQueryData

This was confusing to many and also created infinite loops if setQueryData was called from within onSuccess. It was also a frequent source of error when combined with staleTime, because if data was read from the cache only, onSuccess was not called.

Similar to onError and onSettled, the onSuccess callback is now tied to a request being made. No request -> no callback.

If you want to listen to changes of the data field, you can best do this with a useEffect, where data is part of the dependency Array. Since React Query ensures stable data through structural sharing, the effect will not execute with every background refetch, but only if something within data has changed:

const { data } = useQuery({ queryKey, queryFn })
React.useEffect(() => mySideEffectHere(data), [data])

和訳

onSuccessはもはやsetQueryDataから呼び出されない

これは多くの人を混乱させ、またsetQueryDataがonSuccessの中から呼び出された場合、無限ループを発生させた。また、データがキャッシュからのみ読み込まれる場合、onSuccessは呼び出されないため、staleTimeと組み合わせるとエラーの原因となることが多かった。

onErrorやonSettledと同様に、onSuccessコールバックはリクエストが行われたことに結びつきます。リクエストなし→コールバックなし。

dataフィールドの変更をリッスンしたい場合は、dataが依存関係のArrayの一部であるuseEffectを使用するのが最適です。React Queryは構造共有によって安定したデータを保証するので、エフェクトはバックグラウンドのリフェッチごとに実行されるのではなく、データ内の何かが変更された場合にのみ実行されます:


あーそういうことか

そもそもsuccessとは何の"成功"なのかということを考えれば確かにそうか

リクエストつまりfetchが実際に行われた場合、その成功時のみしかonSuccessは発火しない。

つまり既にリクエスト(fetch)が終わって2回目以降にそのリソースにアクセスしようとしてキャッシュから読み取るだけみたいになってる状態だと、"成功"も何もないのでonSuccessは発火しない

...ということに注意せよ、という話

もしそれやりたいならuseEffetct使え、かぁ・・

Yug (やぐ)Yug (やぐ)

ていうかこの1文って、

onSuccess is no longer called from setQueryData

以下の間違えでは?という気がするなぁ

setQueryData is no longer called from onSuccess

Yug (やぐ)Yug (やぐ)

いや、関係ないか、よく考えたら

どちらにせよ今回は初めてのリクエストだからリクエストはブラウザのキャッシュで留まっているのではなく元サーバーまで絶対に送られてる。だからonSuccessはちゃんと呼ばれてる

(というかonSuccess内のalertが出るんだからそりゃあ呼ばれてるか・・・忘れてた)

Yug (やぐ)Yug (やぐ)

メモ:ログイン成功のalertが出るまで5秒くらいかかってるので遅すぎるし、それにFallbackがついてないのがやばい

Yug (やぐ)Yug (やぐ)

まず現状整理

  1. localStorageにセッションが保存されてない
  2. キャッシュは2つ作られてはいるんだけど即Inactiveに入っている

1に関してはhono側で呼び出しているsupabase.auth.signInWithPasswordの部分が機能していないのが原因のはず

2に関してはqueryClient.setQueryDataのやり方がおかしいのかもしれない


1から見ていくか

Yug (やぐ)Yug (やぐ)

honoの以下signInエンドポイントにおいてsuccessがちゃんとtrueになって中に入っているのは確認できた

index.tsx
app.post('/api/signin', async (c) => {
  const { mailAddress, password } = await c.req.json()
  const success = await signIn({ mailAddress, password })
  if (success) {
    return c.json({
      status: 'success',
      message: 'API: signInに成功しました',
      mailAddress
    })
  } else {
    return c.json({
      status: 'failed',
      message: 'API: singInに失敗しました',
      mailAddress
    })
  }
})

なのでsignInのfetchリクエストはちゃんと送ってるしそこからちゃんとレスポンスも返してる

Yug (やぐ)Yug (やぐ)

userId取得のリクエストもちゃんと成功してることを確認
(遷移先のpathを見ればわかることだが、当然userIdもしっかり取れてる)

index.tsx
app.get('/api/get/userId', async (c) => {
  const userId = await getUserId()
  if (typeof userId === 'string') {
    return c.json({
      status: 'success',
      userId
    })
  } else {
    return c.json({
      status: 'failed',
      message: 'API: userId取得に失敗'
    })
  }
})
Yug (やぐ)Yug (やぐ)

知見

chrome devtoolsのnetworkタブでダウンロードされたjsを眺めていたら、HTTPレスポンスステータスコードが304になっているのばかりだった

304の意味を調べたら、「リクエストされた内容はNot Modified(修正されていない)ので再レスポンスする必要はないからキャッシュをそのまま使ってokやで」という意味らしい
今回tanstack queryでキャッシュしているからだろう

https://developer.mozilla.org/ja/docs/Web/HTTP/Reference/Status/304

Yug (やぐ)Yug (やぐ)

honoのindex.tsx内で呼び出してるsignIn.tsを確認したがちゃんと呼ばれてた

signIn.ts
import { supabase } from "~/backend/supabase/client"
import { SignInProps } from "~/backend/supabase/types"

export const signIn = async({ mailAddress, password }: SignInProps): Promise<boolean> => {
  const { data, error } = await supabase.auth.signInWithPassword({
    email: mailAddress,
    password
  })

  if (!data || error) {
    console.error('signIn.tsでエラーだよ')
    console.error(`data: ${data}`)
    console.error(`error: ${error}`)
    return false
  }

  console.log('成功したで')
  console.log('dataは', data)
  console.log('emailは', mailAddress)
  console.log('passwordは', password)
  return true
}

つまり現状は、signInWithPasswordというlocalStorageを利用するはずのsupabaseのメソッドが実行されているのに、localStorageには何も保存されていないという状況

なぜや・・・どういうことや

Yug (やぐ)Yug (やぐ)

supabase.auth.signInWithPassword自体のソースコードを見に行ってみるか

GoTrueClient.ts
  /**
   * Log in an existing user with an email and password or phone and password.
   *
   * Be aware that you may get back an error message that will not distinguish
   * between the cases where the account does not exist or that the
   * email/phone and password combination is wrong or that the account can only
   * be accessed via social login.
   */
  async signInWithPassword(
    credentials: SignInWithPasswordCredentials
  ): Promise<AuthTokenResponsePassword> {
    try {
      let res: AuthResponsePassword
      if ('email' in credentials) {
        const { email, password, options } = credentials
        res = await _request(this.fetch, 'POST', `${this.url}/token?grant_type=password`, {
          headers: this.headers,
          body: {
            email,
            password,
            gotrue_meta_security: { captcha_token: options?.captchaToken },
          },
          xform: _sessionResponsePassword,
        })
      } else if ('phone' in credentials) {
        const { phone, password, options } = credentials
        res = await _request(this.fetch, 'POST', `${this.url}/token?grant_type=password`, {
          headers: this.headers,
          body: {
            phone,
            password,
            gotrue_meta_security: { captcha_token: options?.captchaToken },
          },
          xform: _sessionResponsePassword,
        })
      } else {
        throw new AuthInvalidCredentialsError(
          'You must provide either an email or phone number and a password'
        )
      }
      const { data, error } = res

      if (error) {
        return { data: { user: null, session: null }, error }
      } else if (!data || !data.session || !data.user) {
        return { data: { user: null, session: null }, error: new AuthInvalidTokenResponseError() }
      }
      if (data.session) {
        await this._saveSession(data.session)
        await this._notifyAllSubscribers('SIGNED_IN', data.session)
      }
      return {
        data: {
          user: data.user,
          session: data.session,
          ...(data.weak_password ? { weakPassword: data.weak_password } : null),
        },
        error,
      }
    } catch (error) {
      if (isAuthError(error)) {
        return { data: { user: null, session: null }, error }
      }
      throw error
    }
  }
Yug (やぐ)Yug (やぐ)

!!!ログ出してみようと思ったら、そもそもこの関数自体が実行されてないっぽいぞ

async signInWithPassword(
    credentials: SignInWithPasswordCredentials
  ): Promise<AuthTokenResponsePassword> {
    console.log('あいうえおあいうえお')  // これが出ない!
    try {
    ...

いや奇妙すぎるな

signInWithPasswordの実行結果(返り値)としてのdataは取得できていることがsingIn.tsで既に確認済みだが、signInWithPasswordは実行されていないのではないかという仮説が発生してしまった

まじでなんだこれ

Yug (やぐ)Yug (やぐ)

この謎すぎる現象をgeminiに聞いてみた

Gemini 2.5 Pro Preview 03-25

問題の分析

ご提示のコードと状況説明を拝見しました。混乱されている点は非常によく理解できます。問題の核心は、Supabaseの認証処理をどこで実行しているか、そしてそれに伴うセッション情報の保存場所にあります。

  1. signInWithPassword の実行場所:

    • あなたのコードでは、Reactクライアント (SignIn.tsx -> useSignIn.ts -> fetchSignIn.ts) がHono APIエンドポイント (/api/signin) を呼び出しています。
    • そして、Hono APIエンドポイント内 (index.tsx -> signIn.ts) で supabase.auth.signInWithPassword が実行されています。
    • つまり、signInWithPassword はHonoのサーバーサイドで実行されています。
  2. console.log が出ない理由:

    • vscode から node_modules 内の GoTrueClient.ts を直接編集しても、それがHonoサーバーの実行プロセスに反映されていない可能性が高いです。
    • Hono (または内部で使用しているVite/Node) は、ビルド時や起動時に node_modules から必要なコードを読み込みます。実行中に直接ファイルを編集しても、すでにメモリにロードされたコードやビルド済みのコードが使われるため、編集内容が反映されないことが多いです。
    • signIn.tsdata が取得できていることから、supabase.auth.signInWithPassword 関数自体は 間違いなく実行されています。ログが出ないのは、編集したファイルが実行時に使われていないか、ログ出力の設定の問題と考えられます。
  3. LocalStorageに何も保存されない理由:

    • これが最も重要な点です。Supabaseのクライアントライブラリ (supabase-js) は、デフォルトではブラウザ環境で実行されたときに、認証情報を自動的にブラウザのLocalStorageに保存します。
    • あなたのコードでは、signInWithPasswordサーバーサイド (Hono) で実行されています。サーバーサイドのJavaScriptプロセスには、ブラウザのLocalStorageという概念は存在しません。
    • Hono API (/api/signin) は、Supabaseでの認証が成功したかどうかを示す { status: 'success' } というJSONを返しているだけで、Supabaseから返された実際のセッション情報(アクセストークン、リフレッシュトークンなど)をクライアントに返していません。
    • そのため、クライアントサイド(React)のSupabaseインスタンスは認証されたことを知らず、LocalStorageにも何も書き込まれません。
  4. 「ログイン成功」アラートが表示される理由:

    • クライアントサイドの useSignIn フックは、Hono API (/api/signin) から { status: 'success' } というレスポンスを受け取ると、onSuccess コールバックを実行します。
    • この onSuccess 内で alert('ログイン成功') を呼び出しているため、アラートが表示されます。これはあくまで API呼び出しが成功したこと を示しているだけであり、クライアントサイドでSupabaseの認証セッションが確立されたこと を意味するわけではありません。
    • fetchUserId が成功しているように見えるのも、おそらくサーバーサイドの /api/get/userId エンドポイントを叩いており、そのエンドポイント内の getUserId 関数(これもサーバーサイドで実行される)が、直前の /api/signin で認証されたサーバーサイドのSupabaseクライアントのコンテキストからユーザーIDを取得できているためだと考えられます。(getUserId の実装によります)

矛盾の解消

  • signInWithPassword はサーバーサイドで 実行されています (signIn.tsdata が取れているため)。
  • console.log が出ないのは、編集したファイルが実行時に反映されていないか、ログ設定の問題です。
  • LocalStorageに保存されないのは、認証がサーバーサイドで行われ、セッション情報がクライアントに渡されていない ためです。

解決策:クライアントサイド認証への変更(推奨)

Supabaseの認証を利用する場合、特別な理由がない限り、クライアントサイドで直接認証処理を行うのが最も一般的で簡単な方法です。これにより、Supabaseライブラリが自動的にセッション管理(LocalStorageへの保存、トークンの更新など)を行ってくれます。

以下のように修正することをお勧めします。

  1. Hono API (/api/signin) を削除または変更:

    • クライアントサイドで認証を行うため、このエンドポイントは基本的に不要になります。
  2. signIn.tsfetchSignIn.ts を削除:

    • これらも不要になります。
  3. クライアントサイドにSupabaseクライアントを用意:

    • Reactアプリケーションのエントリーポイントなどで、クライアントサイド用のSupabaseクライアントインスタンスを作成します。環境変数 (NEXT_PUBLIC_VITE_ プレフィックス推奨) を使ってURLとAnon Keyを設定します。
    // 例: src/lib/supabaseClient.ts (クライアント用)
    import { createClient } from '@supabase/supabase-js'
    
    const supabaseUrl = import.meta.env.VITE_SUPABASE_URL // Viteの場合
    // const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL // Next.jsの場合
    const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
    // const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
    
    if (!supabaseUrl || !supabaseAnonKey) {
      throw new Error("Supabase URL or Anon Key is missing in environment variables.");
    }
    
    export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
        auth: {
            // storage: localStorage, // デフォルトでLocalStorageが使われる
            autoRefreshToken: true,
            persistSession: true, // セッションを永続化する (デフォルトtrue)
            detectSessionInUrl: true, // URLからのセッション検出 (メール認証などに必要)
        },
    })
    
  4. useSignIn.ts を修正:

    • fetchSignIn の代わりに、クライアントサイドの supabase インスタンスを使って直接 signInWithPassword を呼び出します。
    • fetchUserId の代わりに、認証成功後に supabase.auth.getUser() を使用するか、signInWithPassword の戻り値からユーザー情報を取得します。
    // useSignIn.ts の修正案
    import { useMutation, useQueryClient } from "@tanstack/react-query";
    import { useNavigate } from "@tanstack/react-router";
    import { authKeys } from "../-key/key";
    import { AuthProps } from "../-types";
    import { supabase } from "~/lib/supabaseClient"; // ★ クライアントサイドのSupabaseクライアントをインポート
    
    export const useSignIn = () => {
      const queryClient = useQueryClient();
      const navigate = useNavigate();
    
      const { mutate } = useMutation({
        mutationFn: async (credentials: AuthProps) => {
          const { mailAddress, password } = credentials;
          // ★ クライアントサイドで直接 Supabase の signInWithPassword を呼び出す
          const { data, error } = await supabase.auth.signInWithPassword({
            email: mailAddress,
            password: password,
          });
    
          if (error) {
            console.error("Supabase Sign In Error:", error);
            // エラーメッセージをスローして onError で捕捉できるようにする
            throw new Error(error.message || "サインインに失敗しました。");
          }
    
          if (!data?.user) {
             // 通常は error が発生するはずだが念のため
             throw new Error("ユーザー情報の取得に失敗しました。");
          }
          // 成功したら data (user, session を含む) を返す
          return data;
        },
        onSuccess: (data) => { // ★ data に { user, session } が含まれる
          const userId = data.user.id;
    
          // ★ Supabaseライブラリが自動でLocalStorageにセッションを保存するはず
    
          // React Query のキャッシュを更新 (任意)
          // 必要であれば user や session 全体をキャッシュしても良い
          // queryClient.setQueryData(authKeys.signIn, data);
          queryClient.setQueryData(authKeys.userId, userId); // ユーザーIDをキャッシュ
    
          console.log("クライアントサイド認証成功:", data); // デバッグ用
          alert("ログイン成功"); // アラート表示
    
          // ログイン後のページへ遷移
          navigate({ to: `/todos/${userId}` });
        },
        onError: (error: Error) => {
            // エラー処理
            console.error("Sign In Mutation Error:", error);
            alert(`ログイン失敗: ${error.message}`);
        }
      });
    
      // mutate関数を返す (元の型定義に合わせる)
      return mutate as (credentials: AuthProps) => void;
    };
    
  5. Hono API (/api/todos/:userId) の認証:

    • クライアントサイドで認証が完了すると、クライアントサイドの supabase インスタンスを使ってAPIを呼び出す際に、自動的に Authorization: Bearer <supabase-jwt> ヘッダーが付与されるようになります(fetch を直接使う場合は手動で設定が必要な場合あり)。
    • Hono側では、リクエストヘッダーからJWTを取得し、Supabase Adminクライアントなどを使ってそのJWTを検証し、ユーザーを特定する必要があります。これには @supabase/supabase-js をサーバーサイドで使うか、あるいはHonoのJWTミドルウェアなどを利用します。
    // Hono API (index.tsx) での認証例 (要 Supabase Admin Client または適切なJWT検証)
    import { createClient } from '@supabase/supabase-js'; // サーバー用クライアント
    
    // サーバーサイド用のSupabaseクライアント初期化 (サービスキーを使用)
    const supabaseAdmin = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
    
    // ... 他のimport
    
    // 認証ミドルウェア (例)
    app.use('/api/todos/*', async (c, next) => {
      const authHeader = c.req.header('Authorization');
      const token = authHeader?.replace('Bearer ', '');
    
      if (!token) {
        return c.json({ status: 'failed', message: '認証トークンがありません' }, 401);
      }
    
      // トークンを検証してユーザー情報を取得
      const { data: { user }, error } = await supabaseAdmin.auth.getUser(token);
    
      if (error || !user) {
        return c.json({ status: 'failed', message: '無効なトークンまたはユーザーが存在しません' }, 401);
      }
    
      // ユーザー情報をコンテキストにセットして後続処理で使えるようにする
      c.set('user', user);
      await next();
    });
    
    // Todo取得エンドポイント (認証ミドルウェアの後)
    app.get('/api/todos/:userId', async (c) => {
      const user = c.get('user'); // ミドルウェアでセットされたユーザー情報
      const requestedUserId = c.req.param('userId');
    
      // ★ ログインユーザー自身のTodoのみ取得できるようにチェック
      if (user.id !== requestedUserId) {
          return c.json({ status: 'failed', message: 'アクセス権がありません' }, 403);
      }
    
      // userIdを使ってTodoを取得 (認証済みなので安全)
      const result = await queryTodos(user.id);
    
      // ... (以降の処理は同じ)
    });
    
    // userIdパラメータが不要になる可能性もある
    app.get('/api/todos', async (c) => { // /:userId を削除
        const user = c.get('user');
        if (!user) { //念のためチェック
            return c.json({ status: 'failed', message: '認証が必要です'}, 401);
        }
        const result = await queryTodos(user.id); // 認証されたユーザーのIDを使用
    
        if (result === null || result.length === 0) { // resultが配列と仮定
            return c.json({
                status: 'success',
                data: [],
                message: 'まだTodoはありません'
            });
        } else {
            return c.json({
                status: 'success',
                data: result,
                message: 'Todo取得完了'
            });
        }
    });
    
    // ... 他のエンドポイント
    

このクライアントサイド認証のアプローチにより、Supabaseの標準的な認証フローに乗り、LocalStorageへのセッション保存も期待通りに機能するはずです。

なるほど!!そういうことか

単にブラウザのlocalstorageいじろうとするjsコードをサーバ側(hono側)で実行しても意味無いに決まっとるやん、クライアント側で実行せなあかんで、ということか

んでソースコードごと編集したのにログが出ない理由は、honoが既にロードしたメモリからしか読み取らないようにしてるからとかいろいろ理由がありそう。だから編集しても意味無い場合が結構ありそうだなぁーという注意は今後していきたい。知見だ

  • Hono (または内部で使用しているVite/Node) は、ビルド時や起動時に node_modules から必要なコードを読み込みます。実行中に直接ファイルを編集しても、すでにメモリにロードされたコードやビルド済みのコードが使われるため、編集内容が反映されないことが多いです。
  • signIn.ts で data が取得できていることから、supabase.auth.signInWithPassword 関数自体は 間違いなく実行されています。ログが出ないのは、編集したファイルが実行時に使われていないか、ログ出力の設定の問題と考えられます。

じゃあhonoってどういう時に使うんだ?存在意義がわからない

gemii

おっしゃる通り、React、Tanstack Query、Tanstack Router、そしてSupabaseクライアントライブラリを組み合わせれば、クライアントサイドだけで非常に多くの機能を持つアプリケーションを構築できます。特にSupabaseは認証、DBアクセス、リアルタイム機能などをクライアントから直接扱えるように設計されているため、「バックエンドAPIサーバーは不要なのでは?」と感じるのは自然な流れです。

では、なぜHonoのようなバックエンドフレームワークが存在し、多くの開発者に利用されているのでしょうか?その理由は、クライアントサイドだけでは解決が難しい、あるいは不可能な課題や要件が存在するからです。

Hono (バックエンドフレームワーク) が必要になる主な理由:

  1. セキュリティ:

    • 秘密情報の保護: APIキー(例: Stripe、SendGrid、Twilioなど外部サービス)、Supabaseのservice_roleキー(RLSをバイパスできる強力なキー)など、絶対にクライアントサイドに公開してはいけない情報を扱う処理は、バックエンドで行う必要があります。Honoサーバーならこれらのキーを安全に保管し、必要な処理を実行できます。
    • 複雑な認可ロジック: SupabaseのRow Level Security (RLS) は強力ですが、それだけでは表現しきれない複雑なアクセス制御やビジネスルールが必要な場合があります。「特定の条件下でのみ更新を許可する」「複数のテーブル情報を参照してアクセス権を判断する」といったロジックは、バックエンドで実装する方が安全かつ管理しやすいことがあります。
    • 不正アクセスの防御: レートリミット(API呼び出し回数制限)、IPアドレス制限、より高度なボット対策などをバックエンドで実装することで、アプリケーション全体のセキュリティを強化できます。
  2. ビジネスロジックのカプセル化:

    • 複雑なデータ操作: 複数のテーブルを結合・集計したり、外部APIから取得したデータと組み合わせたり、重い計算処理を行ったりする場合、それらをバックエンドAPIとしてまとめておくと、クライアント側のコードがシンプルになり、処理も効率化できます。
    • 一貫性の担保: 複数のステップにわたる処理(例: 注文処理で在庫確認→決済→DB更新→メール送信)をトランザクションのように扱いたい場合、バックエンドで一連の流れを管理する方が確実です。
    • 再利用性: 複数のクライアント(Web、モバイルアプリ、他のサービス)から利用される共通のロジックは、バックエンドAPIとして提供するのが効率的です。
  3. 外部サービス連携:

    • メール送信、プッシュ通知、ファイル変換、支払い処理など、多くの外部サービス連携は、セキュリティ(APIキー管理)や安定性の観点からバックエンドを介して行われます。
  4. パフォーマンス最適化:

    • データ整形・集約: クライアントが必要とする形式にデータを加工・集約してから返すことで、クライアントに送信するデータ量を削減し、ネットワーク負荷やクライアント側の処理負荷を軽減できます。
    • キャッシング: よくアクセスされるデータや計算結果をバックエンドでキャッシュすることで、レスポンス速度を向上させ、データベースへの負荷を減らせます。
  5. サーバーサイドレンダリング (SSR) / エッジコンピューティング:

    • Honoは特にCloudflare Workersのようなエッジ環境で高速に動作するように設計されています。エッジでAPIを提供することで、ユーザーに近い場所で処理を行い、低遅延を実現できます。また、フレームワークによってはSSR(サーバーサイドレンダリング)のためにバックエンド実行環境が必要です。(Hono自体はAPIフレームワークですが、エッジでの活用文脈があります)

Supabase と Hono の関係性:

  • Supabaseは強力なBaaSであり、多くのバックエンド機能を肩代わりしてくれます。これにより、開発者はより少ないコードでアプリケーションを構築できます。
  • しかし、上記のようなSupabaseだけではカバーしきれない、あるいはバックエンドで処理した方が適切な要件が出てきた場合に、Honoのようなフレームワークが役立ちます。
  • Hono API内でSupabaseのAdminクライアント(service_roleキーを使用)を使い、RLSをバイパスして管理操作を行ったり、複雑なクエリを実行したりできます。

結論:

クライアントサイド完結型のアーキテクチャは、多くのシンプルなアプリケーションやプロトタイピングにおいて非常に有効です。しかし、アプリケーションが成長し、より複雑な要件(高度なセキュリティ、複雑なビジネスロジック、外部連携、パフォーマンス最適化など)が出てくると、バックエンドを持つことの利点が大きくなります。

Honoは、そのような要求に応えるための、軽量で高速、かつ柔軟な選択肢の一つとして存在意義があります。特にAPIサーバーを構築したい場合や、エッジコンピューティングを活用したい場合に強力なツールとなります。

したがって、「Honoが不要」なのではなく、「アプリケーションの要件に応じて、クライアントサイドのみで十分か、バックエンドが必要か」を判断し、必要な場合にHonoのようなフレームワークを活用する、と考えるのが適切でしょう。今回のサインイン処理に関しては、クライアントサイドで完結させる方がシンプルで一般的ですが、他の機能でバックエンドが必要になる可能性は十分にあります。

あ~なるほど、Honoの意義としてはCF Workersみたいなエッジ環境でSSRとして活用するのか
だからrenderer.tsxがあったりする訳か、そのjsxをssrできるみたいな話なのだろう

あと共通ロジックをapiエンドポイントとしてまとめておいた方が楽とかいろいろある

へぇ~

Yug (やぐ)Yug (やぐ)

よし、ブラウザのlocalStorage使ってる処理は全部hono使わずにクライアントだけで完結するように修正しよう

Yug (やぐ)Yug (やぐ)

というかsigninのfetchした後userId取得のfetchもしててめっちゃ時間かかってたけど、signinの返り値のdataからidも取れることに気付いた...!!

これこそがN + 1問題の解消みたいなやつなのかな?(知らん)

Yug (やぐ)Yug (やぐ)

ただsignInのレスポンスpromiseはキャッシュすべきなのか?

/authではbeforeLoad: checkSessionを設定して非認証ユーザの強制リダイレクト処理を書いているのでそもそも必要ないfetchはされない気もするが....

いやでもchrome devtoolsでlocalStorage消してもう一度signinページに来てsigninしようとするみたいな場合はキャッシュが生きるのか(まぁそんなこと普通のユーザはしないだろうけど)

localStorageには無いけどtanstack queryのキャッシュにはまだ残っているsigninの情報を使用して、すぐにsigninができるみたいな

じゃあちゃんと残しておくべきだな多分

Yug (やぐ)Yug (やぐ)

ただそもそも、なぜかsinginのキャッシュがずっとinactiveなんだよなぁ、なんでだ

queryClient.setQueryData(authKeys.signIn, userId)が機能してないのか?


んん?mutationKeyというのを発見したぞ、これを使えないだろうか
https://tanstack.com/query/latest/docs/framework/react/reference/useMutation

確かにuseQueryの時は普通にqueryKeyというオプションが用意されてたのにuseMutationの時だけそういうオプションが無いのってなんでだろうとか思ってたけど、普通にあったってことか?

使ってみたけどキャッシュに登録されないなぁ。じゃあmutationKeyというのは何なんだろう

うーん、キャッシュするものではなさそう。だめか。

gemini

はい、TanStack Query v5 (旧 React Query v5) の useMutation フックに導入された mutationKey オプションについて説明します。

参照された GitHub ディスカッションにもあるように、mutationKey は v5 で新たに追加された オプショナルな オプションです。


mutationKey とは何か?

mutationKey は、特定の useMutation インスタンスを一意に識別するためのキーです。useQuery における queryKey がクエリキャッシュを識別するのと概念的には似ていますが、役割は少し異なります。

主な目的・メリット

  1. DevTools での識別性向上:

    • React Query DevTools を使用している場合、mutationKey を設定しておくと、どのミューテーションが実行されているか、その状態(pending, success, errorなど)を特定しやすくなります。キーがない場合、ミューテーションは DevTools 上で区別しにくくなります。
  2. プログラムからのミューテーションへのアクセス:

    • queryClient を通じて、特定のミューテーションの状態やインスタンスにアクセスする必要がある場合に役立ちます。例えば、queryClient.getMutationCache().find({ mutationKey: [...] }) のようにして、特定のキーを持つミューテーションの状態を取得したり、操作したりすることが可能になります。(これは高度なユースケースです)
  3. コードの明確性と整理:

    • アプリケーション内に多数のミューテーションが存在する場合、それぞれに mutationKey を設定することで、どのミューテーションが何を担当しているのかがコード上でより明確になる可能性があります。

queryKey との主な違い

  • キャッシュ: queryKey はクエリの結果をキャッシュするために不可欠です。一方、mutationKey はミューテーションの「結果」自体をキャッシュするためのものではありません。ミューテーションは通常、サーバーの状態を変更するアクションであり、その結果をキャッシュすることは少ないです。
  • 必須性: queryKeyuseQuery に必須ですが、mutationKeyuseMutation には オプショナル(任意) です。設定しなくてもミューテーションは正常に機能します。
  • 主な役割: queryKey の主な役割はデータキャッシュと再取得の管理です。mutationKey の主な役割は、ミューテーションインスタンス自体の識別です。

どういう時に使うか?

  • React Query DevTools でデバッグを容易にしたい場合。
  • queryClient を介して特定のミューテーションの状態を外部から参照・操作したい高度なケース。
  • コードベースが大きく、多数のミューテーションがあり、それらを明確に区別したい場合。

基本的な使い方では必須ではない

onSuccessonErroronSettled コールバック内で queryClient.invalidateQueries() を使って関連するクエリを無効化する、といった基本的な使い方においては、mutationKey は必須ではありません。クエリの無効化は、従来通り、無効化したいクエリの queryKey を指定して行います。

コード例

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { addTodo } from './api'; // 仮のAPI関数

function AddTodoForm() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    // ここで mutationKey を設定
    mutationKey: ['addTodo'],
    mutationFn: addTodo, // APIを呼び出す関数
    onSuccess: () => {
      // 成功時に 'todos' クエリを無効化
      // ここで mutationKey は直接使わないことが多い
      queryClient.invalidateQueries({ queryKey: ['todos'] });
      console.log('Todo added!');
    },
    onError: (error) => {
      console.error('Error adding todo:', error);
    }
  });

  const handleSubmit = (event) => {
    event.preventDefault();
    const title = event.target.elements.title.value;
    if (title) {
      mutation.mutate({ title }); // mutationを実行
      event.target.reset();
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" placeholder="New Todo" disabled={mutation.isPending} />
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? 'Adding...' : 'Add Todo'}
      </button>
      {mutation.isError && (
        <div style={{ color: 'red' }}>Error: {mutation.error.message}</div>
      )}
    </form>
  );
}

まとめ

  • mutationKey は TanStack Query v5 の useMutation に追加されたオプショナルなキー。
  • 主な目的は、DevToolsでの識別や、queryClient を使ったプログラムからのアクセス
  • queryKey のようにミューテーション結果のキャッシュに使われるわけではない。
  • 基本的なミューテーションの利用(実行とクエリ無効化)には必須ではない。

デバッグや高度な制御が必要な場合に設定すると便利な機能です。

Yug (やぐ)Yug (やぐ)

はーなるほど、useQueryでデータ取得しようとしないとinactiveになるんだ、知らなかった

claude

この状況はおかしくありません。signinのキャッシュがInactiveになっている理由はTanStack Queryの設計によるものです。

キャッシュがInactiveになっている理由は以下の通りです:

  1. 直接設定されたデータの扱い
    queryClient.setQueryDataで直接設定されたデータは、アクティブなサブスクリプション(useQueryフック)がない場合、Inactiveとマークされます。

  2. キャッシュステータスの意味

    • Inactive:データはキャッシュに存在するが、現在アクティブに使用されていない
    • Active:useQueryフックを通じて現在サブスクライブされている
  3. 想定される動作
    あなたのケースでは、signinデータをsetQueryDataで設定した後、それを使用するuseQueryがまだアクティブでない(あるいは別ページにある)ため、Inactiveとして表示されています。

スクリーンショットからは、["todos"]がActiveで["signIn"]がInactiveになっていることが確認できます。これは["todos"]データがどこかでuseQueryによって使用されていて、["signIn"]データはsetQueryDataで設定されたものの、現在のページでuseQueryによって読み取られていないことを示しています。

この状態は問題ありません。データは依然としてキャッシュに保存されており、必要なときにuseQueryでアクセスできます。ユーザーIDがInactiveでも、その値は保持されており、useQueryでアクセスすると再びActiveになります。

試してみるか

todo一覧ページ内でボタンを押したらsigninプロミスをuseQueryでfetchする感じでやってみるか

いや普通にレンダー時にfetch発火させちゃって良いか、他のやつと同じように

Yug (やぐ)Yug (やぐ)

うーん、これ何に対してfetchすればいいんだ?

基になってるやつはsupabaseのsigninメソッド実行してるだけで自分が立てたhonoのapiのurlとか取得できないしなぁ


あー普通にまったく同じsignIn.tsをこっちでも実行すれば良いのかも

Yug (やぐ)Yug (やぐ)

できた!!ちゃんとどっちもStaleになった

https://github.com/clumsy-ug/hono-todoapp/tree/b2e379ae39ec7db11dc4aad073c42e2f738b1513

へぇー、useQuery(useSuspenseQuery)で同じkeyに対するリクエストが送られて初めてそのキャッシュされたプロミスは反応してinactiveからstaleになってくれるんだなぁ、勉強になった


疑問

  • signInリクエストは1度やったらもう普通やらないと思うのでキャッシュする必要性ほぼ無い気もするが、キャッシュしないで良いか?どうすべきだ?
  • todos一覧のキャッシュに関しても、追加/削除/編集したらtodo一覧のデータはすでに変わってしまうので、同じキャッシュを使用することはできないので、結局キャッシュって意味なくないか...??
    • ブラウザを再読み込みする訳ではない、かつデータ内容がまったく変わっていない可能性が普通にあるもの、に対して、何度もリクエスト送りたいなんてケースあるか...??
      • キャッシュの必要性について、やはりまだよくわかっていない
      • あーでも5分経過してstaleがinactiveになった時に再fetchしてくれるのが助かるのかなぁ
      • 現状だとuseEffectではなくレンダー時に直接コンポーネント直下でfetchに繋がるuse〇〇を実行しているが、そうではなくuseEffect内でsetStateとか使っちゃうと無限レンダーになってしまうってこと?
        • いやそんなことはない、当然そんなやばすぎることはuseEffect使ったからと言って起こらない
      • あー多数のユーザが同時編集している可能性があり、その場合はtanstack queryみたいなキャッシュ&自動fetchという機能が役立つということ?
        • いやでもそれは自動fetchが役立つのであってキャッシュは関係ない
        • いや関係あるか。自動fetchの際に該当プロミスのkeyを使用した更新処理が行われていない場合、中身同じであると判定できて、その場合はそのままキャッシュを返すことができる
        • だがそうなるとそれは多数ユーザ同時編集という概念とは関係ない話で、自分のみを想定した場合になるけれども
        • ん、?だがそもそもuseMutationで更新する際に再レンダーしてUI変更もちゃんとやるので、その時点で状態は最新であることが保証できている。だから自動fetchの必要が無いよな..?
        • ん、そこで多数ユーザ同時編集という概念が登場するのか。他の人は更新してるかもしれない、ていうことを考えねばいけないので定期的な自動fetchはやはり必須だよね、みたいな
        • あとuseEffectでデータ更新すると、レンダー後にvalue更新してもう1回レンダーすることになって、画面のちらつきにもなるし効率性も悪いし、という問題(="ウォーターフォール問題")があるのに対して、tanstack query使うと内部で良きにやってくれるからその心配はなく、かつサーバへの不要なリクエストはしないで済む、そしてそれを活かすためにはuseEffectではなくtanstack queryのようなキャッシュ&自動fetch機能が必要である。そしてそれを機能させるにはReactの書き方の制約とかも相まってコンポーネント直下に書く必要がある。だから「tanstack queryを使う かつ それはコンポーネント直下でないといけない」という今の状況が生まれている、と言える...!?
        • つまり意義(積極的理由)と制約(消去法的理由)の2つがあるのかなぁという感じ
Yug (やぐ)Yug (やぐ)

claudeに聞いたら、かなりいろいろ意義がありそうだなぁとわかった

  • バックグラウンドでfetchすることで、ローカルという枠組みを超えたキャッシュが実現できてるとも言えるよね
    • 多数ユーザ同時編集についての話が多分これ
    • たしかに
  • ローカルだけの観点で言っても、複数コンポーネントから同じデータ取得しようとしたらキャッシュが便利に決まってるやん
    • たしかに
  • あとページネーション(ただのページ遷移)でも、mountだとfetch反応してしまうがそうではなくキャッシュ使うだけでええやんみたいなこともできそう
  • optimistic updatesとかライブラリ側がやってくれるから助かるやん
    • たしかに。つまり何かを避けるためではなく何か凄い積極的な理由を実現するためという側面もあるんだなぁ、たしかに、となった
  • 単純に、宣言的記述だから再利用性上がって最高やん
    • (まぁそれはtsquery使わずとも関数自分で作れば同じな気もするけど)
  • useSuspenseQueryとか使っちゃえばisLoadingとかの変数不要になってめっちゃ便利やん
    • たしかに
Yug (やぐ)Yug (やぐ)

というまとめをclaudeに精査してもらった

claude 3.7

あなたのまとめは非常に的確です。TanStack Queryの価値を様々な角度から捉えられています。少し補足と精査をしてみます:

まとめの精査

バックグラウンドfetchによるグローバル視点のキャッシュ

  • 正確な理解です。ローカルキャッシュに最新のサーバーデータを定期的に同期することで、「ローカル」の枠を超えた価値を提供しています。
  • 多数ユーザー環境では特に重要な機能です。

複数コンポーネントでの共有キャッシュ

  • その通りです。これはアプリケーション内でのデータ整合性を保ちつつ、パフォーマンスも向上させる重要な利点です。

ページネーション/ページ遷移での恩恵

  • 正確です。特にSPAでページ間を行き来する場合、前のページに戻ったときに即座にデータを表示できる点は大きな利点です。
  • また、無限スクロールやページネーションの実装も大幅に簡略化されます。

Optimistic Updates

  • おっしゃる通り、これは「何かを避ける」というネガティブな理由ではなく、「より良いUXを実現する」という積極的な理由です。
  • ユーザーアクションに対する即時フィードバックは現代UIの重要な要素です。

宣言的APIと再利用性

  • 確かに独自関数でも実現可能ですが、TanStack Queryの宣言的APIは:
    • チーム内での一貫性を保ちやすい
    • エッジケース(キャンセル、競合状態など)も含めて適切に処理
    • フレームワークと深く統合されている
    • メンテナンスの負担が減る

Suspenseとの統合

  • おっしゃる通りです。特にReact 18以降、SuspenseとTanStack Queryの組み合わせはコードをさらにシンプルにします。
  • ローディング状態の宣言的な処理が可能になり、コンポーネントのロジックに集中できます。

付け加えるとしたら

  • 開発者体験 (DX): 単なる機能性だけでなく、開発プロセス全体が改善されることも大きな価値です。
  • 型安全性: TypeScriptとの相性が良く、データ取得から表示までの型の一貫性を保てます。
  • デバッグのしやすさ: DevToolsが提供され、キャッシュ状態やクエリの挙動を可視化できます。

全体として、あなたのまとめは本質を捉えています。TanStack Queryは単なる「キャッシュライブラリ」ではなく、モダンReactアプリケーションのデータフローを根本的に改善するツールと言えるでしょう。

このあたりもあるのか↓

  • 独自関数でも再利用性上げることできるとはいっても、書き方が完全に自由になるのでチーム内でばらつきとか出る可能性が高く、良くない
    • tsqueryだと書き方の制約があるからこそ統一性が保てて良い
    • 修正もテストも楽になる
  • tsとの相性が良く型安全性が高まる
  • デバッグツールが用意されてて便利
Yug (やぐ)Yug (やぐ)

よし、ということでキャッシュやtsqueryの意義については腑に落ちたので開発を進める

とりあえず非同期処理は全部キャッシュするので良いかなーていう感じでゆる~くやっていく

(signInのやつは1回しか普通実行しないからキャッシュの必要性無くない?みたいな応用はまた次の機会で。)

Yug (やぐ)Yug (やぐ)

できた!ユーザをテーブルにINSERT
https://github.com/clumsy-ug/hono-todoapp/tree/57b535fdc942fb708bfe47b08b8b9ffb4833d684

  • onSuccessをasyncに変えて、中でhono apiへリクエスト送る感じにした
    • それ以外の方法/より良い方法 があるのかはすごく気になる
    • リクエスト2回送ってるので、やはり遅い感じが否めない
    • onSuccessを2つ作って分離して見易くするとかできるのかなぁ
  • mutationFnのsignUp関数でdata.user.idを返してたところをdata.userにすることで、User.idとUser.emailとしてどちらも使えるように抽象化した
Yug (やぐ)Yug (やぐ)

claudeに聞いてみた、なるほど~~ただ数行分を関数に分ければ良いのか

claude

このコードを改善する方法をいくつか提案します。確かに、onSuccessを分割することで、コードをより整理できるでしょう。

// useSignUp.ts
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import { authKeys } from "../-key/key"
import { AuthProps } from "../-types"
import { signUp } from "./signUp"
import { fetchRegisterUser } from "./fetchRegisterUser"

export const useSignUp = () => {
  const queryClient = useQueryClient()
  const navigate = useNavigate()

  const { mutate } = useMutation({
    mutationFn: (credentials: AuthProps) => signUp(credentials),
    onSuccess: async (user) => {
      if (!isValidUser(user)) return

      const registrationSuccess = await handleUserRegistration(user.email)
      if (!registrationSuccess) return

      handleSuccessfulSignUp(user)
    }
  })

  // ユーザーの有効性を確認する関数
  const isValidUser = (user: any): boolean => {
    if (!user || !user.email) {
      alert('サインアップの結果、userもしくはuserのemailが取得できませんでした')
      console.error("user: ", user)
      console.error("user.email: ", user?.email)
      return false
    }
    return true
  }

  // ユーザー登録を処理する関数
  const handleUserRegistration = async (email: string): Promise<boolean> => {
    const success = await fetchRegisterUser(email)
    if (!success) {
      alert('user registerのfetchの結果がfalsyでした')
      return false
    }
    return true
  }

  // サインアップ成功時の処理を行う関数
  const handleSuccessfulSignUp = (user: { id: string, email: string }) => {
    queryClient.setQueryData(authKeys.signUp, user.id)
    alert('サインアップ成功')
    navigate({ to: `/todos/${user.id}` })
  }

  return mutate as (credentials: AuthProps) => void
}

主な改善点:

  1. 関数の分割: onSuccess内のロジックを3つの小さな関数に分割しました:

    • isValidUser: ユーザーオブジェクトのバリデーション
    • handleUserRegistration: ユーザー登録プロセス
    • handleSuccessfulSignUp: 登録成功後の処理
  2. 型の明示化: userパラメータに対して明示的な型を追加しました。

  3. 早期リターン: 各段階でエラーがあった場合に早期リターンするパターンを使用し、コードを読みやすくしました。

  4. 命名の改善: successregistrationSuccessに変更し、変数の目的をより明確にしました。

この改善により、各関数は一つの責任だけを持つようになり、コードの可読性と保守性が高まります。また、将来的にこれらの関数を他の場所で再利用することも容易になります。

関数を別ファイルに分離したら、ちゃんとuserがnullでないことを保証してるのに、userがnullの可能性があります、と赤線で型エラーが出る

へぇ、tsはその関数の内部まで見てくれる訳ではないのか、知見だ

なので関数はuserだけ確認するように変えてそれを実行しつつuser.emailがあるかどうかは別で直接書くことで解決した
https://github.com/clumsy-ug/hono-todoapp/tree/01feb6d66d0bc70979d745848e866bcc19c34e08

Yug (やぐ)Yug (やぐ)

todo新規作成(insert)を書いたがなぜかDBへのクエリエラー。ログ見てもほぼ情報が無い


別問題だと思うが、AuthのuserIdとusersテーブルのidが違ってしまっているのに気づいたので修正
https://github.com/clumsy-ug/hono-todoapp/tree/966ca585c5d28b7932eb974dec89f15b52f9f704


ん!!それで再度試すと普通にinsertができるようになった。なぜだ...?

あーわかった

todosテーブルのuser_idはnullを許容しない設定でsupabase上でtable設計したのに、contentだけ送ってuser_idは送らずにリクエスト送ってしまってたからか

つまりテーブルの値が入るべきフィールドに入っていないリクエストがきたからクエリエラーになった

こういうケースだとあんなにそっけないエラーログが出るのか...
というかスタックトレースしかでないのか...覚えておこう。
supabaseのテーブル設計に準じていないリクエスト、みたいなやつだとsupabaseからの細かいエラーメッセージみたいなのは提供されない、ということなんだろうなぁ

んでAuthのuseridとusers.idが違かったのは今回の問題に関係しておらず、単に自分のミス
(まぁ統一性が無いのは依然問題なので、気付けて良かった&直せて良かった)

これ、さっき気付いて直したんだけど、過去の自分の不注意によりinsertなのにupdateという名前にしたり処理がごっちゃになっていたので、その影響で「渡すのはcontentだけで良いや、user_idは渡す必要ないし」とか思っていたのだろう

Yug (やぐ)Yug (やぐ)

そして更新時にqueryClient.invalidateQueries(...)をやるだけで該当キャッシュ(Todo一覧)の更新(再リクエスト)までやってくれるのが便利だ...!!

Yug (やぐ)Yug (やぐ)

supabaseのdeleteメソッド
https://supabase.com/docs/reference/javascript/delete

  • バックエンド

普通にgetリクエストのAPI作ればいけるだろう
いや消すtodoのidを送らないといけないからpostリクエストか

  • フロントエンド

削除してまたuseMutation内でinvalidateQuery走らせてtodo一覧のクエリを無効化することで更新すれば良さげかな

Yug (やぐ)Yug (やぐ)

これはほんとに謎なのだが、なぜか<ul>要素にpadding-left:40pxが自動付与されてる(???)ので応急処置として以下を書いておいた

<ul style={{ paddingLeft: '0px' }}>

スタイルは面倒だから今回は無し、最低限のやつのみstyle属性にベタ書きする

Yug (やぐ)Yug (やぐ)

というかこのapi側の最後のレスポンス部分、関数として共通化できないかなぁと思ってしようとしたけどresultの型それぞれ違うし、cをpropsで渡すと型指定面倒そうだし、messageもそれぞれ違うからきついか。諦めよう

if (result === null) {
  return c.json({
    status: 'success',
    data: [],
    message: 'まだTodoはありません'
  })
} else {
  return c.json({
    status: 'success',
    data: result,
    message: 'Todo取得完了'
  })
}
Yug (やぐ)Yug (やぐ)

あ、キャッシュのkeyをtodoKeys.allしか持っていないので、各todoのkeyも作ってそれだけ更新するみたいなことできそうだな

んーでもそもそも各todoのキャッシュを設定していないんだよな
各todoの詳細ページ作るつもりなかったから

あーinsertの際に設定することはできそうか

  • 最初に["todos"]キーで設定してtodo一覧取得
  • その後insertする度に["todos", "$todoId"]で追加
    • そこで["todos"]キーをinvalidateもすることで更新情報も画面に反映
  • updateする際は["todos", "$todoId"]をinvalidateするだけで良い
    • それならupdate後も順序は変わらなくなるはず
Yug (やぐ)Yug (やぐ)

と思ったんだけどclaudeに聞いたらめっちゃ良さげな案を教えてくれた

要約

queryClient.invalidateQueriesは使わないで良いよ。
queryClient.getQueryDataqueryClient.setQueryData使ってキャッシュだけ更新しちゃってそれを["todos"]キャッシュに上書きしちゃいな。

なるほど...!!!
ローカルで全部済ましてしまうことができるのか...天才的だ

勉強になった部分をコメントに書いた

useUpdateTodo.ts
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { fetchUpdateTodo } from "./fetchUpdateTodo"
import { todoKeys } from "../-key/key"
import { UpdateTodoProps } from "~/backend/supabase/types"
import { Todo } from "../-types"

export const useUpdateTodo = () => {
  const queryClient = useQueryClient()

  const { mutate } = useMutation({
    mutationFn: (updateProps: UpdateTodoProps) => fetchUpdateTodo(updateProps),
    onSuccess: (updatedTodo, variables) => {
      console.log("react:updatedTodoは", updatedTodo)
      const previousTodos = queryClient.getQueryData<Todo[]>(todoKeys.all)

      if (!previousTodos) return

      // 良い: updatedTodoが必要なくなるし引数で渡ってくるvariablesは同期的なものなので確実
      const updatedTodos = previousTodos.map(todo => 
        todo.id === variables.todoId ? { ...todo, content: variables.newContent } : todo
      )

      // 微妙: updatedTodo(非同期だからデータの完全性/確実性が担保されない)を貰う必要がある
      const updatedTodos = previousTodos.map(todo => 
        todo.id === updatedTodo.id ? { ...todo, content: updateTodo.content } : todo
      )

      queryClient.setQueryData(todoKeys.all, updatedTodos)

      alert('更新成功')
    }
  })

  return mutate
}

このvariablesという引数は何かという話に関して。
使う側でmutate({ ... })として設定した引数がmutationFnの引数に渡ることは知ってたけど、onSuccessの第二引数(variables)にも渡るらしい

Yug (やぐ)Yug (やぐ)

最初の自分の「各todoのキャッシュ(キー)も作ってそれぞれ更新する感じでいけるのでは」という案に対しても一応claudeに聞いてみたけどこれはまた今度検証すれば良いかな

claude

あなたの考えは非常に的確です!TanStack Queryのキー設計についての素晴らしい思考プロセスですね。

ポイント別分析

1. キー設計の階層化

  • todoKeys.all (['todos']) だけでなく、個別のtodoに対して ['todos', todoId] のようなキーを設けるアプローチは理想的です
  • これはReact Queryの「キー階層」の概念に完全に合致しています

2. インサートと更新の処理

  • インサート時に両方のキャッシュを設定/更新する戦略は効率的です
  • 個別todoの更新時にそのtodoのキーだけをinvalidateする発想は正確で、順序問題を解決します

3. 補足と推奨点

  • キーの構造をよりシステマチックにするため、todoKeysオブジェクトを拡張するとさらに良いでしょう:
export const todoKeys = {
  all: ['todos'] as const,
  lists: () => [...todoKeys.all, 'list'] as const,
  detail: (id: number | string) => [...todoKeys.all, id] as const,
} as const

こうすれば:

  • 一覧の取得: useQuery({ queryKey: todoKeys.all, ... })
  • 特定のtodoの取得/更新: useQuery({ queryKey: todoKeys.detail(123), ... })

このようなキー設計は、アプリケーションが成長してtodoの詳細ページを追加する場合にもシームレスに対応できます。

あなたの思考プロセスは、React Queryの設計思想にぴったり合っています。最初はシンプルなキー設計からスタートし、必要に応じて階層化していくアプローチは理にかなっていますね。

多分todo詳細ページとか作りたいならこっちの方が拡張性高かったりとかで良さげなんだろうな

同じようにこれもinvalidateQueriesではなく(get/set)QueryDataでいけるはずだし

ただ今回は別に詳細ページ不要なのでいいや
(useUpdateTodo.tsを編集するだけで良いので楽だし)

Yug (やぐ)Yug (やぐ)

ということで更新後順序が変わってしまう問題も解決
https://github.com/clumsy-ug/hono-todoapp/tree/dc07ba1228c0e63ae05924d6413bb3116048912e

だがキャッシュを更新してるだけであってそれを画面に反映してるだけなので、データベースでは更新すると順序が変わっている。なので、ブラウザリロードすると順序は変わってしまう...

これはいつかやろう

Yug (やぐ)Yug (やぐ)

「更新後すぐ順序が変わってしまう」を、「更新後リロードしたら順序が変わってしまう」に変えただけで根本解決になっていないことに気付いたが、まぁ改善ではあるのでとりあえずok

このスクラップは4ヶ月前にクローズされました