🤧

ServerActionを使ってクライアント側の値を元にRSCを再レンダリングさせる

2024/10/25に公開

はじめに

弊社ではApp Routerを使用していますがサーバ/クライアントという境界線の扱いが難しく、何が最適解なのか模索しながら開発する毎日です。

いろいろと苦戦していますが、中でも「stateなどクライアント側の値によってRSCを再レンダリングさせ、(サーバ側で)データを取得し表示を更新する」の実現でかなり苦戦したので、今日はこの点について知見を共有したいと思います。

まとめ

少し長いので先にこちらの考えをお伝えしておくと、

  • サーバ → クライアントというレンダリングフローのため、クライアントの値を元にRSCを再レンダリングさせるのは現状難しい
  • 無理やりServer Actionで実現しようとすると複雑なコードになる。useEffectを使ってクライアント側でfetchするほうがシンプルに収まる

となります。

サンプルの構成と要件

具体例として下記要件の検索機能を考えます。

1. 検索条件を設定し、検索できる。検索結果と検索対象の件数を表示する
2. 検索条件が変更されたと同時に件数表示を更新する
3. 検索で重い処理が発生すると仮定し、検索と件数取得のクエリは分ける
4. formを使用し検索条件の設定はcheckbox、検索はbutton(submit)を使う

検索条件を設定したと同時に検索対象件数を取得し表示することで、ユーザに不要な検索を避けさせるという要件です。

今回検索そのものは考慮せず、検索条件の変更時に件数の再取得・表示が行われる点にフォーカスします。

コンポーネントツリーは下記を想定します。

- A (RSC)
    - B (RSC)
        - C (RSC) ← 件数の取得と表示
    - D (RSC)
        - E (Client) ← 検索結果を表示
    - F (RSC)
        - G (Client)
            - H (Client) ← 検索条件を設定(stateの更新)
        - I (Client) ← 検索(submit)

検索条件は基本的にクエリで管理しますが今回はstateで管理するものとします。特にこの例ではツリーが別々に存在するので(BC, DE, FGH, FGI)、state管理にstoreを使うものとします

検索条件の設定(選択)と検索件数の表示はそれぞれH、Cで行っており、別ツリーに存在しています。Cでは件数の取得も行っています。

上記構成を踏まえた検索フローは下記となります。

1. Hで検索条件を設定する(stateの更新)
2. 1.によりCの件数再取得が発火する
3. 2.の取得後、Cの件数表示が更新される

// 以下は考慮しない
4. Iで検索処理を発火させる (submit)
5. Eに検索結果が表示される

Hでのstateの更新と同時に件数表示の再取得(サーバ側処理)を含むCを再レンダリングさせる想定です。

上記構成をベースにして「stateなどクライアント側の値によってRSCを再レンダリングさせ、(サーバ側で)データを取得し表示を更新する」をどうやって実現しようとしたか、試したことを書いていきます。

試したこと

1. router.refresh()でRSCの再レンダリングする

router.refresh()は現在のルートのRouter Cacheを破棄し、再度新しいリクエストを送信します。

router.refresh()であればRSCを再レンダリングできるので、Hで更新したstateを元にデータをre-fetchできると考えました。

が、結果はうまくいきませんでした。

これは当然で、router.refresh()はあくまでRSC内でレンダリングされた時点でのエンドポイントに再リクエストを送るためです。

今回は動的に変わり得るstateをクエリに含めてリクエストを送りたいので適するわけがありませんでした。

router.refresh()の前にURLを書き換えることも考えましたが、サーバ → クライアントというレンダリングの流れがあるため、クライアント側からRSC内のfetcherのエンドポイントを変更することは不可能と判断しました。

そのためrouter.refresh()はURLを動的に変えてリクエストを送る場合には適さないという知見を得ることができました。

逆に言えば、(クエリまで含めて)同じエンドポイントからデータを取得できるのであれば適していると言えそうです。例えば取得したデータ一覧の一部を更新し、更新内容を画面に反映させる場合などです。この場合はDB上の変更を同じ一覧エンドポイントから取得できます。

ただ気をつけたいのはrouter.refresh()はルート(=ページ)全体のRSCを再レンダリングするという点です。今回検索の例を出しましたが、検索では結合やソートなどの重いクエリを叩くことがよくあるため、そのような処理の再発火はできるだけ防ぐほうがベターと考えられます。

