4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

記事投稿キャンペーン 「2024年!初アウトプットをしよう」

サーバーアクションを使ったフォームを共通化する by タグ付きユニオン / usePathName

Last updated at Posted at 2024-01-20

1. はじめに

Next.js v14 にて新しい機能として、サーバーアクションがサポートされました。
これは、サーバー側で実行する処理をフロント側で呼び出すことができる機能であり、煩雑だったデータベース連携をシンプルにしたり、別ファイルにサーバーアクションを定義しておくことでサーバー側のロジックを完全に切り離したりすることができるという点で、非常に画期的であります。

この「クライアントとサーバーの関心を切り離す」という恩恵を確保しつつ、例えばsignInフォームやsignUpフォームを一つのフォームコンポーネントで対応するように拡張したいという時、まだあまり情報が出ていないようでした。

今回は試行錯誤の末、そのような場面においてタグ付きユニオンや、pathの取得による条件制御が活かせるらしいことを見つけたので、忘備録も兼ねて記事にしたためました。


※主なライブラリーのバージョン一覧

{
    "next": "14.0.4",
    "react": "^18",
    "react-dom": "^18",
    "zod": "^3.22.4"
}

目次

2. Server Actionsの概要

まず、なぜReact Server ComponentやServer Actionsが求められるようになったかに至るまでの背景を、三つの時代区分に分けて説明した後に、実装例を示していきます。
Perter Ekene Eze 氏やuhyo氏のブログ、そしてReact/Next公式ドキュメントは大変参考になりました。参考文献の蘭にリンクを張っているので、気になったらご一読ください。

2-1. RSC & Server Actions以前

SSR(Server Side Rendering)の時代

多くのサイトは、phpなどのサーバーサイド言語を利用して作られていました。例えばユーザーがあるページにアクセスしようとすれば、クライアントはサーバーにリクエストを発行し、サーバー側で必要な処理を行ってからhtmlをフロントに返却することで、動的なWebサイトを実現してきました。

しかし、ここで問題が発生します。ページ遷移のたびにクライアントとサーバーがデータのやり取りをするような状態("round trip")は処理に時間がかかるため、ページの表示がどうしても遅くなってしまうのです。

ページの制御をなんとかフロントで行いたい。
ここで登場したのがCSRでした。


CSR(Client Side Rendering)の時代

JavaScript(node.js)ライブラリーは、サーバーから軽いhtmlと大きなレンダリング用のJavaScriptコードをクライアントに送ることで、クライアント側でページ遷移を表現しようとしました。これがいわゆるSPAの概念です。

即ちSPAとは、アプリを一つのページ上で表示し、ページ遷移のリクエストに対しては最初のページのDOMを書き換えて必要な情報のみをサーバーから非同期で取得することで、ページ自体は変わっていない(別のHTMLファイルをサーバーから取得したわけではない)のにもかかわらず、あたかもほんとうにページを遷移したように見せかける技術のこと、といえると思います。

UXの向上に成功したようにみえましたが、今度は別の問題が発生しました。
それが、バンドルサイズの問題です。


RSC(React Server Comopnent)の登場
先述した通り、Reactはクライアントにバンドルサイズの大きいJavaScriptコードを送りクライアント側でレンダリングの処理を実行するため、過剰な負荷がクライアントにかかっていたのです。クライアントで実行する必要のないコードまでもがクライアントに送られていたのも、クライアントの処理を重くした原因です。

RSCの導入によって、この問題は大幅に緩和しました。サーバー側で処理を実行し、且つ大きな依存関係はサーバーに残したままUXに関わるコードだけクライアントにデータを送ることのできるためです。
サーバーにできることはサーバーで。クライアントとサーバーの関心を分離し適切に処理を分担することで、パフォーマンスの向上を見込むことができます。

他にも、よりサーバーに近いところでデータを取得することができるので速度と安全性が向上したり、内部でデータをフェッチするサーバーコンポーネントをストリーミングで取得できたりする利点も生まれました。

2-2. 二種類のServer Actions

