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はグローバル変数でないと、return内で参照できずに困ります。
そこで、「別で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側からcookieをimportしてきているのは重要なので、当然消すなんてことはできません。
であれば、server側として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 {
const { data: { session } } = await supabase.auth.getSession();
if (session) globalSession = session;
} catch (e) {
console.error('Home内のエラー->', e);
}
を
try {
console.log('hello');
const { data: { session } } = await supabase.auth.getSession();
if (session) globalSession = session;
} catch (e) {
console.error('Home内のエラー->', e);
}
とやってconsole.logを追加した場合、ブラウザの検証ツールに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を使うべき場所/使わない場合はその代替案」が具体的にイメージできていないので、今後実際に開発しながらイメージを深めていこうと思います。
少しでもお助けになれたなら幸いです!!