useEffect で苦しまない!useActionState × Server Actions ベストプラクティス
はじめに
React 19 では useActionState
と Server Actions
を使う機会が大幅に増えたことでしょう。
しかし、フォームアクションの結果に応じてトーストを表示するなど、UI を適切に更新しようとすると意外と手間がかかります。特に、useEffect
を使わずにこれを実現しようとすると、どう書けばいいのか悩むこともあるのではないでしょうか?
筆者も、この記事で紹介する方法に出会うまでは、「Server Actions
+ useActionState
はリダイレクトする用途なら便利だけど、トースト表示やモーダルの開閉には結局 useEffect
が必要で、それなら onSubmit
で良くないか?」と考えていました。
本記事では、useEffect
に頼らずに useActionState
と Server 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つが問題になると考えています。
-
useEffect
によるレンダリングパフォーマンスとUXの低下
-
useEffect
はレンダリング完了後に実行されるため、トースト表示までに遅延が生じる。 -
useEffect
はコンポーネントのマウント時にも実行されるため、lastResult === null
のチェックが必要。 - useEffect内で状態更新を行う場合は追加のレンダリングサイクルが発生し、パフォーマンスに影響する可能性がある。
-
トーストの処理がフォームアクションのロジックと分離している
useEffect
内でlastResult
の状態を監視しているため、トースト表示のタイミングが分かりにくい。 -
コードの可読性が低下する
useEffect
の中でtoast
を処理しているため、状態管理の流れが直感的でない。
特に重要なのは1.です。
Reactの公式にもあるように、useEffect
は不要なレンダリングを起こす可能性があるため、不要なレンダリングを起こす useEffect
を避けるように推奨しています。
useEffectの実行順序
あらためて useEffect
の実行順序を見ていきます
(知っているという方は飛ばしてください)
useEffect
の動作は依存配列がある場合とない場合で、異なります。
依存配列が空の場合 useEffect(() => {}, [])
依存配列が空の場合、useEffect
は以下の動きをします。
- コンポーネントの初回レンダリング
- レンダリングの完了(DOM更新)
- ブラウザの描画
-
useEffect
の実行
この場合、useEffect
内で setState()
のような状態更新関数を使用しない限り、再レンダリングはトリガーされません。つまり、マウント時に一度だけ実行されるシンプルな動作となります。
依存配列がある場合 useEffect(() => {}, [someValue])
依存配列がある場合は、より複雑な動作となります。特に内部で setState()
を呼んだり、親からの props
を使用するケースでは注意が必要です。
初回レンダリング時
- コンポーネントの初回レンダリング
- レンダリングの完了(DOM更新)
- ブラウザの描画
-
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
を更新するためのフックなので、useActionState
の callback
を利用して、トースト表示を直接ハンドリングできます。
コードにすると以下のようになります。
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)
このコードでは、useActionState
の callback
内で Server Actions
を実行し、その結果に応じてトーストを表示しています。
この方法は、useEffect
の使用と比較して、以下の点で優れています。
- 不要なレンダリングやトースト表示の遅延が発生しない
- トーストの処理がフォームアクションのロジックと近い
- 可読性が比較的良い
そのため、useEffect
を使用するよりかは遥かに良いです。
ただ、このコードの場合、以下の問題点があります
-
Server Actions
の結果(ここではupdateWorkspaceAction
の結果)を追わないと、何をしているのかわからない -
useActionState
のジェネリクスのネストが深い
特に、1. は比較的、大きな問題と言えそうです。
そこで、2つ目の方法です。
② withCallbacksを使用する方法
この方法は以下のポストで紹介されていました。
それが、withCallbacks
を使用する方法です。
これが現時点では、最もベストプラクティスな実装と筆者は考えています。
全体としては以下のようになります。
const [lastResult, action, isPending] = useActionState(
withCallbacks(updateWorkspaceAction, {
onError() {
toast.error('Failed to update workspace')
},
onSuccess() {
toast.success('Workspace updated')
setOpen(false)
},
}),
null,
)
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
についてですが、全体のコードで見た通り、これは、useActionState
の callback
に渡すユーティリティ関数です。
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
の結果が入ります。そのため、onSuccess
と onError
では Server Actions
の結果を引数で受取ることができます。
そして、R
ですが、デフォルト値で unknown
を指定しています。
そのため、onStart
では、返り値がない(returnがない)場合、返却値は unkown
になりますし、返却値がある(returnがある)場合、その返却値の型が入ります。
onEnd
では onStart
の返却値を引数に取るようにしています。
そのため、ポストにあるように、 onStart
で toast.loading('loading...')
とし、onEnd
で toast.dismiss(refernces)
とすることができます。
次に、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
}
}
withCallbacks
は Args
・T
・R
をジェネリクスにとります。
そして、呼び出されるときは以下のように呼び出します。
const [lastResult, action, isPending] = useActionState(
withCallbacks(updateWorkspaceAction, {
onError() {
toast.error('Failed to update workspace')
},
onSuccess() {
toast.success('Workspace updated')
setOpen(false)
},
}),
null,
)
呼び出し時にジェネリクスの指定は明示的に指定する場合を除き、不要です。
なぜなら、Args
と T
は第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
}
こちらは処理の流れをそのまま追うだけなので、簡単です。
順序としては以下です。
-
fn(...args)
を実行し、その返り値(Promise
)をpromise
変数に格納 -
onStart
があれば、onStart
を実行し、返却値をreference
に格納 -
promise
(fn(...args)
)の完了を待つ(Promise解決) -
reference
が存在すれば、分岐に入る -
onEnd
があれば、reference
を引数にとり、onEnd
を実行 -
status
に応じて処理を分岐 -
onSuccess
・onError
がある場合に5.の結果(result
)を引数にそれぞれの関数を実行 - 渡された
Server Actions
(promise
(fn(...args)
))を返却
これで withCallbacks
を用いた useActionState
のハンドリング方法の解説は以上です。
withCallacksのTの型について
筆者の場合、Conform
というform管理ライブラリで Server Actions
の実装をしていたので、そこで提供されている型(SubmissionResult<string[]>
)を withCallbacks
の T
の制限(extends
)に使用しています。
しかしながら、ポスト主は ActionState
という型を使用していました。実際のところ、以下の型を使用していたようですので、Conform
使っていない方は、以下を参考にしてみてください。
export type ActionState =
| {
message: string;
status: "SUCCESS" | "ERROR";
}
| null
| undefined;
おわりに
筆者は、このポストに出会うまで、① useActionStateのコールバックでのハンドリングの方法を使用していました。
今後は、withCallbacks
を使用したハンドリングを使用・推奨していこうと思っていますし、これが現時点(2025年3月時点)では最もベストプラクティスな方法だと考えています。
ここまで、読んでくださり、ありがとうございました。
少しでも参考になれば幸いです。
参考文献
Discussion
[should] onEndの実行とPromise解決の順番は逆ですかね?
ありがとうございます
先にPromise解決しないとonEndが正しく発生しないですね
修正しておきます👍