RSCの時代に入って、Reactはサーバー側にもある程度関心領域を広げることになりました。そこでReactは、クライアント側に呼ばれるけれどもサーバー側で処理が実行されるような特殊な関数、「Server Actions」を実装しました。
Next.jsにおけるServer ActionsはReactのそれを拡張し、より使いやすくしています。

例えば、React.jsにおけるServer Actionsでは返却値をキャッシュする手段を持ちませんが、Next.jsではServer Actionsをcacheとrevalidateのアーキテクチャーと統合することで、値をキャッシュし、管理をすることができます。

またReactでは、ステートを更新しつつUIをブロックしないために、Server Actionsをtransitionのなかで呼び出すことが推奨され、単なるデータフェッチには向かないとされています。
※なおform actionは自動的にtransitionの中で呼び出されており、この条件は満たしています。
一方でNext.jsでは、たしかに単なるdata fetchならばサーバーコンポーネント内で実行することが推奨されているものの、文字通りアプリの「どこからでも」呼び出されるため、かなり使用の制約は緩いと言えます。

かなり利便性が高くなったNext.jsのServer Actionsですが、基盤がReactなので共通して注意するべきところもあります。例えばそれは、両者ともにServer Actionsの返り値がシリアライズ可能な値1でなければならないという制約です。

2-3. Server Actionsを用いた本格的な実装例

以下に、Server Actionsとそれを用いた典型的なフォーム用のコンポーネントの例をお示しします。

データの流れとしては、次のようになります。

フォームの入力 -> Server Actionsによる値の検証と他サーバーへの送信 -> サーバーで受け取った値をデータベースに追加

まず、下準備としてapi層を準備します。
今回はapiをNext.jsで実装していますが、これは例えばPythonのFastAPIなど、任意のapiサーバーに見立ててください。

api/comment/routes.tsx
import { NextResponse } from "next/server";
import { sql } from '@vercel/postgres';

export async function POST(req: Request, res: NextResponse) {
  const {comment} = await req.json();
  
   await sql`
    INSERT INTO comments
    VALUES (${comment})
  `;
  
  return NextResponse.json({message:"verify"},{status:200});
}

次に、簡単なフォームUIを作っていきます。

CommentList.tsx
"use client";

import { addComment } from "@/app/lib/Actions";
import { useFormState } from "react-dom";

export default function CommentList() {
  const initialState = { error: "" };
  const [state, dispatch] = useFormState(addComment, initialState);

  return (
    <>
      <h2>Server Actions</h2>
      <form action={dispatch}>
        <div name="comment-form" aria-describedby="comment-error">
          <div>
            <label htmlFor="comment">comment</label>
          </div>
          <div>
            <input id="comment" name="comment" type="text" required />
          </div>
          <div>
            <button type="submit"> Add Comment</button>
          </div>
        </div>
        {state.error && (
          <div name="display-error" id="comment-error" aria-live="polite" aria-atomic="true">
            <p>{state.error}</p>
          </div>
        )}
      </form>
    </>
  );
}

ここでは、ReactのuseFormStateというHooksを使っています。これは、formのactionに渡した関数から返されるstate(ここではエラー用のstate)に基づいて、状態を更新できるものです。
第一引数には非同期の関数を、第二引数にはstateの初期状態を渡します。第一引数には別ファイルで定義したServer Actions関数(addComment)を渡しています。ここで、addComment関数を見てみましょう。

Actions.tsx
"use server";

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";

type FormState = {
  error: string;
};

export async function addComment(
  formState: FormState,
  formData: FormData,
): Promise<FormState> {
  const safeparsedComment = z
    .object({comment: z.string().min(5, { message: "please enter at least 5 characters." })})
    .safeParse({ comment: formData.get("comment") });
  if (!safeparsedComment.success) {
    return { error: safeparsedComment.error.issues[0].message };
  }
  const { comment } = safeparsedComment.data;
  try {
    const res = await fetch("https://localhost:3000/api/comment", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ comment }),
    });
    if (res.status !== 200) throw new Error("Failed to post a comment");
  } catch (error) {
    return error instanceof Error
      ? { error: error.message }
      : { error: "Something went wrong." };
  }
  revalidatePath("/");
  redirect("/");
}

