Closed16

Remix Singles やってみる💽

KeiKei

はじめに

Remix公式より、以下のポストがありました。
個人的にRemixは興味のある技術なので、学んだことをスクラップに残しておこうと思います。
動画は全部で15個あります。

今からクリスマスまで、毎日リミックス シングルをリリースします。各リミックス シングルは、高度な UX を備えた Trello クローンである Trellix に基づいています。ユーザー認証、保留中およびオプティミスティック UI、ドラッグ アンド ドロップなどのトピックを取り上げています。 🧵

https://x.com/remix_run/status/1734936208025735353?s=20

実際のコードはこちらから↓
https://github.com/remix-run/example-trellix/tree/main

KeiKei

1. Forms: GET vs. POST and Remix Actions

概要

  • GETメソッドとPOSTメソッドを使用した、フォーム送信の違い
  • Remixのactionを使用して、フォームデータにアクセスする方法

フォームを用いた、GET・POSTの挙動の違い

  • GETの場合、URLのクエリ文字列にフォームデータが含まれる
    • http://example.com/signup/name=〇〇&password=〇〇
  • POSTの場合、HTTPリクエストのBodyに格納される、データはユーザーから見えない

フォームデータにサーバー側からアクセスするには?

  • actionを定義する
    • actionを定義しないと、Postリクエスト送信時にメソッドが許可されない
  • reqest.formData()を使うことでフォームデータを取得できる。
    (MDN参考) → https://developer.mozilla.org/ja/docs/Web/API/Request/formData
  • <Form >のデフォルトがgetのため、method="post"を明示する
サインアップ用のルート
export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
}

<Form method="post">・・・<Form />

https://www.youtube.com/watch?v=RTHzZVbTl6c&list=PLXoynULbYuED9b2k5LS44v9TQjfXifwNu

KeiKei

2. Form Validation

概要

  • actionでフォームデータを検証して、クライアント側に適切なエラーメッセージを表示させる

受信したフォームデータの検証

  • メールアドレスとパスワードが適切な形式であるかをチェック
サインアップ用のルート
export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const email = String(formData.get("email") || "");
  const password = String(formData.get("password") || "");
  // エラー情報を格納するオブジェクト
  const errors: { email?: string; password?: string } = {};
  
  // メールアドレスのチェック
  if (!email) {
    errors.email = "Email is required.";
  } else if (!email.includes("@")) {
    errors.email = "Please enter a valid email address.";
  }
  // パスワードのチェック
  if (!password) {
    errors.password = "Password is required.";
  } else if (password.length < 8) {
    errors.password = "Password must be at least 8 characters."
  }

  return Object.keys(errors).length ? errors : null;
}

エラーメッセージの表示

  • useActionDataを使ってactionの返り値(エラーオブジェクト)を取得する
  • 各エラーが存在する場合、UI上に表示されるようにする
サインアップ用のルート
export default function Signup() {
  const actionResult = useActionData<typeof action>();
  const emailError = actionResult?.errors?.email;
  const passwordError = actionResult?.errors?.password;
  return (
    <Form method="post">
      <p>
        <input type="email" name="email" />
        {emailError ? (
          <em>{emailError}</em>
        ) : null}
      </p>
      <p>
        <input type="password" name="password" />
        {passwordError ? (
          <em>{passwordError}</em>
        ) : null}
      </p>
      <button type="submit">Sign Up</button>
    </Form>
  );
}

参考

https://remix.run/docs/en/main/guides/form-validation

https://www.youtube.com/watch?v=e6LkNsa4fWk&list=PLXoynULbYuED9b2k5LS44v9TQjfXifwNu&index=2

KeiKei

3. Module Co-Location with Route Folders

概要

  • Remix v2のルーティング規則により、routeをフォルダに変換できる。
  • これを用いれば、ルートと関連ロジックをクリーンな状態に保つことができる

memo

  • RemixのV2の機能で、各ルートに関連するファイルをコロケーション的にまとめられる。
    • route.tsxがルートとしての役割を持つファイルで、routesフォルダ配下に任意のフォルダ(今回はsignup)を作ってその中にroute.tsxを置けば、/signupに対応するルートとなる。
    • DBへのクエリ処理や、バリデーション処理を別ファイルに分けて管理しつつ、近くに置いておけるので見やすい。
