Closed7

Remix+CloudflareでWebサイトを作る 37(actionのエラーハンドリング、Single Fetch、さよならremix-typedjson、Prismaのsetとconnect)

saneatsusaneatsu

【2024-10-18】actionでエラーが発生するとフォームがリセットされるのでthrowせずにsubmission.reply()で値を返してuseFormに値を入れよう

conformとzodを使っているが、今までErrorBoundary()を使ってこんな感じにしていた。
本当は Errorもパターン化して型をつけている。

問題なのはactionでエラーが発生するとフォームが完全にリセットされてしまうこと。
どうやらエラーが発生したときは、throw するのではなく、actionでsubmission.reply() を返してそれを useFormlastResultに入れる必要があるっぽい。

エラーが発生したらなんでもErrorBoundary()でどうにかしてた。

export const action = async ({ request, context }: ActionFunctionArgs) => {
  const formData = await request.clone().formData();
  const submission = parseWithZod(formData, { schema });
  if (submission.status !== "success") {
    throw new Error(e);
    // ✅ return { lastResult: submission.reply() } みたいな感じに
  }

  try {    
    await update(submission.value);
    return json({ success: true });
  } catch (e: unknown) {
    throw new Error(e);
  }
};

function Layout({ children }: { children: React.ReactNode }) {  
  const actionData = useActionData<typeof action>();

  const [form, fields] = useForm({
    // ✅ lastResult: actionData.lastResult ここを書く必要がある
    constraint: getZodConstraint(schema),
    onValidate({ formData }) {
      return parseWithZod(formData, { schema });
    },
    shouldValidate: "onInput",
    defaultValue: {
      name: actionData?.values?.name || sponsoredAd?.name,
    },
  });

  return (
    <div>      
      <div>title</div>

      <Breadcrumb />

      {children}
      
      <div className="max-w-3xl mb-80">
        <Form
          method="POST"
          id={form.id}
          onSubmit={form.onSubmit}
          className="space-y-6"
        >
          <div className="flex justify-end mt-10">
            <Button type="submit">更新</Button>
          </div>
        </Form>
      </div>
    </div>
  );
}

export default function Page() {
  return (
    <Layout>
      <Outlet />
    </Layout>    
  );
}

// 🚨 throw するとこっちに入ってくる
export function ErrorBoundary() {
  const error = useRouteError(); 

  return (
    <Layout>
      <ErrorMessage error={handleError(error)} />
      <Outlet />
    </Layout>
  );
}
saneatsusaneatsu

やりながら思ったけどErrorBoundary()使わないようにするならlastResult入れなくてもいいのか。

あと、SuspenseとAwait使ってる場合AwaitのerrorElementでloaderでthrowしたエラーをキャッチするという実装もできるな。

saneatsusaneatsu

【2024-10-18】Remixをv2.13.0にアプデしてSingle Fetchに対応

https://x.com/techtalkjp/status/1847143356402651230

溝口さんのツイートで思い出した。

https://remix-docs-ja.techtalk.jp/guides/single-fetch

remix-typedjsontypedjsonとかtypeddefer使ってるから先にアプデしてSingle Fetchに対応しようかな。

Single Fetchとは

https://azukiazusa.dev/blog/single-fetch-in-remix/

  • クライアントサイドでのページ遷移が行われた際に、サーバーへの複数のHTTPリクエストを並行して行う代わりに、1つのHTTPリクエストを実行しまとめてレスポンスを返す機能
  • いくつかの破壊的な変更があるが、大きな変更を加えることなく導入可能
  • v2.9ではフィーチャーフラグとして提供されており、2.13.0で安定化、v3以降ではデフォルトの挙動になる
saneatsusaneatsu

ベストプラクティスがわからなさすぎるので、一旦以下のような型を作ってactionで返すようにする。

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

type SuccessJson = {
  success: true;
  lastResult: SubmissionResult<string[]>;
};

export function successJson({
  lastResult,
}: {
  lastResult: SuccessJson["lastResult"];
}): SuccessJson {
  return {
    success: true,
    lastResult,
  };
}

type ErrorJson = {
  success: false;
  error: string;
  lastResult: SubmissionResult<string[]>;
};

