🙄

Next.jsのServer Actionsについて調べてみた

2023/08/16に公開

概要

前回、新しいNext.jsのAPIでフォームのデータの送受信について調べてみました。

https://zenn.dev/kiriyama/articles/87b8911973444d

調べていたら、新機能としてNext.jsの13.4で新たに追加された機能としてServer Actionsという機能があるのをしりました。
まだアルファ版という事ですが、これがまた凄いなという事で調べてみた。

Server Actionsについて

これまでフォームからデータの追加、削除などをNext.jsで実現するには、apiフォルダにAPIを用意してfetchなどでデータを送ったりなどクライアント側で処理をしてました。

ただ、Server Actionsの登場で、フォームの送信やボタンクリックしてfetchを利用したクライアント側の処理の必要がなくなり、サーバーコンポーネント内でフォームのデータを受けとり、データ更新(作成、更新、削除)がサーバーサイドで実行されるようになる。
この機能を使うには、pages RouterではなApp Routerでないと使えません。

公式サイトはこちら。
https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions

Server Actionsを利用する為の設定

Server Actionsを利用するには、next.config.jsexperimentalの項目にserverActions: trueの設定を追加する必要がある。

next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    appDir: true,
    serverActions:true //これを追加する
  },
}

module.exports = nextConfig

Server Actionsのサンプル

まずは余計なコードを省いたシンプルな全体像。
Server Actionsを利用するにはサーバーコンポーネントでないと利用できない点に注意してください。

またServer Actionsを実装するには3つの方法があるらしい。
とりあえず今回は2つの方法は似てるので、2つを紹介。

action属性にサーバーで実行したい関数を指定

まずは1つ目の方法として<form>タグのaction属性にサーバーで実行したい関数を指定する。

app/pages.tsx
export default async function Home() {

  return (
   
    <form action={addPost}>
       <input type='text' name='text' />
       <button>送信</button>
    </form>
  )
}

formAction属性にサーバーで実行したい関数を指定

2つ目の方法として<button>タグにformAction属性を追加してサーバーで実行したい関数を指定する方法。
formActionですが、<form>タグで括られてないと実行されないので注意が必要である。

app/pages.tsx
export default async function Home() {

  return (
    <form>
       <input type='text' name='text' />
       <button formAction="addPost">送信</button>
    </form>
  )
}

Server Actionsの関数

フォームに指定したサーバーで実行したい関数です。

app/pages.tsx
 const addPost = async (formData: FormData) => {
    "use server"
    const textdata = formData.get('text')
    console.log(textdata)
  }

関数は非同期処理としてasyncを指定します。
指定しないとServer actions must be async functionと表示されます。

またサーバー側で実行するにはuse serverを関数内で宣言する必要がある。
この設定をしないとエラーがでます。

フォームの入力内容は関数の引数から受けてることができる。データの型はFromDataとなる。
変数名はformDataとしてるが何でもいい。
このFromData型にgetメソッドがあるので、引数に受け取りたいフォームのname属性を指定すれば取得できる。

あとは、MySQLやMongoDB、Supabaseなど利用してるデータベースなどに追加するそれぞれのメソッドで追加することができる。

実装

簡単な例としてSupabaseを利用してフォームに入力したテキストの追加と削除、一覧表示をやってみたいと思う。

今回のDEMOはこちら

実装にあたって、以下のディレクトリー構成にする。

  • app/page.tsx
  • app/libs/supabaseClient.ts

Supabaseの設定もろもろは今回省きます。Supabaseの設定は下記。

  • todos:テーブル名
  • id:デフォルトでprimarykeyとして設定されていて、自動的にID番号が振られる
  • text:入力フォームのテキストを保存する
  • created_at:こちらも初期値で自動的に投稿日が保存

Supabaseの設定もろもろ

まずは、Supabseでテーブルなど作成しておきます。
APIの設定からProject URLProject API keysに記されてるANONが必要になる。(下記画像、赤枠の箇所)

プロジェクトにSupbaseが使えるようにパッケージをインストールします。

npm install @supabase/supabase-js

KeyをもとにSupbaseのデータベースにアクセスするためのフィルをsupbaseClient.tsとして別ファイルにしておきます。
Keyに関しては、ルートディレクトリに.envファイルを作成してNEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_ANON_KEYとして読み込んでます。

app/libs/supabaseClient.ts
import { createClient } from '@supabase/supabase-js';

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!

export const supabase = createClient(supabaseUrl, supabaseKey)

このcreateClientに引数をURLとKeyを渡してsupabaseをエクスポートしておきます。
こちらをインポートする事でSupabaseの追加、削除、更新などのメソッドを利用することができる。今回は追加(insert)と削除(delete)メソッドのみ利用する。

全体像

まずはファイルの全体像。

app/page.tsx
import { Inter } from 'next/font/google'
import { supabase } from './libs/supabaseClient'
import { PostgrestSingleResponse } from '@supabase/supabase-js'
import { revalidatePath } from 'next/cache'

type Todo = {
  id: number,
  title: string
}

const inter = Inter({ subsets: ['latin'] })

