🌏

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はグローバル変数でないと、return内で参照できずに困ります。

そこで、「別で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側からcookieをimportしてきているのは重要なので、当然消すなんてことはできません。

であれば、server側として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 {
    const { data: { session } } = await supabase.auth.getSession();
    if (session) globalSession = session;
} catch (e) {
    console.error('Home内のエラー->', e);
}

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);
}

とやってconsole.logを追加した場合、ブラウザの検証ツールに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

YugYug

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

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