2. Server Action + useActionStateでイベント経由でfetchを実行する

Server ActionとuseActionStateを組み合わせることで、イベント発火時にfetchを行うことができます。

// get-search-count.ts
"use server" 

const getSearchCount = async (_previousState, query)=> {
  const searchCount = await fetch(`https://hogehoge.com/searchCount?${query}`)
  
  return searchCount
}
// SearchFilter.tsx
'use client'

import { useActionState } from 'react'
import { getSearchCount } from './get-search-count'

export const SearchFilter = async ({ query }: { query: URLSearchParams }) =>  {
  const getSearchCountAction = getSearchCount.bind(null, query) // bindで引数を渡す
  const [searchCount, formAction] = useActionState(getSearchCountAction, []);
  
  return (
    <form>
      <button formAction={formAction}>
        re-fetch Seach Count: {searchCount}
      </button>
    </form>
  )
}

この例ではbuttonをクリックするとgetSearchCountがサーバ側で実行され、実行結果をsearchCountとして参照することができます。

またbindを使用してqueryをgetSearchCountに事前に渡しているので、動的に変化するURLへのリクエストを送信することもできます。

上記のようなコードをHに記述し、Iでstateを更新してからqueryに渡しfetchすれば同時に件数を取得できるのではないかと考えました。

// HComponent.tsx
'use client'

import { useActionState } from 'react'
import { getSearchCount } from './get-search-count'
import { useSearchFilterStore } from './hooks/use-search-filter-store'

export const HComponent = async ({ query }: { query: URLSearchParams }) =>  {
  const { setSerachFilter } = useSearchFilterStore() 
	
  const getSearchCountAction = getSearchCount.bind(null, query) // bindで引数を渡す
  const [searchCount, formAction] = useActionState(getSearchCountAction, []);
  
  return (
    <form>
      <button
        formAction={formAction}
        onClick={() => setSearchFilter(...)}
      >
        re-fetch Seach Count: {searchCount}
      </button>
    </form>
  )
}

しかしこの方法は下記理由で適しませんでした。

  1. 別ツリーのコンポーネントでuseActionStateの値を共有できない

HのuseActionStateをCでも定義し、Hで更新したデータをCでも取得しようとしました。するとHで何度取得してもCでは初期値の[]が返されました。おそらくuseActionStateのインスンタンスがHとCそれぞれで作成されてしまっているため、Hでの取得結果がCに反映されないのだろうと考えます。Cの件数を変えるにはCのformActionを使用してリクエストを送る必要がありそうです。

  1. イベント発火元(H)と表示先(C)が別のツリーにあり、’use client’をまたいでいる

1.でできないのなら、HとCの共通親コンポーネントにuseActionStateを定義して、searchCountとformActionをバケツリレーでH, Cに渡していけばよいのではと考えました。

しかしサーバからクライアントに関数を渡すことはできないですし、クライアントからサーバにpropsを渡すことはできません。そのため下記のようにAに’use client’を付与し、A配下すべてをClientとして扱う必要があります。

- A(Client) ← useActionStateを定義
	- B (Client) ← 件数の取得と表示
	- C (Client)
    - D (Client) ← 検索条件を設定(stateの更新)

A配下でもRSCを使いたい部分がある場合この方法は適切ではなく、実際にそうだったため適さないと判断しました。

3. storeでformActionを管理する

バケツリレーがイヤなら下記のようにsearchCountとformActionをstore経由で共有できるようにすればいいのでは?と考えました。

これならstoreを使っているのでuseActionStateの別インスタンスにはならないですし、propsのバケツリレーも避けることができます。

// hooks/use-search-count-store.ts
'use client'

import { atom, useAtom } from "jotai"
import { useActionState } from 'react'

import { getSearchCount } from './get-search-count'
  
type Action = (state: Awaited<State>) => State | Promise<State>
 
const countAtom = atom<number | undefined>(undefined)
const actionAtom = atom<Action | undefined>(undefined)
  