ここでは、zodライブラリーを使って入力値の検証を行っています。入力値の制約をzod オブジェクトで定義し、safeParseで入力値の検証を行います。入力値の検証を行うものはparseもありますが、safeParseがsuccessプロパティの真偽地で検証結果を返却するのに対して、parseはエラーを吐き出します。

このように、Server Actionsはサーバーで非同期に実行することができ、返り値をクライアントコンポーネントに渡すことができます。

"use server"宣言をファイルの最初に書いたり、非同期関数の最初に書いたりするだけでサーバー側の処理を実装できるので分かりにくいですが、本当のところはPOSTメソッドを用いてサーバーにデータを送って処理しているようです。エンドポイントいらずでサーバーにデータを送り、サーバー側であらかじめ定義されていた関数を実行してくれるイメージでしょうか。
「サーバーで実行している」というだけあって、もしVercelでPostgreSQLと連携しているのであれば、Server Actions内でsql文を直書きすることもできるようです。現に公式チュートリアルでは"use server"宣言を行ったファイルの中でsqlの操作を行っていました。セキュリティが心配であれば、別途データを保護する仕組みがあるようなので、それを実装するとよいでしょう。

3. タグ付きユニオンで共通化する

さて、Server Actionを用いた共通Formの実装に当たっては、ひとつ、難しい壁が存在します。
それは、処理がフロントだけで完結しないため、フォームや受け取ったデータ処理の仕組みを共通化することが難しいのです。
できることならば、以下のように、フォームUIをページの中で呼び出すだけで其々に対応したUIが表示されるようにしたくなります。

// path:/auth/signUp
export default async function SignUp(){
    return(
        <Form differentiate={something...} />
    );
}

// path:/auth/signIn
export default async function SignIn(){
    return(
        <Form differentiate={something...} />
    );
}

そんな贅沢な悩みを解決してくれる機能の一つが、タグ付きユニオンになります。

3-1. タグ付きユニオンとは

TypeScriptにおいて、タグ付きユニオンは、異なる型を組み合わせるためのユニオン型を、特定の条件や特徴に基づいて絞り込むための手法です。通常、この絞り込みは、オブジェクトのプロパティや特定のタグに基づいて行われます。

以下に、タグ付きユニオンの典型的な例を示します。

unionwithtag.tsx

type Circle = {
    shape: 'circle';
    radius: number;
};

type Rectangle = {
    shape: 'rectangle';
    width: number;
    height: number;
};

type Shape = Circle | Rectangle;

function calculateArea(shape: Shape): number {
    switch (shape.shape) {
        case 'circle':
            return Math.PI * Math.pow(shape.radius, 2);
        case 'rectangle':
            return shape.width * shape.height;
        default:
            throw new Error('Unknown shape');
    }
}

const circle: Circle = { shape: 'circle', radius: 5 };
const rectangle: Rectangle = { shape: 'rectangle', width: 4, height: 6 };

console.log(calculateArea(circle));      
console.log(calculateArea(rectangle));   

上の例では、Circle型とRectangle型を定義し、それらを結合してShape型を作成しています。calculateArea関数では、Shape型を引数として受け取り、switch文を使ってオブジェクトのshapeプロパティに基づいて適切な処理を行います。これにより、関数が正しい型情報を持ちつつ、異なる型のオブジェクトを処理できるようになります。

3-2. 共通化処理

下準備として、タグ付きユニオンを定義しておきます。

Definitions
export type SignInFormFields={
    tag:"SignIn";
    fields:["email","password"];
};

export type SignUpFormFields={
    tag:"SignUp";
    fields:["name","email","password"];
}

export type FormFields= SignInFormFields | SignUpFormFields;

2章3節で例に挙げたような形で、signInとsignUp用の共通フォームをつくろうと思います。
Form.tsx
"use client"

import {authServerAction } from "@/lib/Actions/FormActions";
import { useFormState, useFormStatus } from "react-dom";
import FormItems from "@/ui/AuthForm/FormItems";
import FormItemsError from "@/ui/AuthForm/FormItemsError";
import { FormFields } from "@/lib/Definitions";

