LoginSignup
0
0

Next.js サーバーアクションを使ってみる【国交省 不動産情報ライブラリ】

Last updated at Posted at 2024-05-12

はじめに

今回、Next.jsのサーバーアクションを使ったサイトを制作しました。筆者は実際に使用するまで「クライアント側からサーバー側の関数を呼び出して実行できる」ということに「……ん???」といった感じで中々イメージできませんでした。
今回の記事を通して、筆者と似たような感覚の方々へのサーバーアクション理解の一助となれれば幸いです。

今回作ったものは「全国各地の不動産情報のデータを取得・閲覧できるサイト」になります。

  • 技術スタック(一部)
    • types/node@20.12.7
    • @types/react@18.2.79
    • next@14.2.3
    • react-dom@18.2.0
    • react@18.2.0
    • recharts@2.12.6
    • styled-components@6.1.8
    • typescript@5.4.5

タイトルにあるように『国土交通省』の【不動産情報ライブラリ】というwebサイトからAPIを発行してもらって全国各地の不動産情報のデータを取得しています。

実は以前Reactで同機能のサイトを作って記事にしていました。

ですので今回制作したものは、その時に作ったReactNext.jsへのリプレースとなります。

Next.jsサーバーアクションについて

Client Components can only import actions that use the module-level "use server" directive.
To call a Server Action in a Client Component, create a new file and add the "use server" directive at the top of it. All functions within the file will be marked as Server Actions that can be reused in both Client and Server Components:
クライアントコンポーネントは、モジュールレベルの 「use server」 ディレクティブを使用するアクションのみをインポートできます。
クライアントコンポーネントでサーバーアクションを呼び出すには、新規ファイルを作成し、先頭に 「use server」 ディレクティブを追加します。ファイル内のすべての関数は、クライアントコンポーネントとサーバーコンポーネントの両方で再利用できるサーバーアクションとしてマークされます:

冒頭で軽く触れましたが「クライアント側からサーバー側の関数を呼び出して実行する」ことができます。もちろん、それだけではありませんが本記事では上記の部分を中心に話を進めていきます。

関心のある方は先ほどの公式ドキュメントをはじめ、こちらの記事に分かりやすく詳細が書かれていたのでどうぞ。

追記情報

  • 2024/05/16

勘違いされやすいのがServer Actionsはサーバー側でActionを実行する機能のため、RSC内部ではもちろん、クライアント側のコンポーネントからもServer Actionsを実行することができます。
Sever Actionsで定義された処理は内部的に、$ACTION_ID_が付与された上でアクションリストに登録されて実行可能になるようになっています。そのためRSCからもCC(クライアントコンポーネント)からもServer Actionで定義された処理は一律にサーバー側の実行可能処理として扱われ、クライアント側からServerActionの実態は参照できない形になっています

クライアント側からサーバーアクションの実態は参照できないそうなので環境変数とか使って値を秘匿する必要もないかもですね(間違っていたらすみません)

  • 2024/05/20

    • サーバーアクションは初期レンダリングで使用できません。

    function is in a file with "use server" at the top, making it a Server Action (or Server Function, as its called in the error message).
    THAT is what cannot be called during initial render. Server Actions are intended to be called as part of user interactions. A user clicks a button, which calls a server action.
    Server actions are special, and result in a network roundtrip. THAT is why React will not let you call them during initial render.
    関数は、先頭に 「use server 」とあるファイル内にあり、Server Action(またはエラーメッセージにあるようにServer Function)となっています。
    サーバーアクションは、ユーザーとのインタラクションの一部として呼び出されることを意図しています。ユーザーがボタンをクリックすると、サーバーアクションが呼び出されます。
    サーバーアクションは特殊で、ネットワークのラウンドトリップが発生する。これが、Reactが初期レンダリング中にサーバーアクションを呼び出させない理由です。

    参照情報:React Server Component Error: Server Functions can not be called during initial render

    筆者の手元で実際に検証したところ、機能した(外部サイトのエンドポイントからfetchできた)ものの、サーバサイドのログ(ターミナル/コマンドプロンプト)にエラー(fetch waterfall~...)が表示されていたので実用は不可だと思います。

    • サーバーアクションを用いたコンポーネントは静的エクスポート(output: 'export')できない
      いやまぁそれはそうでしょ、という感じなのですが物は試しに精神でbuildしてみたところserver actions are not supported with static exportと。Next.jsは親切ですね。