export default async function Home() {

//Supabaseからselect文でデータを読み込む
const todos: PostgrestSingleResponse<Todo[]> = await supabase.from('todos').select("*");

 //Supabaseにデータを追加する
const addPost = async (formData: FormData) => {
    "use server"
    
    //フォームのデータを取得
    const textdata = formData.get('text')
    //supabaseにデータを追加
    await supabase.from('todos').insert({ text: textdata })

    //ページをリロード
    revalidatePath('/')
  }

  //削除ボタンを押したときの処理
  const deletePost = async (formData: FormData) => {
    "use server"
    
    //削除したいIDを取得
    const id = formData.get('id')

    try {
     //Supabaseのdeleteメソッドで該当するIDを削除する
      const { error } = await supabase.from('todos').delete().eq('id', id);

      if (error) {
        throw error;
      }

      //ページをリロード
      revalidatePath('/')

    } catch (error) {
      console.error('Error deleting data:', error);
    }
  }
  
if (!todos) {
    return (
      <main>
          <form action={addPost}>
            <input type='text' name='text' />
            <button>送信</button>
          </form>
     
          <h2>Todo一覧</h2>
          <p>投稿がありません。</p>
      </main>
    )
  }

  return (
    <main>
     
      <form action={addPost}>
          <input type='text' name='text' />
          <button>送信</button>
        </form>
     
        <h2 >Todo一覧</h2>
	
	//Supabaseから取得した一覧を表示する
        <ul>
          {todos.data?.map((todo: any) => (
            <li key={todo.id} >
              <span>{todo.text}</span>
              <form>
                <input type='hidden' name='id' value={todo.id} />
                <button formAction={deletePost}>削除</button>
              </form>
            </li>
          )
          )}
        </ul>
    
    </main>
  )
}

すべてを説明すると長いので、所々説明。

先に作成したsupabaseClient.tsをインポートする。

Supabaseからselect文でデータを読み込む

まずはSupabaseからデータを取得。サーバーコンポーネントなので、useEffectとか利用しない。

const todos: PostgrestSingleResponse<Todo[]> = await supabase.from('todos').select("*");

取得したデータがない場合は、「投稿がありません。」としてデータがあった場合は、map()関数で一覧を出力します。

Supabaseにデータを追加する

今回の目的であるServer Acitonsの設定部分です。
<form>タグのaction="addPost"という関数を設定しています。
その関数の部分を抜粋。

const addPost = async (formData: FormData) => {
    "use server"
    
    //フォームのデータを取得
    const textdata = formData.get('text')
    //supabaseにデータを追加
    await supabase.from('todos').insert({ text: textdata })

    //ページを検証する
    revalidatePath('/')
  }

ServerActionsの関数内でuse serverを宣言。
formDataから入力されたフォームの値を取得。
Supabaseにinsert文を利用してデータを追加する。

その後、revalidatePathでページを検証しています。
こちらについては後述します。

データを削除してみる

削除自体はformActionで削除するのでdeletePpstという関数を設定。
各データをSupabaseから削除するにはID番号が必要となるので、一覧表示させるときに<input>タグのtype="hidden"にIDを設定しておく。

先に述べたように<form>タグで括る必要があるので以下のように記述。

<ul>
   {todos.data?.map((todo: any) => (
    <li key={todo.id} >
       <span>{todo.text}</span>
       <form>
         <input type='hidden' name='id' value={todo.id} />
         <button formAction={deletePost}>削除</button>
       </form>
     </li>
     )
   )}
</ul>

削除は、SupabaseのdeleteメソッドでIDを指定すればいい。

const deletePost = async (formData: FormData) => {
  "use server"
    
  //削除したいIDを取得
  const id = formData.get('id')
  const { error } = await supabase.from('todos').delete().eq('id', id);
}

ページを更新させる(revalidatePat)

通常、フォームなどを入力してデータを追加した後はブラウザのリロードなどをしないと一覧は更新されない。Supabaseにはデータベースの変更をリアルタイムに検知する事ができる機能もあるが、、ただこれを解決してくれる機能としてrevalidatePathrevalidateTagいうのがある。(今回はrevalidateTagについては省く)

const addPost = async (formData: FormData) => {
    "use server"
    
    //ページを検証する
    revalidatePath('/')
  }

このrevalidatePathaddPostdeletePostでのデータの追加・削除が完了した後に追加しておけば、データを検証して更新されていれば最新に反映されるようになる。

引数にはパスを指定するのだがパスに紐づくデータを再検証するようです。
例えば、投稿一覧のページのURLが/postsとしてパスを下記のようにする追加したデータと一覧した場合に更新されるが、関係のないパス、、、/hogehogeとしたら更新されない。

//更新される
revalidatePath('/posts')

//更新されない
revalidatePath('/hogehoge')

ただ、投稿一覧のページのURLが/postsでパスをルートパスを指定するとルートパス配下は更新される??模様。

//更新される
revalidatePath('/')

他にもrevalidateTagというのもあります。
詳しくはこちらの記事が参考になりまくります。一部引用させていただくと以下のようです。

Server Actions と revalidatePath revalidateTag を組み合わせると、いま現在表示している画面に対して revalidate に応じた更新を行うことができます。

https://zenn.dev/cybozu_frontend/articles/server-actions-and-revalidate#revalidatepath-%26-revalidatetag

公式サイトはこちら
https://nextjs.org/docs/app/api-reference/functions/revalidatePath

参考サイト

今回、2つのパターンを調べてみたが長くなりそうなので、3つ目のパターンは別にします。

https://zenn.dev/rgbkids/articles/c983df12cfa87d
https://azukiazusa.dev/blog/nextjs-server-action/
https://reffect.co.jp/react/next-j-server-actions/

https://developer.mozilla.org/ja/docs/Web/API/Response/Response

https://zenn.dev/k_kind/articles/supabase-realtime-postgres
https://www.wadeen.net/posts/85x6m39yk
https://www.sukerou.com/2022/12/supabase.html

Discussion