↓ こんな感じでsignupのルートと関連処理を同じフォルダ内にまとめる

./app/routes/signup/
├── queries.ts
├── route.tsx
└── validate.ts

他のルートもこんな感じ↓

https://www.youtube.com/watch?v=xEl8OCMOf_I&list=PLXoynULbYuED9b2k5LS44v9TQjfXifwNu&index=3

KeiKei

4. Cookies in Remix 🤔ちょいむずかったので復習する

概要

  • cookieは、サーバーがHTTPレスポンスでユーザーに送信する小さな情報のこと。
  • そのユーザーのブラウザは以降のリクエストでそれを送り返す。
  • ユーザー認証を管理するためにクッキーを作成・書き込み・読み取る方法を学ぼう。

https://remix.run/docs/en/main/utils/cookies#cookies

クッキーの作成

  • Remix側で抽象化してくれる、createCookieでクッキー作成する。
  • 第2引数で色々オプションつけることができる
import { createCookie } from "@remix-run/node";

// クッキー作成
const authCookie = createCookie("auth", {
  secrets: [secret],
  maxAge: 30 * 24 * 60 * 60,
  httpOnly: true, // サーバー上のHTTP経由でのみアクセス可能(JSでアクセス不可)
  secure: process.env.NODE_ENV === "production",
  sameSite: "lax",
});

クッキー書き込み

  • action関数で書き込む
  • Cookieヘッダーが追加された後、ブラウザ上でクッキーをローカル保存する
    • 以降はこのクッキーをリクエスト毎にサーバーに送信する
// 新規登録処理があるので、ユーザーIDが取得できていると仮定
・・・
// レスポンスヘッダにクッキーをセットして、リダイレクト
retutn redirect("/", {
  headers: {
    "Set-Cookie": await authCookie.serialize(user.id),
  },
});

クッキー読み取り

  • loader関数で読み取る
export async function loader({ request }: LoaderFunctionArgs) {
  const  cookieString = request.headers.get("Cookie");
  const userId = await authCookie.parse(cookieString);
  return { userId };
}

https://www.youtube.com/watch?v=ivmumaIZrJM&list=PLXoynULbYuED9b2k5LS44v9TQjfXifwNu&index=4

KeiKei

5. Creating User Accounts

概要

  • サインアップ時、ユーザーがすでに存在するかチェックして、ユーザー検証を強化させる
queries.ts
// DB上に同一メールアドレスが存在するかチェックする関数
export async function accountExists(email: string) {
  const account = await prisma.account.findUnique({
    where: { email: email },
    select: { id: true },
  });

  return Boolean(account);
}

こんな感じで呼び出す

validate.ts
const errors: { email?: string; password?: string } = {};
if (!errors.email && (await accountExists(email))) {
    errors.email = "An account with this email already exists.";
}

https://www.youtube.com/watch?v=An3tSvXnaq8&list=PLXoynULbYuED9b2k5LS44v9TQjfXifwNu&index=5

KeiKei

6. Implementing Logout

概要

  • ログアウト時に、認証用のクッキーを無効にしてリダイレクトさせる方法を学ぶ

フォームの送信先となるURLをactionで定義する

logout.ts
export async function action() {
  return redirect("/", {
    headers: {
      "Set-Cookie": await authCookie.serialize("", {
        maxAge: 0,
      }),
    },
  });
}
  • 実際のフォームでactionに/logoutを指定
<form method="post" action="/logout">
      <button>Log out</button>
</form>

https://www.youtube.com/watch?v=-_vX_bp8m08&list=PLXoynULbYuED9b2k5LS44v9TQjfXifwNu&index=6

KeiKei

7. Logging in Users

概要

  • サインアップしてログアウトしたユーザーが、再度ログインできるようにする
ログイン用のルート
export async function action({ request }: DataFunctionArgs) {
  const formData = await request.formData();
  const email = String(formData.get("email") || "");
  const password = String(formData.get("password") || "");

  const errors = validate(email, password);
  if (errors) {
    return json({ ok: false, errors }, 400);
  }

  // ログインユーザーのID取得
  const userId = await login(email, password);
  if (userId === false) {
    return json(
      { ok: false, errors: { password: "Invalid credentials" } },
      400,
    );
  }
  
  // レスポンスヘッダにクッキーをセットする 
  return redirect("/", {
    headers: {
      "Set-Cookie": await authCookie.serialize(userId),
    }
 });
}