export const useSeachCountStore = () => {
  const [searchCount, setSearchCount] = useAtom(countAtom)
  const [action, setAction] = useAtom(actionAtom)
	
  const getFormAction = async ({ query }: { query: URLSearchParams }) => {
    const getSearchCountAction = getSearchCount.bind(null, query) // bindで引数を渡す
    setAction(getSearchCountAction)
  })
	
  const [fetchedSearchCount, formAction] = useActionState(action, []);
	
  // Cで参照できるよう実行結果をstoreに格納する
  useEffect(() => {
    setSearchCount(fetchedSearchCount)
  }, [fetchedSearchCount])
	
  return {
    getFormAction,
    formAction,
    searchCount,
  }
}
// HComponent.tsx
'use client'

import { useEffect } from 'react'

import { getSearchCount } from './get-search-count'
import { useSearchFilterStore } from './hooks/use-search-filter-store'
import { useSearchCountStore } from './/hooks/use-search-count-store'
  
export const HComponent = () => {
  const { searchFilter, setSerachFilter } = useSearchFilterStore() 
  const { fetchedSearchCount, formAction, setSearchCount } = useSearchCountStore()

  const query = convertSearchFilterToQuery(searchFilter)
	 
  // queryの変更のたびActionを更新する
  useEffect(() => {
    query && getFormAction(query)
  }, [query])
	
  return (
    // formは共通の親コンポーネントに移動
    <button
      formAction={formAction}
      onClick={() => setSearchFilter(...)}
    >
      re-fetch Seach Count: {searchCount}
    </button>
  )
}

一応これでサーバ側の処理を経由してデータを取得・表示することはできました。

ただご覧のように

  • count/action更新用のatomを用意する必要がある
  • queryの変更を検知してactionを更新し、formAction経由で取得したデータをstoreに格納するuseEffectが複数必要になる

ため、少し複雑なロジックになってしまいました。

これでは処理を追うのが大変になりそうですし、useEffectによりバグも怖いです。動くが複雑なので、もう少しシンプルにできないか考えました。

4. useEffectを使ってクライアント側でfetchする

こんなに複雑になるならいっそのことクライアント側でfetchですればいいのでは?と考えました。結果、下記のようになりました。

// get-serch-count.ts
"use server" 

const getSearchCount = async (query)=> {
  const searchCount = await fetch(`https://hogehoge.com/searchCount?${query}`)
  
  return searchCount
}
// hooks/use-search-count-store.ts
'use client'

import { atom, useAtom } from "jotai" 

const countAtom = atom<number | undefined>(undefined)
  
export const useSeachCountStore = () => {
  const [searchCount, setSearchCount] = useAtom(countAtom)

  return {
    searchCount,
    setSearchCount
  }
}
// HComponent.tsx
'use client'

import { getSearchCount } from './get-hoges'
import { useSearchFilterStore } from './hooks/use-search-filter-store'
import { useSearchCountStore } from './/hooks/use-search-count-store'
  
export const HComponent = () => {
  const { searchFilter, setSerachFilter } = useSearchFilterStore() 
  const { searchCount, setSearchCount } = useSearchCountStore()

  const query = convertSearchFilterToQuery(searchFilter)

  useEffect(() => {
    const count = getSearchCount(query)
    setSearchCount(count)
  }, [query]
	
  return (
    <button onClick={() => setSearchFilter(...)}>
      re-fetch Seach Count: {searchCount}
    </button>
  )
}

formActionを使う場合と比べてこちらのほうが明らかにシンプルで処理のフローも追いやすいです。

そもそもですがサーバ → クライアントというレンダリングのフローがある中で、クライアント側の値を用いてサーバ側で既に実行された処理を再レンダリングによって再発火させる、というのは無理があるように感じました。

結局のところfetch処理をサーバ/クライアント側どちらで行うか、という違いだけなので可読性(=理解のしやすさ)、他のメンバーが読むことなど総合的に判断すると自分ならこちらを選択しますし、実際のプロダクトでもクライアントフェッチを使って実装しています。

まとめ

以上をふまえ、改めて結論を書いておきます。

  • サーバ → クライアントというレンダリングフローのため、クライアントの値を元にRSCのデータ再取得を発火させるのは難しい
  • 無理やりServer Actionで実現しようとすると複雑なコードになる。useEffectを使ってクライアント側でfetchするほうがシンプルに収まる

ただ欲を言えば、router.refresh()の範囲を決められたり、useActionStateのstore版など、クライアント側からサーバ側を操作するAPIが増えればなあと思ったり。
Vercelの今後の機能拡充に期待したいです。

フィシルコム

Discussion