Next.js(App Router)でuseStateを使わずに変数をグローバルに保持したい&エラーハンドリングもしたい
Next.js使い始めたばかりなので、変な箇所あればぜひコメントいただけると幸いです。
TypeScript, Next.js(App Router)で開発していたときに苦戦した内容(タイトルの通り)とその解決方法についてです。
(バックエンドではSupabaseを使用しています)
ただのReactだったら普通にuseState使えるんですが、Next.jsだとそうもいかず、少々苦戦しました。
一応先に結論を言うと、「useState使わないで普通にlet 〇〇 = 〇〇というグローバル変数作ったら解決した」ということになります。
前提
Next.js(App Router)では、useStateやuseEffectなどのclient側で実行される必要がある処理を使いたい場合、それを使用するファイルの1行目に"use client"と書き、「このファイルはserver側ではなくclient側で実行されるものである」ということを宣言しなければいけません。
(とはいえserver側で絶対に実行されないという訳でもないらしい?)↓
また、以下ドキュメントにあるように、「"use client"を定義することで、子コンポーネントを含む、そのファイルにインポートされた他のすべてのモジュールはクライアントバンドルの一部とみなされ」ます。つまり、そのファイルの内容だけでなくそのファイルでインポートしたものもクライアントコンポーネントとみなされます。↓
(ちなみに、何も書かない状態、つまりデフォルトでは"use server"の状態になっています)
苦戦した内容
という前提のもとで今回苦戦したのが、「あるファイル内で、useStateかuseEffectを使いたい(=client側で実行したい)けど、server側で実行するものもimportしたい」という状況に陥ったことです。
それぞれを分けて説明します。
問題のファイルは以下です。↓
import { createClient } from "@/supabase/server";
import SignOutButton from "@/components/SignOutButton";
export default async function Home() {
const supabase = createClient();
const { data: { session } } = await supabase.auth.getSession();
return (
<div>
<h1>Welcome to the home page</h1>
{session ? (
<>
<p>You are logged in as <b>{session.user.email}</b></p>
<SignOutButton />
</>
) : (
<p>You are not logged in</p>
)}
</div>
);
}
client側
まず、「useStateかuseEffectを使いたい(=client側で実行したい)」の部分から説明します。
awaitをしている行でエラーハンドリングがなされていないという問題があります。そのため、try/catchでまとめようと思い、
const { data: { session } } = await supabase.auth.getSession();
を、
try {
const { data: { session } } = await supabase.auth.getSession();
} catch (err) {
console.error(err);
}
とすることにしました。
しかし、これだとsessionがtryの中に入ってしまい、外部からsessionを使えなくなってしまいました。sessionはグローバル変数でないと、画面描画のjsx内でsessionの値を参照できずに困ります。
そこで、「別でsessionを保存する変数をuseStateで持っておいて、それをtry内で更新すれば良いのではないか」と考えました。(結果ダメだったけど)
つまり、こういうのを追加します。
(useStateの方も変数名をsessionにすると、sessionが2つできてややこしいので、sessionStateという名前にしました。よくこういうケースが起こるんだけど、こういう場合って命名規則とかあるのかな?)
import { useState } from "react";
...
const [sessionState, setSessionState] = useState(null);
今回はバックエンド側でSupabase(DB/認証機能)を使用しているので、Supabaseで用意されているSessionという型を使用すると、
import { Session } from "@supabase/supabase-js";
...
const [sessionState, setSessionState] = useState<Session | null>(null);
という感じになります。
そして、以下のようにtry/catchを使ってエラーハンドリングもしつつ、sessionをグローバルに保持しよう、そうすれば解決だ!と考えていました。(結果ダメだったけど)
try {
const { data: { session } } = await supabase.auth.getSession();
if (session) {
setSessionState(session);
}
} catch (err) {
console.error(err)
}
完成後は以下になります。↓
import { createClient } from "@/supabase/server";
import SignOutButton from "@/components/SignOutButton";
import { useState } from "react";
import { Session } from "@supabase/supabase-js";
export default async function Home() {
const [sessionState, setSessionState] = useState<Session | null>(null);
const supabase = createClient();
try {
const { data: { session } } = await supabase.auth.getSession();
if (session) {
setSessionState(session);
}
} catch (err) {
console.error(err)
}
return (
<div>
<h1>Welcome to the home page</h1>
{sessionState ? (
<>
<p>You are logged in as <b>{sessionState.user.email}</b></p>
<SignOutButton />
</>
) : (
<p>You are not logged in</p>
)}
</div>
);
}
さあ、これを実行するとどうなるかというと...
と言う感じで、当然エラーが出ますね。「useState使ってるんだからちゃんと"use client"って書け!」ということになります。
ということで、ファイルの先頭に
"use client"
を追加し、再度実行してみると...
という感じで今度は違うエラーが出ています。
ここからが「server側で実行するものをimportしたい」という問題の箇所になります。
server側
「server側で実行するものをimportしたい」の部分を説明します。
これは最初に説明した「"use client"を定義することで、そのファイルの内容だけでなくそのファイルでインポートしたものもクライアントコンポーネントとみなされます」という部分に起因したエラーです。
つまり、"use client"を書いて自分がclient側だと宣言しているのに、server側で実行するものをimportしてしまっているためエラーになっています。
該当箇所はこのcreateClientですね。
import { createClient } from "@/supabase/server";
このcreateClientのファイルに移動してみると、中身はこうなっています
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { cookies } from "next/headers";
export function createClient() {
const cookieStore = cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// The `setAll` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
},
}
);
}
先程のエラーメッセージを見ればわかるように、このnext/headersがserver側でしか実行できないものなので、それをimportしてしまっているのが原因のようです。↓
import { cookies } from "next/headers";
さて、どうしましょう。
このserver側からcookiesをimportする処理は重要なので、当然消すなんてことはできません。
であれば、server側としてsrc/app/page.tsxを実行しなければいけない。
けど、src/app/page.tsx内ではclient側の処理であるuseStateを使わないとグローバルに保持できない(と思い込んでいた)。
...どうする?
ということで、タイトルにある通り、「useStateを使わずに変数をグローバルに保持したい」わけです。useStateをどう上手く使うか、とかではなく、そもそもuseStateを使わずに何とかなる方法を探すしかない、ということです。
整理すると、
- session変数がグローバルに保持されている
- エラーハンドリングができている
という2つの条件が満たされた状態を、useState, useEffectなどを使わずに実現する必要があります。
解決
結論、グローバルでlet 〇〇 = 〇〇というように単純なJavaScriptの変数を作り、それを更新すれば解決しました。
完成系は以下です。
import SignOutButton from "@/components/SignOutButton";
import { createClient } from "@/supabase/server";
import { Session } from "@supabase/supabase-js";
const Home = async () => {
const supabase = createClient();
let globalSession: Session | null = null;
try {
const { data: { session } } = await supabase.auth.getSession();
if (session) globalSession = session;
} catch (e) {
console.error('Home内のエラー->', e);
}
return (
<div>
<h1>Welcome to the home page</h1>
{globalSession ? (
<>
<p>
You are logged in as <b>{globalSession.user.email}</b>
</p>
<SignOutButton />
</>
) : (
<p>You are not logged in</p>
)}
</div>
);
};
export default Home;
こうすればglobalSessionとしてsessionがグローバルに保持できて、try/catchによるエラーハンドリングも書けて、tryの中でsessionがtrueかどうかまで判定できています。
よって、2つの条件を満たすことができました。
他にも、
「try/catchではなくthen/catchを使ったら何とかなるかなぁ」
とか
「そもそもserver側の処理ではなくclient側の処理を優先して、そこにserver側を迎合させることが必要なのか?つまりcookiesをnext/headers以外のclient側のどこかから上手くimportできないか?」
とか考えましたが、解決できてよかったです。
ということで、多分この手法であれば結局try/catchだろうがthen/catchだろうが関係なく解決できましたね。
余談
ちなみにserver側で実行されているのでconsole.logの出力結果などはブラウザの検証ツールでは確認できません。npm run devを走らせている開発用サーバのターミナルのログを確認すれば、そこでconsole.logの出力結果が確認できます。例えばこんな感じ。
try {
console.log('hello'); // これはターミナルにしか出力されない
const { data: { session } } = await supabase.auth.getSession();
if (session) globalSession = session;
} catch (e) {
console.error('Home内のエラー->', e);
}
ブラウザの検証ツールにhelloは出力されず、ターミナルに出力されます。"use client"を書いていないこのコンポーネントはserver側で実行されているからですね。勿論、try内ではなくグローバルにconsole.logを書いても同じようにターミナルに結果が出力されます。
これを知らなかったせいで、実行したい処理は以下のようにuseEffectで囲まないと実行されないのか?とかよくわからない予想・試行錯誤をしてしまいました。(この予想も今考えると間違っている)
useEffect(() => {
try {
console.log('hello');
const { data: { session } } = await supabase.auth.getSession();
if (session) globalSession = session;
} catch (e) {
console.error('Home内のエラー->', e);
}
}, []);
try内でconsole.logは実行されていない
...のに、
値は更新されている!
なんで?
try内が実行されているのかされていないのかどっちや!多分されてるんだろうけど!
...みたいに無駄に混乱しました。server側でconsole.logが実行されているという視点を得られて良かったです。
Discussion
記事内部で言及されていることも含めて、いくつか補足させていただきます。
何かご参考になれば幸いです。
このあたりは、公式ドキュメントにある程度わかりやすくまとめられているので、ぜひ目を通してみてください!
コメントありがとうございます!
自分がReactの基礎全然わかっていなかったことに気づけました。とても助かります。
「useState, useEffectを使うべき場所/使わない場合はその代替案」が具体的にイメージできていないので、今後実際に開発しながらイメージを深めていこうと思います。
少しでもお助けになれたなら幸いです!!