💥

Next.js v14 & React v18.3 互換の useActionState

2024/07/08に公開

前置き

Next.js で server action を利用する際に、実行中の状態を判定したい機会があり、 useActionState を使いたくなったのですが、現時点では、Next.js v14 と React v18 を利用している環境では、まだ利用できないこともあり、それに変わる方法として代替の hooks を実装したのでご紹介します。

useActionState とは

React 19 RC のリリース記事を見ると、React 19 で、React 18 Canary で追加された useFormState から改名したものであり、

React.useActionState は以前の Canary リリースでは ReactDOM.useFormState と呼ばれていましたが、名前を変更し、useFormState を非推奨にしました。
詳細は #28491 を参照してください。
https://ja.react.dev/blog/2024/04/25/react-19#new-hook-useactionstate

また、上記に記載の React への PR では、以下のように紹介されており、

this hook is the automatic tracking of the return value and pending states of the wrapped function.
意訳: このフックはラップされた関数の戻り値とペンディング状態を自動的にトラッキングします。

上述のうちの「ペンディング状態のトラッキング」ができる点が、useFormState とは異なる点である事がわかります。

useFormStateuseTransition で再現する

そこで、「ペンディング状態のトラッキング」ができる useFormState が実装できれば、useActionState の代替になりうると考え、@types/react-dom/canary.d.ts を参考に useActionStateCompat として、以下のように実装してみました。

import { useCallback, useTransition } from "react";
import { useFormState } from "react-dom";

/**
 * @see {@link https://github.com/DefinitelyTyped/DefinitelyTyped/blob/0b728411cd1dfb4bd26992bb35a73cf8edaa22e7/types/react/canary.d.ts#L103-L112}
 */
export function useActionStateCompat<State>(
  action: (state: Awaited<State>) => State | Promise<State>,
  initialState: Awaited<State>,
  permalink?: string,
): [state: Awaited<State>, dispatch: () => void, isPending: boolean];
export function useActionStateCompat<State, Payload>(
  action: (state: Awaited<State>, payload: Payload) => State | Promise<State>,
  initialState: Awaited<State>,
  permalink?: string,
): [
  state: Awaited<State>,
  dispatch: (payload: Payload) => void,
  isPending: boolean,
];
export function useActionStateCompat<State, Payload>(
  action: (state: Awaited<State>, payload: Payload) => State | Promise<State>,
  initialState: Awaited<State>,
  permalink?: string,
) {
  const [isPending, startTransition] = useTransition();

  const [currentState, dispatchAction] = useFormState(
    action,
    initialState,
    permalink,
  );

  const finalAction = useCallback(
    (payload: Payload) => {
      startTransition(() => {
        dispatchAction(payload);
      });
    },
    [dispatchAction],
  );

  return [currentState, finalAction, isPending];
}

使い方

基本的に、useFormState と同じ形式で利用でき、期待通り「実行中」の状態を戻り値の配列の3つめの値から参照できます。


type FormState = { errors: { word?: string } }

const myAction = async (state: FromState, payload: FormData) => {
    "use server";

    const newState = structuredClone(state);

    if (!payload.get('word')) {
        newState.errors.word = '入力してください'
    }

    return newState;
}

function MyForm() => {
  const [currentState, action, isPending] = useActionState(myAction, { errors: {} });

  return (
    <form action={action}>
      <div>
        <input type="text" name="word" />
        {currentState.errors.word && <div>{currentState.errors.word}</div> }
      </div>
      <button disabled={isPending}>Submit</button>
    </form>
  );
}

結び

React 19 、Next.js 15 がリリースされるころには不要になるとは思いますが、リリース後は useFormState が廃止予定となるため、その間に Server Action を積極的に利用しているプロジェクトでは、このようなフックを用意しておくと良いのではないでしょうか?

以下は、useActionStateCompat を先に紹介した方法で実装し、パッケージ化したものになります。
プロジェクト毎に用意するのが面倒な場合にご利用ください。

https://github.com/strozw/use-action-state-compat

株式会社ゆめみ

Discussion