たかぎとねこの忘備録

プログラミングに関する忘備録を自分用に残しときます。マサカリ怖い。

React Native用にRemixのuseActionDataもどきを作ってみる話

僕がWebアプリを開発するときはもっぱらRemixフレームワークを利用する。

remix.run

理由は単純で、loader関数とaction関数の考え方が大好きだからだ。

React Nativeのアプリの開発をするときも、Remixのloader関数とaction関数を使いたい。 しかし、そんな機構はもちろんReact Nativeには存在しない。

そこで、action関数もどきをReact Nativeで再現するためにuseActionDataもどきを作ってみる。

useLoaderは別の機会に作ることにする。

引数

引数としてaction関数を受け取れるようにする。

T型は、formで送信された内容を表す。

R型は、いわゆるaction関数の返り値のActionData型を表す。

createMockActionRequest関数は、前に書いた記事で作った関数とほぼ同じ。

takagimeow.hatenablog.com

作成されるrequestオブジェクトではformData関数を持ってて、Remixで扱うのと同じようにformDataの内容を引き出すことができる。

function useActionData<
  T extends { [key: string]: string },
  R extends { [key: string]: any }
>(
  action: ({
    request,
  }: {
    request: ReturnType<typeof createMockActionRequest>;
  }) => Promise<R>
)

ロジック

useActionDataもどきの内部では、React QueryのuseMutationを使うことにする。

react-query.tanstack.com

useMutationの返り値のdataをそのまま返すのではなくて、本家のuseActionDataをエミュレートするためにデータの取得がない場合はundefinedを返したい。 なので、actionDataステートを用意して、action関数の成功時に取得した値をセットするようにする。

mutateをそのまま返すのでは意図が伝わりづらいので、submitという名前に変えてクライアントに返す。

const { data, isSuccess, mutate } = useMutation((props: T) =>
    action({ request: createMockActionRequest<T>(props) })
);
// 内部でキャッシュしておくaction関数の結果
const [actionData, setActionData] = useState<typeof data | undefined>(
    undefined
);

useEffect(() => {
    // データの取得に成功した場合はキャッシュする
    if (isSuccess && data) {
      setActionData(data);
    }
}, [data, isSuccess]);

const submit = useCallback(mutate, []);

完成

テストも含めて書き終えるとこんな感じになった

// src/hooks/useActionData.test.ts

import { afterAll, test, vitest, expect } from "vitest";
import { renderHook, act } from "@testing-library/react-hooks";
import { QueryClient, useMutation, QueryClientProvider } from "react-query";
import { createMockActionRequest } from "../createMockActionRequest";
import React, { useCallback, useEffect, useState } from "react";

afterAll(() => {
  vitest.clearAllMocks();
});

function useActionData<
  T extends { [key: string]: string },
  R extends { [key: string]: any }
>(
  action: ({
    request,
  }: {
    request: ReturnType<typeof createMockActionRequest>;
  }) => Promise<R>
) {
  const { data, isSuccess, mutate } = useMutation((props: T) =>
    action({ request: createMockActionRequest<T>(props) })
  );
  // 内部でキャッシュしておくaction関数の結果
  const [actionData, setActionData] = useState<typeof data | undefined>(
    undefined
  );

  useEffect(() => {
    // データの取得に成功した場合はキャッシュする
    if (isSuccess && data) {
      setActionData(data);
    }
  }, [data, isSuccess]);

  const submit = useCallback(mutate, []);

  return {
    actionData,
    submit,
  };
}

test("smoke test", async () => {
  // action関数を準備する
  const action = async ({
    request,
  }: {
    request: ReturnType<typeof createMockActionRequest>;
  }) => {
    return {
      fields: {
        name: request.formData().get("name"),
        email: request.formData().get("email"),
      },
    };
  };
  // wrapperを準備する
  const queryClient = new QueryClient()
  const wrapper = ({ children }: { children: React.ReactNode }) => {
    return (
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    )
  };
  const { result } = renderHook(() => useActionData(action), { wrapper });

  // action関数を呼び出す
  await act(async () => {
    result.current.submit({
      name: "田中太郎",
      email: "example@example.com",
    })
  });

  // action関数の結果が期待値であることを検証する
  expect(result.current.actionData).toEqual({
    fields: {
      name: "田中太郎",
      email: "example@example.com",
    }
  });
});

実際に使ってみて、使いづらかったら中身変えるかもしれない。ご愛嬌。