💡

useEffect で苦しまない!useActionState × Server Actions ベストプラクティス

2025/03/08に公開
2

はじめに

React 19 では useActionStateServer Actions を使う機会が大幅に増えたことでしょう。

しかし、フォームアクションの結果に応じてトーストを表示するなど、UI を適切に更新しようとすると意外と手間がかかります。特に、useEffect を使わずにこれを実現しようとすると、どう書けばいいのか悩むこともあるのではないでしょうか?

筆者も、この記事で紹介する方法に出会うまでは、「Server Actions + useActionState はリダイレクトする用途なら便利だけど、トースト表示やモーダルの開閉には結局 useEffect が必要で、それなら onSubmit で良くないか?」と考えていました。

本記事では、useEffect に頼らずに useActionStateServer Actions を活用して、スマートに UI を更新する方法を紹介します。

なぜuseEffectなしが良いのか

まず、useActionState を使ってフォームの処理を行い、トーストの表示を useEffect で処理する一般的な書き方を見てみましょう。

const [lastResult, action, isPending] = useActionState(updateWorkspaceAction, null)

useEffect(() => {
  if (lastResult === null) {
    return
  }
  
  if (lastResult.status !== 'success') {
    toast.error('Failed to update workspace')
  } else {
    toast.success('Workspace updated')
    setOpen(false)
  }
}, [lastResult])

一般的ではあるものの、この方法にはいくつか問題点があります。
筆者は、特に以下の3つが問題になると考えています。

  1. useEffect によるレンダリングパフォーマンスとUXの低下
  • useEffect はレンダリング完了後に実行されるため、トースト表示までに遅延が生じる。
  • useEffect はコンポーネントのマウント時にも実行されるため、lastResult === null のチェックが必要。
  • useEffect内で状態更新を行う場合は追加のレンダリングサイクルが発生し、パフォーマンスに影響する可能性がある。
  1. トーストの処理がフォームアクションのロジックと分離している
    useEffect 内で lastResult の状態を監視しているため、トースト表示のタイミングが分かりにくい。

  2. コードの可読性が低下する
    useEffect の中で toast を処理しているため、状態管理の流れが直感的でない。

特に重要なのは1.です。

Reactの公式にもあるように、useEffect は不要なレンダリングを起こす可能性があるため、不要なレンダリングを起こす useEffect を避けるように推奨しています。

https://ja.react.dev/learn/you-might-not-need-an-effect

useEffectの実行順序

あらためて useEffect の実行順序を見ていきます
(知っているという方は飛ばしてください)

https://ja.react.dev/reference/react/useEffect

useEffect の動作は依存配列がある場合とない場合で、異なります。

依存配列が空の場合 useEffect(() => {}, [])

依存配列が空の場合、useEffect は以下の動きをします。

  1. コンポーネントの初回レンダリング
  2. レンダリングの完了(DOM更新)
  3. ブラウザの描画
  4. useEffect の実行

この場合、useEffect 内で setState() のような状態更新関数を使用しない限り、再レンダリングはトリガーされません。つまり、マウント時に一度だけ実行されるシンプルな動作となります。

依存配列がある場合 useEffect(() => {}, [someValue])

依存配列がある場合は、より複雑な動作となります。特に内部で setState() を呼んだり、親からの props を使用するケースでは注意が必要です。

初回レンダリング時

  1. コンポーネントの初回レンダリング
  2. レンダリングの完了(DOM更新)
  3. ブラウザの描画
  4. useEffect の実行(依存配列の内容に関わらず、初回は必ず実行)

依存配列の値が変更された場合
5. なんらかの要因による再レンダリングのトリガー(状態変更やprops変更など)
6. レンダリングの完了(DOM更新)
7. ブラウザの描画
8. 依存配列の値が前回と異なる場合、useEffect の再実行

重要なのは、依存配列の変更自体が再レンダリングを引き起こすのではなく、再レンダリングが発生した際に依存配列の値を確認し、変更があればuseEffectが実行されるという点です。

const [lastResult, action, isPending] = useActionState(updateWorkspaceAction, null)

useEffect(() => {
  if (lastResult === null) {
    return
  }
  
  if (lastResult.status !== 'success') {
    toast.error('Failed to update workspace')
  } else {
    toast.success('Workspace updated')
    setOpen(false)
  }
}, [lastResult])

つまり、この useEffect は、フォームアクションの実行がされた際のレンダリング時に、前回のlastResultとフォームアクション実行後のlastResultを比較して、処理をしているということになります。

そのため、イベントハンドラで直接トーストを表示する場合と比べて、若干の遅延や追加のレンダリングサイクルが発生する可能性があります。

また、useEffect 内で状態更新関数を呼び出すと、新たな再レンダリングサイクルが始まることにも注意が必要です。このような状況では、無限ループに陥る可能性もあるため、依存配列の設計には細心の注意が必要ですし、可能であれば、使わない方向を模索するほうが筆者は良いと考えています。