ReactNext.jsの経緯

そもそも何故ReactNext.jsにリプレースしたのか説明すると、以前使っていたAPI(土地総合情報システム)が廃止(正確には【不動産情報ライブラリ】に統合)されて使えなくなったことがきっかけです。

当初、筆者は「ほなら新しいサイトのAPIの書き方に変えたらええだけか」程度に思っていたのですが、当該サイトの【不動産情報ライブラリ】ではAPIの申請が必要でした。
ちなみに、申請には理由が必要です。筆者は普通に「全国各地の不動産情報を閲覧できるサイト制作のため」としました。

今はどうか分かりませんが筆者が申請した時は混みあっていたようで3~5日かかるとされていました。実際は2日程度で取得できたと記憶しています。

とりあえず指示通りAPIの申請を行い無事に取得。

この時はリプレースなんて全く考えておらず、先に触れたようにAPIの書き換え程度で済むと思っていました。

実際に作業に取り掛かっていきまして、まずは【不動産情報ライブラリ】の「API操作説明」の記述に則ってheadersを設けた以下のようなデータフェッチに書き換えました。

useEffect(()=>{
    const fetchRealEstateData = async () => {
        const response = await fetch(`https://www.reinfolib.mlit.go.jp/ex-api/external/XIT002?area=${prefCode}`, {
            headers: {
                "Ocp-Apim-Subscription-Key": API_KEY,
            },
        });
        const resObj: FetchApiData = await response.json();
        setRealEstateData((_prevRealEstateData) => resObj);
    }
    fetchRealEstateData();
}, []);

そして画面を確認すると何も反映されておらず、ログを見るとCORSのエラーが出ていました。
「不動産情報ライブラリのあるサーバーで実行しているわけではないからかなぁ~」とか思いつつも、「自分の記述がどこか間違えているのかもしれない」と色々試したのですが(問題は当然そんなことではないので)CORSのエラーが出続け、生成AIに尋ねてみることに。

すると以下の回答を得ました。

サーバーサイドでのデータフェッチ: クライアント側で直接外部APIを呼び出すと、CORSの制約などに遭遇することがありますが、Next.jsのAPIルートを使用することで、サーバーサイドで外部APIを呼び出し、その結果をクライアントに返すことができます。

ここで(愚かにも)ようやくCORSを意識し、「え?Next.jsに作り替えなあかんの?めんどくさ」と思いながらも、せっかくAPIも申請したし勉強がてら作り替えようと思った次第です。

以下から具体的な制作の話に入っていきます。

クライアントコンポーネントではやはりCORS

Next.jsならもしかして」という謎の淡い期待から一先ずクライアントコンポーネントでデータフェッチしてみることにしました。

スクリーンショット 2024-05-11 101634.png

当たり前ですが、当たり前の結果になりましたね。
潔くサーバーサイドでごにょごにょするようにします。

なお、本記事で紹介したサイトでは【不動産情報ライブラリ】から取得した不動産情報を以下の方法で表示・閲覧できるようにしています。

  • ページ送りで一覧表示・閲覧
  • 地区や不動産種別のフィルター、取引価格でのソートによる一覧表示・閲覧
  • 指定した市区町村と年、期間に準拠した取引価格のリスト及びグラフ表示・閲覧

Reactverの時は上記機能の切り分けを基本的には、各種コンポーネントとデータフェッチ用の各種カスタムフックを用意して行っていました。
今回のNext.jsではファイルシステムベースルーティングなので素直にpager,filter,compareでディレクトリを分けて各ページを設けました。

on***イベントハンドラやeffect hookでサーバーアクションを実行

onSubmitonChangeonInputといったイベントハンドラ関数はクライアントサイドでしか実行できません。
当初、筆者は公式ドキュメントの以下の部分を見て不適切な書き方をしていました。

// Server Component
export default function Page() {
  // Server Action
  async function create() {
    'use server'
 
    // ここにデータフェッチの記述を書いて
  }
 
  return (
    // select の onChange イベントで上記 Server Action を実行する
  )
}