https://www.youtube.com/watch?v=ywS4bUowh9c&list=PLXoynULbYuED9b2k5LS44v9TQjfXifwNu&index=7

KeiKei

8. Protecting Routes with Auth

概要

  • Remixでは、ユーザー認証が必要なルートを簡単に保護できる
  • 認証済みであればユーザーデータを取得し、そうでない場合ログインページにリダイレクトさせる処理実装

認証済みかどうかをチェックする処理実装

  • クッキーが存在しない(=認証できていない)と、ログインルートへ飛ばす
    • throw redirect()とできるのは学びだ。。
auth.ts
export async function requireAuthCookie(request: Request) {
  // リクエストヘッダからクッキー取得
  const userId = await authCookie.parse(request.headers.get("Cookie"));

  // クッキーが存在しない場合、リダイレクトをthrowする
  if (!userId) {
    throw redirect("/login", {
      headers: {
        "Set-Cookie": await cookie.serialize("", {
          maxAge: 0,
        }),
      },
    });
  }
  return userId;
}

loaderでクッキーを検証する

  • 認証が必要なルートでrequireAuthCookieを呼び出す
  • クッキーから取得したuserIdで以降の処理を進める。
ユーザー認証が必要なルート
export async function loader({ request }: LoaderFunctionArgs) {
  // クッキーを検証
  const userId = await requireAuthCookie(request);
  const boards = await getHomeData(userId);
  return { boards };
}

https://www.youtube.com/watch?v=eKHBAk1N7wo&list=PLXoynULbYuED9b2k5LS44v9TQjfXifwNu&index=8

KeiKei

9. Redirecting Logged in Users

概要

  • 認証されていないユーザーをルートから保護したいのは当然。
  • 同様に、認証されたユーザーを別のルートからリダイレクトさせたい方法を学ぶ
    • ログイン済みの場合はhomeルートへレンダリングさせる

rootルートからリダイレクトする 🙅

  • rootルートは常にレンダリングされている (内部的にReact Routerが採用)
    • rootルートからhomeルートへリダイレクトするとき、rootルートはアクティブなままなのでloader関数が何度も呼ばれてしまうので、無限にリダイレクトが走ってしまう。
root.tsx
export async function loader( { request }: LoaderFunctionArgs ) {
  const cookieString = request.headers.get("Cookie");
  const userId = await authCookie.parse(cookieString);
  if (userId) throw redirect("/home"); // ← これがダメ
  return { userId } 
}

export default function App() {
  return (・・・)
}
補足情報: rootにリダイレクト処理を書くには?
  • root.tsxでも無限リダイレクトを防ぐことは一応可能ではある
    • リクエスト時のパスが/であることが保証できていることを条件に追加する
root.tsx
export async function loader({ request }: DataFunctionArgs) {
  const cookieString = request.headers.get("Cookie");
  const userId = await authCookie.parse(cookieString);
  // クッキーが存在する かつ 現在のパスが`/`である場合 リダイレクトする
  if (userId && new URL(request.url).pathname === "/") {
    throw redirect("/home");
  }
  return { userId };
}

indexルートからリダイレクトする 🙆‍♀

  • indexルートは毎回レンダリングされるわけではない
  • レンダリング時にクッキーが存在する場合、homeルートへリダイレクトさせる
_index.tsx
export async function loader( { request }: LoaderFunctionArgs ) {
  const cookieString = request.headers.get("Cookie");
  const userId = await authCookie.parse(cookieString);
  if (userId) throw redirect("/home");
  return null; // このルートでuserIdは使用しないのでnullを返す
}

export default function Index() {
  return (・・・)
}

Remix側で、将来的にミドルウェアを作ってくれるらしいです

https://www.youtube.com/watch?v=MxR6X2jTLGU&list=PLXoynULbYuED9b2k5LS44v9TQjfXifwNu&index=9

KeiKei