useEffect なしでの実装

ここまでで、useEffect を避けるべき理由がわかったと思います。
そこで、useEffect なしで実装する方法を2つ紹介します。
ただ、筆者は2つめの方法がベストプラクティスと考えているので、そちらを推奨します。

① useActionStateのコールバックでのハンドリング

useActionState はフォームアクションの結果に基づいて state を更新するためのフックなので、useActionStatecallback を利用して、トースト表示を直接ハンドリングできます。

https://ja.react.dev/reference/react/useActionState

コードにすると以下のようになります。

const [lastResult, action, isPending] = useActionState<
    Awaited<ReturnType<typeof updateWorkspaceAction> | null>,
    FormData
  >(async (prev, formData) => {
    const result = await updateWorkspaceAction(prev, formData)

    if (result.status !== 'success') {
      toast.error('Failed to update workspace')

      return result
    }

    toast.success('Workspace updated')
    setOpen(false)

    return result
}, null)

このコードでは、useActionStatecallback 内で Server Actions を実行し、その結果に応じてトーストを表示しています。

この方法は、useEffect の使用と比較して、以下の点で優れています。

  1. 不要なレンダリングやトースト表示の遅延が発生しない
  2. トーストの処理がフォームアクションのロジックと近い
  3. 可読性が比較的良い

そのため、useEffect を使用するよりかは遥かに良いです。

ただ、このコードの場合、以下の問題点があります

  1. Server Actions の結果(ここではupdateWorkspaceActionの結果)を追わないと、何をしているのかわからない
  2. useActionState のジェネリクスのネストが深い

特に、1. は比較的、大きな問題と言えそうです。
そこで、2つ目の方法です。

② withCallbacksを使用する方法

この方法は以下のポストで紹介されていました。

https://x.com/rwieruch/status/1897686642732785927

それが、withCallbacksを使用する方法です。
これが現時点では、最もベストプラクティスな実装と筆者は考えています。

全体としては以下のようになります。

const [lastResult, action, isPending] = useActionState(
  withCallbacks(updateWorkspaceAction, {
    onError() {
      toast.error('Failed to update workspace')
    },
    onSuccess() {
      toast.success('Workspace updated')
      setOpen(false)
    },
  }),
  null,
)
with-callback.ts
import type { SubmissionResult } from '@conform-to/react'

type Callbacks<T, R = unknown> = {
  onStart?: () => R
  onEnd?: (reference: R) => void
  onSuccess?: (result: T) => void
  onError?: (result: T) => void
}

export const withCallbacks = <
  Args extends unknown[],
  T extends SubmissionResult<string[]>,
  R = unknown,
>(
  fn: (...args: Args) => Promise<T>,
  callbacks: Callbacks<T, R>,
) => {
  return async (...args: Args) => {
    const promise = fn(...args)

    const reference = callbacks.onStart?.()
    const result = await promise

    if (reference) {
      callbacks.onEnd?.(reference)
    }

    if (result.status === 'success') {
      callbacks.onSuccess?.(result)
    }

    if (result.status === 'error') {
      callbacks.onError?.(result)
    }

    return promise
  }
}

どうでしょうか?
1つ目の方法に比べて、可読性・保守性・型安全性の面で非常に良くなったのではと思います。
つまり、useEffect の問題も解消しつつ、より優れたプラクティスになっているということが言えそうです。

withCallbacksの内容

では、詳細を見ていきましょう。
まず、withCallbacks についてですが、全体のコードで見た通り、これは、useActionStatecallback に渡すユーティリティ関数です。

import type { SubmissionResult } from '@conform-to/react'

type Callbacks<T, R = unknown> = {
  onStart?: () => R
  onEnd?: (reference: R) => void
  onSuccess?: (result: T) => void
  onError?: (result: T) => void
}

export const withCallbacks = <
  Args extends unknown[],
  T extends SubmissionResult<string[]>,
  R = unknown,
>(
  fn: (...args: Args) => Promise<T>,
  callbacks: Callbacks<T, R>,
) => {
  return async (...args: Args) => {
    const promise = fn(...args)

    const reference = callbacks.onStart?.()
    const result = await promise

    if (reference) {
      callbacks.onEnd?.(reference)
    }

    if (result.status === 'success') {
      callbacks.onSuccess?.(result)
    }

    if (result.status === 'error') {
      callbacks.onError?.(result)
    }

    return promise
  }
}

それでは、1つずつ具体的に見ていきましょう。
(TypeScriptを問題なく、読めるという方は、飛ばしてください。)

type Callbacks<T, R = unknown> = {
  onStart?: () => R
  onEnd?: (reference: R) => void
  onSuccess?: (result: T) => void
  onError?: (result: T) => void
}