しかし筆者が試したこの不適切な方法ではonChangeイベントを機能させるためにuse clientの記述が必要となり、クライアントコンポーネントにすると同ファイルに書いたサーバーアクションを実行できない状況になりました。

筆者の知識不足や書き方がおかしかっただけで、もしかすると実行可能なのかもしれません。何かお気づきの方はコメント欄などでご指摘いただけますと嬉しいです。

そこでサーバーアクションをエクスポートする方法を採りました。

  • getSelectElValueCityCode.ts
    選択した都道府県の市区町村リストを取得
    スクリーンショット 2024-05-12 100934.png
// getSelectElValueCityCode.ts
"use server";

import { CityAry, FetchCityData } from "../ts/cityDataAryEls";

export async function get_SelectElValue_CityCode(prefCode: string): Promise<CityAry[] | undefined> {
    const API_KEY: string = process.env.REINFOLIB_API_KEY!;

    const response = await fetch(`https://www.reinfolib.mlit.go.jp/ex-api/external/XIT002?area=${prefCode}`, {
        headers: {
            "Ocp-Apim-Subscription-Key": API_KEY,
        },
    });

    const resObj: FetchCityData = await response.json();

    try {
        if (resObj.message) {
            throw new Error(`fetch failed or no Results:${resObj.message}`);
        }

        return resObj.data;
    } catch (error) {
        console.error('error occurred - get_SelectElValue_CityCode.ts', error);
    }
}
  • SelectPrefCities.tsx
    getSelectElValueCityCode.tsを実行するクライアントコンポーネント
/* SelectPrefCities.tsx はクライアントコンポーネントですが、親コンポーネントで "use client" を記述しているのでここでは明記しません */

import selectElsStyles from "../../styles/selectEls.module.css";
import { GetFetchEachCode } from "@/app/providers/filter/GetFetchEachCode";
import React, { useContext, useEffect, useState, ChangeEvent, memo } from 'react';
import { CityAry } from "@/app/ts/cityDataAryEls";
import { prefcodeData } from "@/app/components/layout/prefcodeData";

/* サーバーアクション */
import { get_SelectElValue_CityCode } from "../../server-action/getSelectElValueCityCode";

function SelectPrefCities() {
    const { isGetFetchPrefCode, setGetFetchPrefCode, setGetFetchCityCode } = useContext(GetFetchEachCode);

    const [cities, setCities] = useState<CityAry[]>([]);

    useEffect(() => {
        if (isGetFetchPrefCode) {
            const fetchCityCode = async () => {
                /* サーバーアクションの実行 */
                const resObjDataAry: CityAry[] | undefined = await get_SelectElValue_CityCode(isGetFetchPrefCode);
                if (typeof resObjDataAry !== "undefined") {
                    setGetFetchCityCode((_prevGetFetchCityCode) => resObjDataAry[0].id);
                    setCities((_prevCities) => resObjDataAry);
                }
            }
            fetchCityCode();
        }
    }, [isGetFetchPrefCode]);

    return (
        <div className={selectElsStyles.termEls}>
            <div id="prefListsWrapper">
                <select name="" id="prefLists" onChange={async (e: ChangeEvent<HTMLSelectElement>) => {
                    /* サーバーアクションの実行 */
                    const resObjDataAry: CityAry[] | undefined = await get_SelectElValue_CityCode(e.target.value);
                    setGetFetchPrefCode((_prevGetFetchPrefCode) => e.target.value);
                    if (typeof resObjDataAry !== "undefined") {
                        setCities((_prevCities) => resObjDataAry);
                    }
                }}>
                    {prefcodeData.map((data) => (
                        <option value={data.prefcode} key={data.prefcode}>{data.prefJaName}</option>
                    ))}
                </select>
            </div>
        </div>
    );
}

export default memo(SelectPrefCities);

このように記述することで無事にデータを取得・反映できるようになりました!

ここで一つ面倒だったのが都道府県リストの用意だったのですが、本筋から少し逸れるのでアコーディオンで省略しておきます。気になる方は是非開いてご覧ください。

都道府県の配列ファイルを ChatGPT に作ってもらう

