🌏

Next.js(App Router)でuseStateを使わずに変数をグローバルに保持したい&エラーハンドリングもしたい

2024/09/16に公開
3

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側で絶対に実行されないという訳でもないらしい?)↓
https://qiita.com/martini3oz/items/7c4e421e8f5174642d5f

また、以下ドキュメントにあるように、「"use client"を定義することで、子コンポーネントを含む、そのファイルにインポートされた他のすべてのモジュールはクライアントバンドルの一部とみなされ」ます。つまり、そのファイルの内容だけでなくそのファイルでインポートしたものもクライアントコンポーネントとみなされます。↓
https://ja.next-community-docs.dev/docs/app-router/building-your-application/rendering/client-components

(ちなみに、何も書かない状態、つまりデフォルトでは"use server"の状態になっています)

苦戦した内容

という前提のもとで今回苦戦したのが、「あるファイル内で、useStateかuseEffectを使いたい(=client側で実行したい)けど、server側で実行するものもimportしたい」という状況に陥ったことです。
それぞれを分けて説明します。

問題のファイルは以下です。↓

src/app/page.tsx
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でまとめようと思い、

src/app/page.tsx
const { data: { session } } = await supabase.auth.getSession();

を、

src/app/page.tsx
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という名前にしました。よくこういうケースが起こるんだけど、こういう場合って命名規則とかあるのかな?)

src/app/page.tsx
import { useState } from "react";

...

const [sessionState, setSessionState] = useState(null);

今回はバックエンド側でSupabase(DB/認証機能)を使用しているので、Supabaseで用意されているSessionという型を使用すると、

src/app/page.tsx
import { Session } from "@supabase/supabase-js";

...

const [sessionState, setSessionState] = useState<Session | null>(null);

という感じになります。

そして、以下のようにtry/catchを使ってエラーハンドリングもしつつ、sessionをグローバルに保持しよう、そうすれば解決だ!と考えていました。(結果ダメだったけど)

src/app/page.tsx
try {
    const { data: { session } } = await supabase.auth.getSession();
    if (session) {
        setSessionState(session);
    }
} catch (err) {
    console.error(err)
}

完成後は以下になります。↓

src/app/page.tsx
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"って書け!」ということになります。

ということで、ファイルの先頭に

src/app/page.tsx
"use client"

を追加し、再度実行してみると...

という感じで今度は違うエラーが出ています。

ここからが「server側で実行するものをimportしたい」という問題の箇所になります。

server側

server側で実行するものをimportしたい」の部分を説明します。

これは最初に説明した「"use client"を定義することで、そのファイルの内容だけでなくそのファイルでインポートしたものもクライアントコンポーネントとみなされます」という部分に起因したエラーです。

つまり、"use client"を書いて自分がclient側だと宣言しているのに、server側で実行するものをimportしてしまっているためエラーになっています。

該当箇所はこのcreateClientですね。

src/app/page.tsx
import { createClient } from "@/supabase/server";

このcreateClientのファイルに移動してみると、中身はこうなっています

src/supabase/server.ts
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してしまっているのが原因のようです。↓

src/supabase/server.ts
import { cookies } from "next/headers";

さて、どうしましょう。
このserver側からcookiesをimportする処理は重要なので、当然消すなんてことはできません。

であれば、server側としてsrc/app/page.tsxを実行しなければいけない。

けど、src/app/page.tsx内ではclient側の処理であるuseStateを使わないとグローバルに保持できない(と思い込んでいた)。

...どうする?

ということで、タイトルにある通り、「useStateを使わずに変数をグローバルに保持したい」わけです。useStateをどう上手く使うか、とかではなく、そもそもuseStateを使わずに何とかなる方法を探すしかない、ということです。

整理すると、

  1. session変数がグローバルに保持されている
  2. エラーハンドリングができている

という2つの条件が満たされた状態を、useState, useEffectなどを使わずに実現する必要があります。

解決

結論、グローバルでlet 〇〇 = 〇〇というように単純なJavaScriptの変数を作り、それを更新すれば解決しました。

完成系は以下です。

src/app/page.tsx
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の出力結果が確認できます。例えばこんな感じ。

src/app/page.tsx
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で囲まないと実行されないのか?とかよくわからない予想・試行錯誤をしてしまいました。(この予想も今考えると間違っている)

src/app/page.tsx
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

Honey32Honey32

記事内部で言及されていることも含めて、いくつか補足させていただきます。

何かご参考になれば幸いです。

  • 前提: Client Component / Server Component の違いは、ライフサイクルの有無です。
    • Server Component としてレンダリングされたコンポーネントは、ライフサイクルを持たない(一度レンダリング結果を client に渡して終了)のに対して
    • Client Component には、「初めてレンダリングされる(マウント)→ ステートが変わる → レンダリングされなくなる(アンマウント)」というライフサイクルを持っています。
  • useEffect は「変わったことをとにかく突っ込む場所」ではなく、具体的に「Client Component のライフサイクルのうち、マウント・ステート変更に関係するものを置く場所」です。
  • useState は、単に「何かを一時的に置く場所」ではありません。
    • 今回のように、Server Component の中で普通の let, const 変数を使ったり、
    • Client Component でも ref とか、関数内の単純な let, const 変数を使うことになります。
    • YouTube の動画になりますが、参考: https://youtu.be/vqHpjcPaDZ8?si=Od2OYfPBu5vq43ed

このあたりは、公式ドキュメントにある程度わかりやすくまとめられているので、ぜひ目を通してみてください!

https://ja.react.dev/learn/you-might-not-need-an-effect#how-to-remove-unnecessary-effects

Yug (やぐ)Yug (やぐ)

コメントありがとうございます!
自分がReactの基礎全然わかっていなかったことに気づけました。とても助かります。

「useState, useEffectを使うべき場所/使わない場合はその代替案」が具体的にイメージできていないので、今後実際に開発しながらイメージを深めていこうと思います。