まず、Callbacks<T, R> ですが、これはwithCallbacksの第2引数の型です。
Tには、SubmissionResult<string[]>ActionState など、Server Actions の結果が入ります。そのため、onSuccessonError では Server Actions の結果を引数で受取ることができます。

そして、R ですが、デフォルト値で unknown を指定しています。
そのため、onStart では、返り値がない(returnがない)場合、返却値は unkown になりますし、返却値がある(returnがある)場合、その返却値の型が入ります。

onEnd では onStart の返却値を引数に取るようにしています。
そのため、ポストにあるように、 onStarttoast.loading('loading...') とし、onEndtoast.dismiss(refernces) とすることができます。

image

次に、withCallbacksについてです。

export const withCallbacks = <
  Args extends unknown[],
  T extends SubmissionResult<string[]>,
  R = unknown,
>(
  fn: (...args: Args) => Promise<T>,
  callbacks: Callbacks<T, R>,
) => {
  return async (...args: Args) => {
    const promise = fn(...args)

    const reference = callbacks.onStart?.()
    const result = await promise

    if (reference) {
      callbacks.onEnd?.(reference)
    }

    if (result.status === 'success') {
      callbacks.onSuccess?.(result)
    }

    if (result.status === 'error') {
      callbacks.onError?.(result)
    }

    return promise
  }
}

withCallbacksArgsTR をジェネリクスにとります。
そして、呼び出されるときは以下のように呼び出します。

const [lastResult, action, isPending] = useActionState(
  withCallbacks(updateWorkspaceAction, {
    onError() {
      toast.error('Failed to update workspace')
    },
    onSuccess() {
      toast.success('Workspace updated')
      setOpen(false)
    },
  }),
  null,
)

呼び出し時にジェネリクスの指定は明示的に指定する場合を除き、不要です。

なぜなら、ArgsT は第1引数の fn の引数と返却値から自動で推論されるからです。

また、R についてですが、onStart が存在しない場合もあるため、デフォルト値を指定しています。

そして R についても同様で、onStart があり、かつ onStart の返却値がある(returnがある)場合に、自動で推論されるため、こちらも明示的に指定する場合を除き、呼び出し時にジェネリクスを使用する必要はありません。

最後に withCallback の返却値についてです。

return async (...args: Args) => {
  const promise = fn(...args)

  const reference = callbacks.onStart?.()
  const result = await promise

  if (reference) {
    callbacks.onEnd?.(reference)
  }

  if (result.status === 'success') {
    callbacks.onSuccess?.(result)
  }

  if (result.status === 'error') {
    callbacks.onError?.(result)
  }

  return promise
}

こちらは処理の流れをそのまま追うだけなので、簡単です。
順序としては以下です。

  1. fn(...args) を実行し、その返り値(Promise)を promise 変数に格納
  2. onStart があれば、onStart を実行し、返却値を reference に格納
  3. promisefn(...args))の完了を待つ(Promise解決)
  4. reference が存在すれば、分岐に入る
  5. onEnd があれば、reference を引数にとり、onEnd を実行
  6. status に応じて処理を分岐
  7. onSuccessonError がある場合に5.の結果(result)を引数にそれぞれの関数を実行
  8. 渡された Server Actionspromisefn(...args)))を返却

これで withCallbacks を用いた useActionState のハンドリング方法の解説は以上です。

withCallacksのTの型について

筆者の場合、Conform というform管理ライブラリで Server Actions の実装をしていたので、そこで提供されている型(SubmissionResult<string[]>)を withCallbacksT の制限(extends)に使用しています。

https://conform.guide/

しかしながら、ポスト主は ActionState という型を使用していました。実際のところ、以下の型を使用していたようですので、Conform 使っていない方は、以下を参考にしてみてください。

export type ActionState =
| {
message: string;
status: "SUCCESS" | "ERROR";
}
| null
| undefined;

おわりに

筆者は、このポストに出会うまで、① useActionStateのコールバックでのハンドリングの方法を使用していました。

今後は、withCallbacks を使用したハンドリングを使用・推奨していこうと思っていますし、これが現時点(2025年3月時点)では最もベストプラクティスな方法だと考えています。

ここまで、読んでくださり、ありがとうございました。
少しでも参考になれば幸いです。

参考文献

https://ja.react.dev/reference/react/useActionState

https://x.com/rwieruch/status/1897686642732785927

https://x.com/rwieruch/status/1897686645903597957

https://ja.react.dev/reference/react/useEffect

https://ja.react.dev/learn/you-might-not-need-an-effect

https://qiita.com/diskszk/items/333511fb97d24f52a439

Discussion

面川泰明面川泰明

[should] onEndの実行とPromise解決の順番は逆ですかね?

kaitokaito

ありがとうございます
先にPromise解決しないとonEndが正しく発生しないですね
修正しておきます👍