サーバーアクションを実行するselect#prefListsでは、別途tsファイルとして用意した都道府県の配列を使って都道府県リストを生成しています。

import { prefcodeData } from "@/app/components/layout/prefcodeData";
..
.
.
{prefcodeData.map((data) => (
    <option value={data.prefcode} key={data.prefcode}>{data.prefJaName}</option>
))}
prefcodeData.ts
import { PrefCodeType } from "../../ts/prefcode";

export const prefcodeData: PrefCodeType[] = [
    {
        "prefcode": "01",
        "prefJaName": "北海道",
        "prefRomenName": "Hokkaido"
    },
    {
        "prefcode": "02",
        "prefJaName": "青森県",
        "prefRomenName": "Aomori Prefecture"
    },
    ...
    ..
    .

スクリーンショット 2024-05-12 100934.png

と言うのも、上のキャプチャ画像の通り、都道府県リストがないと市区町村リストも生成できないので用意は必須だったのです。
getSelectElValueCityCode.ts:選択した都道府県の市区町村リストを取得するサーバーアクションが実行できないため)

以前のReactverでは自身で都道府県の配列を用意し、ループ処理を通して都道府県リストを生成していたのですが、先の「API操作説明」で都道府県コードが用意されていたので改めてそちらに準拠することにしました。

いちいち手動で都道府県コードのファイルを作るのは手間だったのでchatGPTに頼んで作ってもらいました。本当に助かりました。

スクリーンショット 2024-05-12 102848.png

イベントハンドラの記述時の注意点やuseEffectでの実行方法も公式ドキュメントを参考にしました。

  • Event Handlers

  • useEffect

余談ですが、筆者は恥ずかしながら当初サーバーアクションを 公式ドキュメント通りの書き方にしておらず、うまく機能しません でした。公式ドキュメント通りに書くと無事に機能しましたのでやはりドキュメントをしっかり読むのは大切ですね。

'use server'

// 機能しなかった筆者の書き方「その1」
export const create = async () => {
  // ...
}

// 機能しなかった筆者の書き方「その2」
async function create () {
  // ...
}
export default create;

// 公式ドキュメントの書き方
export async function create() {
  // ...
}

ちなみに、サーバーアクション(getSelectElValueCityCode.ts)で投げた例外はサーバーサイドで受け取るためエラーはターミナルに表示されます。ここらへんはNext.jsのサーバーコンポーネントと同じ振る舞いですね。

先のサーバーアクションをエクスポートする方法で他のデータ取得機能(合計3つ)も実装していきました。

今回作成したサーバーアクションの書き方はほぼ同じなのですが、以下の「各年ごとの不動産の平均価格を取得」するgetPrefCompareYearData.tsだけ少し調整しました。

  • getPrefCompareYearData.ts
    各年ごとの不動産の平均価格を取得
    スクリーンショット 2024-05-12 104224.png
    ※グラフ表示はRechartsを使用しています。
// getPrefCompareYearData.ts
"use server";

import { EstateInfoJsonData } from "../ts/estateInfoJsonData";

export async function get_Pref_CompareYearData(
    year: string,
    prefCode: string,
    cityCode: string
): Promise<string[] | undefined> {
    const API_KEY: string = process.env.REINFOLIB_API_KEY!;

    const response = await fetch(`https://www.reinfolib.mlit.go.jp/ex-api/external/XIT001?year=${year}&area=${prefCode}&city=${cityCode}`, {
        headers: {
            "Ocp-Apim-Subscription-Key": API_KEY,
        },
    });

    const resObj: EstateInfoJsonData = await response.json();
    // この辺りまでは他のサーバーアクションとほぼ同じ記述

    try {
        if (resObj.message) {
            throw new Error(`fetch failed or no Results:${resObj.message}`);
        }

        /* 不動産取引価格のみ抽出(サーバーアクション内でフェッチしたデータを加工して返却)*/
        const tradePrice: string[] = resObj.data.map(resElm => resElm.TradePrice);
        return tradePrice;
    } catch (error) {
        console.error('error occurred - get_Pref_CompareYearData.ts', error);
    }
}