type FormState={
    errors?:{
        name?:string[],
        email?:string[],
        password?:string[],
    },
    message?:string;
};

export default function Form({formType}:{formType:FormFields}){ // 1
  const formFields=formType.fields;
  
  const formAction=authServerAction.bind(null,formType.tag); // 2

  const initialState={errors:{},message:""};
  const [state,dispatch]=useFormState(formAction,initialState);

  return(
      <form action={dispatch}>
        <div>
            <h5 >フォームの入力</h5>
        </div>
        <div>
            {formFields.map(field=>( 
            <div key={field}>
                <FormItems inputType={field}/>
                {state.errors && <FormItemsError errors={state.errors[`${field}`]}  inputType={field} />}
            </div>
            ))}
        </div>
        <div>
            {state.message &&  <p className="me-2 text-red-500">{state.message}</Typography>}
        </div>
        <Submit/>
      </form>
  )
}

function Submit(){
  const {pending}=useFormStatus(); // 3

  useEffect(()=>{
    pending ? isLoading() : isLoaded();
  },[pending]);

  return(
      <button
        color="primary"
        variant="contained"
        type="submit"
        disabled={pending}
      >
        {pending ? "送信中..." : "送信" }
      </button>
  );
}

1:親コンポーネントからpropsとして、タグ付きユニオンの形に合わせたオブジェクトを渡すようにしています。
tagにはSignInかSignUpの文字列リテラルのどちらかが、そしてfieldsにはフォームに必要なinputフォームを指定しています。signInならemailとpassword、signUpなら先の二つの加えてnameも含みます。

2:tagをServer Actions関数に渡しています。bindメソッドでなくても、例えば<input name="tag" value={formType.tag} disabled />のように、隠したinput要素からサーバー側でtagを取得することもできます。
とはいえ、検証モードでユーザーに透けてしまいますし、わざわざこのためにDOM要素を増やすのは適切でない気がしますので、特にこだわりがなければbindメソッドで大丈夫だと思います。

3:useFormStatusは、直近のフォーム送信に関するステータス情報を提供するフックです。ほかに、action,method,dataなどの値を返します。

※FormItemsやFormErrorコンポーネントは其々、2章3節で紹介したCommentListファイルの、comment-form div タグや display-error div タグをコンポーネント化したものです。


次に、Server Actionの中身をご紹介します。

Actions.tsx
"use server";

import { signIn, signUp } from "@/lib/Actions/AuthActions";
import { FormFields, FormState } from "@/lib/Definitions";
import {SignInScheme, SignUpScheme } from "@/lib/Schemes";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";

export async function authServerAction(formType:FormFields["tag"],prevState:FormState,formData:FormData):Promise<FormState>{
    const safeParsedFieldsWithTag = formType === "SignUp" 
    ? {
        formType,
        safeParsedFields:SignUpScheme.safeParse({
            name:formData.get("name"),
            email:formData.get("email"),
            password:formData.get("password"),
        })
    } :{
        formType,
        safeParsedFields:SignInScheme.safeParse({
            email:formData.get("email"),
            password:formData.get("password"),
        })
}

    if(!safeParsedFieldsWithTag.safeParsedFields.success){
        return{
            errors:safeParsedFieldsWithTag.safeParsedFields.error.flatten().fieldErrors,
            message:"Fields Error: Missing Field types."
        }
    }

    try{
        if(safeParsedFieldsWithTag.formType === "SignUp"){
           const {name,email,password} = safeParsedFieldsWithTag.safeParsedFields.data;
           await signUp(name,email,password);
        }else{
            const {email,password} = safeParsedFieldsWithTag.safeParsedFields.data;
            await signIn(email,password);
        }
    }catch(error){
        return error instanceof Error ? {message:error.message} : {message:"Something went wrong"};
    }
    
    revalidatePath("/");
    redirect("/");
}

