Closed16
Remix Singles やってみる💽
はじめに
Remix公式より、以下のポストがありました。
個人的にRemixは興味のある技術なので、学んだことをスクラップに残しておこうと思います。
動画は全部で15個あります。
今からクリスマスまで、毎日リミックス シングルをリリースします。各リミックス シングルは、高度な UX を備えた Trello クローンである Trellix に基づいています。ユーザー認証、保留中およびオプティミスティック UI、ドラッグ アンド ドロップなどのトピックを取り上げています。 🧵
実際のコードはこちらから↓
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 />
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>
);
}
参考
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
他のルートもこんな感じ↓
4. Cookies in Remix 🤔ちょいむずかったので復習する
概要
-
cookie
は、サーバーがHTTPレスポンスでユーザーに送信する小さな情報のこと。 - そのユーザーのブラウザは以降のリクエストでそれを送り返す。
- ユーザー認証を管理するためにクッキーを作成・書き込み・読み取る方法を学ぼう。
クッキーの作成
- 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 };
}
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.";
}
6. Implementing Logout
概要
- ログアウト時に、認証用のクッキーを無効にしてリダイレクトさせる方法を学ぶ
action
で定義する
フォームの送信先となるURLを 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>
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),
}
});
}
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 };
}
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側で、将来的にミドルウェアを作ってくれるらしいです
10. Creating Records
概要
- 認証されたユーザーが新規ボードを作成して、自動的に閲覧できるようにする
- この章ではRemixのデータフローについて語る内容の動画である
- フォームを用意
- フォームからPostリクエスト
- actionでDB内のデータ更新(ミューテーション)
- データが自動的に再検証
- 保留中UI
- Remixは、SPAなど素のReactを用いたuseState, useEffectなどの煩雑な状態管理を、シンプルにする
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}`);
}
12. User Feedback with Busy Indicators
概要
-
useNavigation
を使うと、保留中UIをレンダリングすることができる - アプリがネットワーク通信を行っている間に、ユーザーに良いフィードバックを与えよう
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
を見て判断するとかできるっぽい。
- nameには
-
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>
);
}
13. In-Place Optimistic UI
概要
- アクション送信後、loaderが再検証が行われる
- そのときにstateがちらつくのを回避するために、In-Placeで楽観的UIを作成しよう
14. Optimistic Add
概要
- 単一の
useState
やuseEffect
はなるべく使わない -
useSubmit
とuseFetchers
を活用して、複数フォームを楽観的に更新する複雑なケースを学ぼう
15. Optimistic Drag and Drop
概要
- Remixのシンプルなプリミティブを用いて、楽観的なDrag and Drop を実装
このスクラップは2024/01/27にクローズされました