サーバーアクションを使ってみて好感を持てたのは、上記のようにサーバーアクション内でフェッチしたデータをそこで調整してクライアント側に返せるところです。
わざわざクライアント側で受け取ったデータを加工する必要がない(=受け取るだけで済む)のは便利だなと感じます。

以下はデータフェッチの実行と、それを受け取るクライアントコンポーネント(AppStartBtn.tsx)になります。

  • AppStartBtn.tsx
    getPrefCompareYearData.tsを実行するクライアントコンポーネント
/* 祖先コンポーネントでクライアントコンポーネントの宣言済みなので "use client" は明記していません */

import { memo, useContext } from "react";
import { CompareSortGraphAction } from "@/app/providers/compare/CompareSortGraphAction";
import { GetFetchEachCode } from "@/app/providers/filter/GetFetchEachCode";
import { useCalcAverageFee } from "@/app/hooks/useCalcAverageFee";
import { get_Pref_CompareYearData } from "@/app/server-action/getPrefCompareYearData";

type AppStartBtnType = {
    isAppStartBtn: boolean;
    termLists_from: number;
    termLists_to: number;
    isViewChart: boolean;
    setViewChart: React.Dispatch<React.SetStateAction<boolean>>;
}

function AppStartBtn({ props }: { props: AppStartBtnType }) {
    const { isAppStartBtn, termLists_from, termLists_to, isViewChart, setViewChart } = props;
    // ...中略

    /* サーバーアクション */
    const async_serverAction_getPrefCompareYearData: () => Promise<void> = async () => {
        let yearCountUp_untill_termLists_to: number = termLists_from;
        while (yearCountUp_untill_termLists_to <= termLists_to) {
            const tradePrice: string[] | undefined = await get_Pref_CompareYearData(yearCountUp_untill_termLists_to.toString(), isGetFetchPrefCode, isGetFetchCityCode);
            if (typeof tradePrice !== "undefined") {
                // フェッチしたデータをグラフ表示
                _viewGetFetchData(tradePrice, yearCountUp_untill_termLists_to);
            } else {
                alert('今回選択した項目・条件のデータは存在しません');
                location.reload();
                break;
            }
            yearCountUp_untill_termLists_to++;
        }
    }

    // ...中略

    return (
        <button type="button" className="appStartBtn" disabled={isAppStartBtn} onClick={async () => {
            /* サーバーアクションの実行 */
            async_serverAction_getPrefCompareYearData();
            appStart();
        }}>計測スタート</button>
    );
}

export default memo(AppStartBtn);

残りの一つは、表示用の不動産情報データを取得するサーバーアクションです。

  • getPrefCityYearTermTargetValueData.ts
    選択した年・期間(4半期別)に紐づいた市区町村の不動産情報データを取得
    スクリーンショット 2024-05-12 104630.png

    スクリーンショット 2024-05-12 104748.png

// getPrefCityYearTermTargetValueData.ts
"use server";

import { EstateInfoJsonData, EstateInfoJsonDataContents } from "../ts/estateInfoJsonData";

export async function get_PrefCityYearTerm_TargetValueData(cityCode: string, year: string, term: string): Promise<EstateInfoJsonDataContents[] | undefined> {
    const API_KEY: string = process.env.REINFOLIB_API_KEY!;

    // 2. 「取引時期Year」&「取引時期Quarter」&「市区町村コード」&「不動産取引価格情報」
    const response = await fetch(`https://www.reinfolib.mlit.go.jp/ex-api/external/XIT001?year=${year}&quarter=${term}&city=${cityCode}&priceClassification=01`, {
        headers: {
            "Ocp-Apim-Subscription-Key": API_KEY,
        },
    });

    const resObj: EstateInfoJsonData = await response.json();

    try {
        if (resObj.message) {
            if (resObj.message.insufficient) {
                throw new Error(`fetch failed or no Results:${resObj.message.insufficient}`);
            } else {
                throw new Error(`fetch failed or no Results:${resObj.message}`);
            }
        }

        return resObj.data;
    } catch (error) {
        console.error('error occurred - get_PrefCityYearTerm_TargetValueData.ts', error);
    }
}