10. Creating Records

概要

  • 認証されたユーザーが新規ボードを作成して、自動的に閲覧できるようにする
  • この章ではRemixのデータフローについて語る内容の動画である
    1. フォームを用意
    2. フォームからPostリクエスト
    3. actionでDB内のデータ更新(ミューテーション)
    4. データが自動的に再検証
    5. 保留中UI
  • Remixは、SPAなど素のReactを用いたuseState, useEffectなどの煩雑な状態管理を、シンプルにする

https://www.youtube.com/watch?v=8_bjkusslcY&list=PLXoynULbYuED9b2k5LS44v9TQjfXifwNu&index=10

KeiKei

11. Redirecting to New Records

概要

  • リダイレクトをreturnすることで、新規ボード作成後のUXを向上させることができる

新規ボード作成後は、各ボードのルートへリダイレクトさせよう

  • /homeでボードを作成できるが、ボード作成後、ホーム画面に留まることは少ない。
  • 作成したボードへのルートに飛ばしてあげたほうがユーザー体験がよい
ホーム用のルート
export async function action({ request }: ActionFunctionArgs) {
  const userId = await requireAuthCookie(request);
  const formData = await request.formData();
  const name = String(formData.get("name"));
  const color = String(formData.get("color"));
  if (!name) throw new Response("Bad Request", { status: 400 });
   
  // 入力情報から新規ボード作成する
  const board = await createBoard(userId, name, color);
  // 作成後のボードルートへリダイレクトさせる
  return redirect(`/board/${board.id}`);
}

https://www.youtube.com/watch?v=Zc-qwU8g_bw&list=PLXoynULbYuED9b2k5LS44v9TQjfXifwNu&index=11

KeiKei

12. User Feedback with Busy Indicators

概要

  • useNavigationを使うと、保留中UIをレンダリングすることができる
  • アプリがネットワーク通信を行っている間に、ユーザーに良いフィードバックを与えよう

https://remix.run/docs/en/main/hooks/use-navigation#usenavigation

useNavigationでクライアント側に実装する

  • ボタンを押すと、別ルートに遷移するがそのときに保留中のUIがわかるようにする
  • useNavigationを呼び出す
    • navigation.locationは何もしないとundefined、データロード中に次のlocationが設定される
    • しかしブラウザの進むボタン押下時にも、locationに入ってしまうため期待通りの挙動にはならないので、以降で改善する。
ホーム用のルート
export async function action({ request }: ActionFunctionArgs) {
  ・・・
  return redirect(`/board/${board.id}`);
}

// ボード作成用のコンポーネント
function NewBoard() {
  const navigation = useNavigation();
  return (
    ・・・
     <button type="submit">
       {navigation.location ? "Creating..." : "Create"} // 動作としてはOKだが、locationで判断するのを変えたい🤔
     </button>
    ・・・
  )
}

ちょっとしたテクニックで改善してみる

  • ユーザーに見えない<input type="hidden">を用意しておく。
    • nameにはintent、valueはフォームで実行したいことを記載。
    • 基本的に1ファイルにつき定義できるactionは1つであるため、delete系の処理とcreate系の処理を両方書きたいときにvalueを見て判断するとかできるっぽい。
  • navigationは保留中の間、navigation.formDataにフィールド値を持っている
    • これを利用して、isCreatingフラグで判断する形に変更する
function NewBoard() {
  const navigation = useNavigation();
  // ナビゲーションが保留中か判断するフラグ()
  const isCreating = navigation.formData?.get("intent") === "createBoard";

  return (
    <Form method="post">
      // ユーザには見せないinput ↓
      <input type="hidden" name="intent" value="createBoard" /> 
      <div>
        <LabeledInput label="Name" name="name" type="text" required />
      </div>
      <div>
          <label htmlFor="board-color">Color</label>
          <input
            id="board-color"
            name="color"
            type="color"
          />
        </div>
        <button type="submit">{isCreating ? "Creating..." : "Create"}</button> 
      </div>
    </Form>
  );
}

https://www.youtube.com/watch?v=agYaOQEFPns&list=PLXoynULbYuED9b2k5LS44v9TQjfXifwNu&index=12

このスクラップは2024/01/27にクローズされました