本当ならば、formTypeの如何によって、safeParsedFieldsの値をSignUpScheme.safeParseにするかSignInScheme.safeParseにするかで分けたかったのですが、その場合共通してプロパティを持つemailとpasswordの検証結果が返却される一方でnameが消えてしまうので、ここでもタグ付きユニオンの形になっています。
妥協的なコードなので、ほかにもっとよい書き方があるかもしれません。

ともかく、これで当初目指していた、signUpとsignInをひとつの共通のフォームで書く、ということが達成できました。もし他にも追加したいフォームの形があれば、それを同じように拡張していくことができるという点で、拡張性と保守性を兼ね備えた実装になれたと思います。


最後に、Formコンポーネントを呼び出す側の親コンポーネントを示して終わります。

// path:/auth/signUp
import { SignUpFormFields } from "@/lib/Definitions";
import Form from "@/ui/AuthForm/Form";

export default function SignUp(){
    const formType:SignUpFormFields={
        tag:"SignUp",
        fields:["name","email","password"]
    };

    return( 
        <Form formType={formType}/>    
    )   
}

// path:/auth/signIp
import { SignInFormFields } from "@/lib/Definitions";
import Form from "@/ui/AuthForm/Form";

export default function SignIn(){
    const formType:SignUpFormFields={
        tag:"SignIn",
        fields:["email","password"]
    };

    return( 
        <Form formType={formType}/>    
    )   
}

4. usePathNameを使う方法

タグ付きユニオンとは別の方法として、usePathNameを用いて条件制御するやり方があります。
usePathNameとは、今いる場所(current url)のpathnameを読み取ることのできるHooksです。
もし、これを使うとすると以下のようになると思います。

Form.tsx
"use client"

export default function Form(){
  const path= usePathName().split("/");
  const formType = path[path.length-1];
  const formFields = formType === "signIn" 
      ? ["email","password"]
      : ["name","email","password"];
  
  const formAction=authenticateServerAction.bind(null,formType); 

  const initialState={errors:{},message:""};
  const [state,dispatch]=useFormState(formAction,initialState);

  return(
      <form action={dispatch}>
        <div>
            <h5 >フォームの入力</h5>
        </div>
        <div>
            {formFields.map(field=>( 
            <div key={field}>
                <FormItems inputType={field}/>
                {state.errors && <FormItemsError errors={state.errors[`${field}`]}  inputType={field} />}
            </div>
            ))}
        </div>
        <div>
            {state.message &&  <p className="me-2 text-red-500">{state.message}</Typography>}
        </div>
        <button type="submit" />
      </form>
  )
}

こちらでも十分に実装が賄えます。

ただ、一つ懸念点があるとすれば、ディレクトリーの名前とurlのpathが一致するApp Routerでは、ディレクトリーの名前を変えてしまったら、usePathNameを使って条件制御をしているコード全てが影響を受けてしまうことでしょうか。

Server Actionsのなかで呼び出しているrevalidatePath及びredirectメソッドでも、遷移先のpathを決めるフォルダ名が変わっていたら同じように適切な挙動にはならないので、ここだけの話ではないかもしえません。しかしどちらにしろ、urlと関係なくオブジェクトの受け渡しだけで条件制御が完結するのならば、挙動が変わりにくいという点でタグ付きユニオンの方が優れているかもしれません。

5. おわりに

いかがでしたでしょうか?
知識をまとめ、情報を調べ、記事にしたことをきっかけに、Next.jsへの理解が深められました。特に、RSC・CC・SC、そしてServer Actions関係の話は、曖昧だったところが結構固められた気がします。
とはいえ、まだまだ分からないことの方が多いのが現状です。
Next.jsはサポートされている機能が多く複雑であるため、公式チュートリアルで紹介されているような基本要素はともかく、そこに含まれていない要素も数多くあります。特にNext.jsが実装するcacheの実装メカニズムは難しく感じました。なかなか全容を把握し、詳細を詰めて理解するのは難しいなと感じる次第です。

6. 参考記事一覧

  1. シリアライズ不可能な値としては主要なものに、クラスやJSX、関数コンポーネントなどがあげられます。

4
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
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?