これを実行するクライアントコンポーネントが以下のSelectEls.tsxになります。formonSubmitでサーバーアクションを行っています。

  • SelectEls.tsx
    getPrefCityYearTermTargetValueData.tsを実行するクライアントコンポーネント
"use client"

import selectElsStyles from "../../styles/selectEls.module.css";
import { SyntheticEvent, memo, useContext } from "react";
import { EstateInfoJsonDataContents } from "@/app/ts/estateInfoJsonData";
import { GetFetchEachCode } from "@/app/providers/filter/GetFetchEachCode";
import { GetFetchDataContext } from "@/app/providers/filter/GetFetchData";
import SelectPrefCities from "./SelectPrefCities";
import SelectTerm from "./SelectTerm";
import { get_PrefCityYearTerm_TargetValueData } from "@/app/server-action/getPrefCityYearTermTargetValueData";

function SelectEls({ isActionable }: { isActionable?: boolean }) {
    const { isGetFetchCityCode, isGetFetchYearValue, isGetFetchQuarterValue } = useContext(GetFetchEachCode);

    const { isGetFetchData, setGetFetchData, setPagers, setCurrPager } = useContext(GetFetchDataContext);
    
    const resetPager: () => void = () => {
        setCurrPager(1);
        setPagers(0);
    }

    return (
        <form action="" className={selectElsStyles.SelectElsWrapper} onSubmit={async (e: SyntheticEvent<HTMLFormElement>) => {
            e.preventDefault();
            /* サーバーアクションの実行 */
            const resObjDataAry: EstateInfoJsonDataContents[] | undefined = await get_PrefCityYearTerm_TargetValueData(isGetFetchCityCode, isGetFetchYearValue, isGetFetchQuarterValue);
            if (typeof resObjDataAry === "undefined") {
                alert('今回選択した項目・条件のデータは存在しません');
                location.reload();
                return;
            }
            if (isGetFetchData.length > 0) resetPager();
            setGetFetchData((_prevGetFetchData) => resObjDataAry);
        }}>
            <SelectPrefCities />
            <SelectTerm props={{
                SelectTermClassName: selectElsStyles.YearsQuarterLists_From,
                explainSentence: '期間'
            }} />
            <p className={selectElsStyles.termCaption}><small> 1:132:463:7104:1112</small></p>
            {isActionable && <button type="submit">run</button>}
        </form>
    );
}

export default memo(SelectEls);

Vercelへデプロイ

今回Next.jsで制作しましたし、他の個人開発でも使用経験があるので迷いなくVercelをホスティング先に選び、特に問題もなくデプロイできました。
しかし盲目的にVercelダッシュボードのプロジェクト設定で環境変数にAPIキーを設定しましたが、今回サーバーアクションでしかそれを使っていないので「そもそも設定する必要なかったのでは?」と思っています。(実際、むちゃくちゃなAPIキーに編集しても機能していました)

一応、自身の備忘録も兼ねてNext.jsの環境変数についての参考記事を残しておきます。

さいごに

途中に書きましたが、サーバーアクション内でフェッチしたデータをそこで調整してクライアント側に返せるので、わざわざクライアント側で受け取ったデータを加工する必要がない(=受け取るだけで済む)のはサーバーアクションの利点だと感じました。

特にサーバー側でデータフェッチから加工まで行えるとパフォーマンス面でも大きなメリットがあるかと思います(※取得するデータ量や内容、サイズにもよりますが)

とはいえ、ここまで書いてきて「何もサーバーアクションでなくともapp/apiで独自APIを設けてデータフェッチする方法でもいけたのでは?」と思いました。
しかし今回、サーバーアクションに初めて触れたので良い機会だったと前向きに捉えます。
まだまだ触りたてなので今後も機会を見て実装し、練度を高めていきたいと考えています。

あと、今回Stateの管理にContextを使っていますが、jotaiなど状態管理ライブラリでも問題ないかと思います。筆者は普段jotaiを使うことが多いのですが、以前作ったReactverでContextを使っていたので今回はそれを引き継ぎました。

本記事で紹介したサイトのGitHubを置いておきますので、関心のある方はご自由にお使いください。
※APIはご自身で取得をお願い致します。

参考情報

  • Event Handlers

  • useEffect

  • client Components

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0