export function errorJson({
  lastResult,
  error,
}: {
  lastResult: ErrorJson["lastResult"];
  error: unknown;
}): ErrorJson {
  return {
    success: false,
    error: error instanceof Error ? error.message : String(error),
    lastResult,
  };
}

こんな感じで使う。

export const action = async ({ request, context }: ActionFunctionArgs) => {
  const formData = await request.clone().formData();
  const submission = parseWithZod(formData, { schema });
  const lastResult = submission.reply();

  if (submission.status !== "success") {
    return errorJson({ error: "入力した情報が正しくありません", lastResult });
  }

  try {    
    await update(submission.value);
    return successJson({ lastResult });
  } catch (e: unknown) {
    return errorJson({ error, lastResult });
  }
};
saneatsusaneatsu

【2024-10-19】Single Fetchにしたら Property 'xxx' does not exist on type 'unknown' と出て useLoaderDataで型推論がされない

以下のようなコードを書いたらuseLoaderDataProperty 'user' does not exist on type 'unknown'というエラーが出た。

redirect のimport先がミスっていた。
デバッグにさほど時間かからなかったけど、Remixはこれ系のエラーに度々遭遇してしまうな。

import { useLoaderData } from "@remix-run/react";
- import { redirect, } from "remix-typedjson";
+ import { redirect } from '@remix-run/cloudflare';

import type { LoaderFunctionArgs } from "@remix-run/cloudflare";

export async function loader({ request, context }: LoaderFunctionArgs) {
  const userId = await getUserId(request);
  if (!userId) {
    return redirect("/");
  }

  const user = await getUserById(context.db, userId);
  if (!user) {
    return redirect("/");
  }

  return { user };
}

export default function AdminUsersIndexPage() {
  const { user } = useLoaderData<typeof loader>(); // 🚨 ここ!

  return (
    <div>{user}</div>
  );
}
saneatsusaneatsu

Single Fetchに対応したのでremix-typedjsonとおさらば! 👋
やったーーー!

今までありがとう!🙏🏻

saneatsusaneatsu

【2024-10-19】Prismaのsetconnectの違い

調べる

https://qiita.com/taiyyytai/items/758f128a4e7c0bc548d2#connectsetってなに

  • connect: 追加する場合
  • disconnect: 一部を削除する
  • set: 新しいレコードへ置き換える

setを使う場合

  • レコードのフィールドの値を直接変更したいとき
    • ユーザーの名前、メールアドレス、プロフィールなどの変更
    • 投稿のタイトル、内容の変更
  • 既存の関連付けは維持したいとき
    • 新しい値に置き換えるだけで、他の関連付けは変更しない
  • 注意点:
    • setで関連付けを変更しようとすると、既存の関連付けが切れてしまう可能性がある

connectを使う場合

  • 既存のレコードと新しいレコードを関連付けたいとき
    • 投稿に作者を割り当てる
    • 注文に商品を紐づける
  • 既存のレコードの関連付けを変更したいとき
    • 投稿の作者を変更する
  • 注意点:
    • connectで指定したレコードは既に存在している必要がある。
    • createと組み合わせて新しいレコードを作成しながら、既存のレコードと関連付けることもできる

実際に使う

わかった気になっているだけかもしれないので動かして確認する。

例として記事にA、Bという2つのタグが紐づいているとする。
この時、Bを削除してCを新たに付け加えるという操作を行う。

  • 更新時にsetを使う
    • ✅ Bは削除される
    • ✅ Cが新たに付け加えられる
  • 更新時にconenctを使う
    • 🚨 Bは削除されない
    • ✅ Cが新たに付け加えられる

connect, disconnectを使ってもできるのでは

setは一度すべての関連付けを削除し、新たに指定されたIDとのレコードとの関連を再構築している。

特定の関連付けを維持したい場合、disconnectで削除対象を絞り込み、connectで新しい関連付けを追加するらしい。

多対多の場合

これも動かしてわかったけど多対多の場合はsetを使って直接更新しようとするとエラーになる。
既存のレコードを直接更新するものなので適していない。

この場合はcreatedeleteを使う。

このスクラップは1ヶ月前にクローズされました