Remixを触ってみる
まずは公式のチュートリアルやる。
npm run dev
でエラー
Could not locate @remix-run/serve. Please verify you have it installed to use the dev command.
npm ERR! code ELIFECYCLE
npm ERR! errno 1
npm ERR! remix-app-template@ dev: `remix dev`
npm ERR! Exit status 1
npm ERR!
npm ERR! Failed at the remix-app-template@ dev script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.
nodeのバージョンが原因らしい
useLoaderDataを使う
useLoaderData
バックエンドとフロントエンドを繋ぐapiとして活用。
import {useLoaderData} from "remix";
// json格納(バックエンドの代わり)
export let loader = ()=>{
return[
{
slug:"my-first-post",
title:'My First Post'
},
{
slug:"90s-mixtape",
title:'A Mixtape I Made Just For You'
}
]
}
export default function Posts(){
// フロント側でjson受け取り
let posts = useLoaderData()
console.log(useLoaderData())
return(
<div>
<h1>Posts</h1>
</div>
);
}
チュートリアル完走。
クラスメソッドの記事読みながら振り返り
Joke Appの前に概念や思想など抽象度高い部分を把握したい。
サーバー/クライアントモデル
RemixはSSGなし→動的にサイトを作る。
Next.jsはSSG,SSRをどっちも使える。
SSGって??
Static Site Generationの略。
Pre-renderingの一種。
Pre-renderingは空のhtmlを読み込んでから、Jsをロードするのではなく、
html、Jsを同時に読み込む。
SSGでは外部からのデータ取得はgetStaticProps関数を使う。
ビルド時1回だけデータフェッチして、ビルドされた後にデータを変更することができないので、外部データが動的に変わるサイトには向かない。
SSRって??
Server-side Rendering の略。
もう一つのPre-rendering。
ユーザーからのリクエストの度にhtmlを書き換える。
なので、SSGよりは当然遅い。
ビルド後にもユーザーのリクエストに応じてページを更新できる。
まとめ
Remixはデータをフェッチする前にフィルターをかけて、ネットワークで通信するデータ量を減すなど、SSRに絞っている分、動的なパフォーマンスはNext.jsより高そう。
→これはNestedRoutingによる恩恵も絡んでくる。
ルーティング
Outletの使い方
親コンポーネント側
import {Outlet} from "remix";
export default function JokesRoute() {
return (
<div>
<h1>J🤪KES</h1>
<main>
<Outlet/> // 子コンポーネントを描画
</main>
</div>
)
}
子コンポーネント
export default function JokesIndexRoute(){
return(
<p>random joke:</p>
);
}
画面で/jokes
子コンポーネントとしてjokes/index.tsx
が描画される。
ルートをjokes/new
にしたら、子コンポーネントはjokes/new.tsxが描画される。
スタイリング
linksの使い方
リファレンス
ユーザーがルートにアクセスしたときにページに追加する要素を定義するらしい。
今回だとstylesheet
データベース接続
prisma使う
流れ
- prismaを使ってdbにデータを入れる
- remix側で
LoaderFunction
を使ってデータを受け取る
seed.ts
を使ってdev.db
に格納。
dbにデータを入れる際に、今回は
import { PrismaClient } from "@prisma/client";
const db = new PrismaClient();
async function seed() {
await Promise.all(
getJokes().map(joke => {
return db.joke.create({ data: joke });
})
);
}
seed();
function getJokes() {
// shout-out to https://icanhazdadjoke.com/
return [
{
name: "Road worker",
content: `I never wanted to believe that my Dad was stealing from his job as a road worker. But when I got home, all the signs were there.`
},
{
name: "Frisbee",
content: `I was wondering why the frisbee was getting bigger, then it hit me.`
},
{
name: "Trees",
content: `Why do trees seem suspicious on sunny days? Dunno, they're just a bit shady.`
},
{
name: "Skeletons",
content: `Why don't skeletons ride roller coasters? They don't have the stomach for it.`
},
{
name: "Hippos",
content: `Why don't you find hippopotamuses hiding in trees? They're really good at it.`
},
{
name: "Dinner",
content: `What did one plate say to the other plate? Dinner is on me!`
},
{
name: "Elevator",
content: `My first time using an elevator was an uplifting experience. The second time let me down.`
}
];
}
remix側で受け取り
import type {LoaderFunction} from "remix";
import {Link, useLoaderData} from "remix";
import type {Joke} from "@prisma/client";
import {db} from "~/utils/db.server";
type LoaderData = { joke: Joke };
export const loader: LoaderFunction = async ({
params
}) => {
const joke = await db.joke.findUnique({
where: {id: params.jokeId}
})
if (!joke) throw new Error("Joke not found");
const data: LoaderData = {joke};
return data
}
export default function JokeRoute() {
const data = useLoaderData<LoaderData>()
return (
<div>
<p>Here's your hilarious joke:</p>
<p>{data.joke.content}</p>
<Link to=".">{data.joke.name} Permalink</Link>
</div>
)
}
LoaderFunctionでそのページで使うデータを受け取る
export const loader: LoaderFunction = async ({
params
}) => {
const joke = await db.joke.findUnique({
where: {id: params.jokeId}
})
if (!joke) throw new Error("Joke not found");
const data: LoaderData = {joke};
return data
}
dom部分でデータを描画する
export default function JokeRoute() {
const data = useLoaderData<LoaderData>()
return (
<div>
<p>Here's your hilarious joke:</p>
<p>{data.joke.content}</p>
<Link to=".">{data.joke.name} Permalink</Link>
</div>
)
}
loader
リファレンス
LoaderFunctionとは?
ルートにデータを提供するためにレンダリングする前にサーバーで呼び出される関数
action
リファレンス
データの変更やその他のアクションを処理するためのサーバーのみの機能です。ルートに対して非GET要求(POST、PUT、PATCH、DELETE)が行われると、ローダーの前にアクションが呼び出されます。
get以外のrequestのタイミングで発火する関数
バリデーション
useActionData
を用いる。
action発火時のデータを返す。主にバリデーションに使う。
バリデーションの基本形
流れとしては
- formをpostした時に、
action
フックが発火 -
action
内でバリデーションのロジックを書いて、errorsとしてreturn -
action`の戻り値を
useActionData``で取得、バリデーションメッセージに反映 - errorでない場合は、postの内容を実行
import { redirect, json, Form, useActionData } from "remix";
// ②
export function action({ request }) {
let form = await request.formData();
let email = form.get("email");
let password = form.get("password");
let errors = {};
if (typeof email !== "string" || !email.includes("@")) {
errors.email =
"That doesn't look like an email address";
}
if (typeof password !== "string" || password.length < 6) {
errors.password = "Password must be > 6 characters";
}
if (Object.keys(errors).length) {
return json(errors, { status: 422 });
}
// ④
await createUser(body);
return redirect("/dashboard");
}
export default function Signup() {
// ③
let data = useActionData();
return (
<>
<h1>Signup</h1>
// ①
<Form method="post">
<p>
<input type="text" name="email" />
{errors?.email && <span>{errors.email}</span>}
</p>
<p>
<input type="text" name="password" />
{errors?.password && (
<span>{errors.password}</span>
)}
</p>
<p>
<button type="submit">Sign up</button>
</p>
</Form>
</>
);
}
useActionData
の使い方としてリファレンスでは
一般に、フォームの検証が失敗した場合は、アクションからデータを返し、コンポーネントでレンダリングしますが、実際に(データベースなどで)データを変更したら、リダイレクトする必要があります。
と書いてあります。
データを変更した際にリダイレクトしないと、変更後にブラウザバックした際に再度データを変更する事になるので注意が必要です。
例外として以下のパターンは例外のようです。
詳しくは今後。公式にも現時点であまり情報がない…
- <form>
- <Form reloadDocument>
- <Scripts/>をレンダリングしていない
- ユーザーがJavaScriptを無効にしている
画面更新の際にはnpm run build
が必要。
buildしていないと変化は現れんよね。(凡ミス…)。
npm run dev
で常時解決。
loaderでMVCのcontrollerの様にデータをいじって受け取れる。
これでいらないデータ通信を抑える。
export const loader: LoaderFunction = async () => {
const data: LoaderData = {
jokeListItems: await db.joke.findMany({
take: 5,
select: { id: true, name: true },
orderBy: { createdAt: "desc" },
}),
};
return data;
};
LoaderFunctionはパラメータを引数のparamsとして受け取るらしい。
//urlとidが一致するjokesを取得
export const loader: LoaderFunction = async ({ params }) => {
const joke = await db.joke.findUnique({ where: { id: params.jokeid } });
if (!joke) throw new Error("joke not found");
const data: LoaderData = { joke };
return data;
};
mutatioon
新しいformをdbに追加する。(create)
ActionFunction
を使う。
export const action: ActionFunction = async ({ request }) => {
const form = await request.formData();
const content = form.get("content");
const name = form.get("name");
// バリデーション
if (typeof name !== "string" || typeof content !== "string") {
throw new Error("Form not submit correctly");
}
const fields = { content, name };
const joke = await db.joke.create({ data: fields });
return redirect(`/jokes/${joke.id}`);
};
redirectした時には追加分のjokeも記載されていた。
→Remixは自動的にキャッシュを無効化している。
認証
prismaのモデル更新
Userテーブル追加。既にあるJokeとralation貼る。
User:1対 Joke:多 の関係
model User {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userName String @unique
passwardHash String
jokes Joke[]
}
model Joke {
id String @id @default(uuid())
jokesterId String
jokester User @relation(fields: [jokesterId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String
content String
}
onDelete: Cascade
で参照先が削除されたらデータ削除。
認証の画面側を作成
パスワードをハッシュさせる為に「bcryptjs」をinstall
logout機能
作る関数
getUser
requestからuserIdを取得し、そこからdb経由でuserを取得する関数。
エラー時はlogout実行
export async function getUser(request: Request) {
//userId取得
const userId = await getUserId(request);
if (typeof userId !== "string") return null;
try {
// userIdからuserデータを取得
const user = await db.user.findUnique({
where: { id: userId },
});
return user;
} catch {
//userが取得出来なければlogout
throw logout(request);
}
}
logout
sessionを破棄してlogoutする
export async function logout(request: Request) {
// requestからcookie経由でsessionを取得
const session = await storage.getSession(request.headers.get("cookie"));
// sessionを破棄してlogin画面に遷移
return redirect("/login", {
headers: {
"Set-Cookie": await storage.destroySession(session),
},
});
}
ActionFuctionとLoaderFunctionの発火タイミング違いが気になる。
import type { ActionFunction, LoaderFunction } from "remix";
import { redirect } from "remix";
import { logout } from "~/utils/session.server";
// logoutする
export const action: ActionFunction = async ({ request }) => {
return logout(request);
};
//home画面にredirect
export const loader: LoaderFunction = async () => {
return redirect("/");
};
(ローダーではなく)アクションを使用している理由は、GETリクエストではなくPOSTリクエストを使用してCSRFの問題を回避したいためです。
らしい。あんまり詳しくはまだ分からん
ユーザー登録機能を作る
- ユーザー登録のresigter関数を作る
- login.tsxで使える様にする
register関数
prismaのapiのcreate使う
passwordはbcryptを使ってhash化する。
export async function register({ username, password }: LoginForm) {
// passwordをhash化する
const passwordHash = await bcrypt.hash(password, 10);
return db.user.create({
data: {
username,
passwordHash,
},
});
}
login.tsx
ActionFunctionの中で実装。
switch (loginType) {
case "login": {
const user = await login({ username, password });
if (!user) {
return {
fields,
formError: `Username/Password combination is incorrect`,
};
}
return createUserSession(user.id, redirectTo);
}
case "register": {
const userExists = await db.user.findFirst({
where: { username },
});
if (userExists) {
return badRequest({
fields,
formError: `User with username ${username} already exists`,
});
}
const user = await register({ username, password });
if (!user) {
return badRequest({
fields,
formError: `Something went wrong trying to create a new user`,
});
}
}
default: {
return badRequest({
fields,
formError: `Login type invalid`,
});
}
}
};
エラー対応
ReactのError Boundaries
あまりこれ自体の内容も分かっていないので、この際にキャチアップする。
React 16からの機能。
- UIの一部のJavaScriptによりアプリ全体が壊れない様にする機能
- エラーが起きたコンポーネントのログを取り、クラッシュしたコンポーネントの代わりにUIをフォールバックするコンポーネント
RemixにおけるError Boundaries
実際にjoke-appに加えて行く
export function ErrorBoundary({ error }: { error: Error }) {
return (
<Document title="UH-oh!">
<div className="error-container">
<h1>App Error</h1>
</div>
</Document>
);
}
CatchBoundaries
クライアントエラー(400系)に対してはCatchBOundariesを使う
CatchBoundaryは、アクションまたはローダーがをスローするたびにレンダリングするReactコンポーネント。
export function CatchBoundary() {
// response dataを取得
const caught = useCatch();
return (
<Document title={`${caught.status} ${caught.statusText}`}>
<div className="error-container">
<h1>
{caught.status} {caught.statusText}
</h1>
</div>
</Document>
);
}
Metaタグの追加
SEO対策用のMetaタグを設定できる。
export const meta: MetaFunction = () => {
const description = `Learn Remix and laugh at the same time!`;
return {
description,
keywords: "Remix,jokes",
"twitter:image": "https://remix-jokes.lol/social.png",
"twitter:card": "summary_large_image",
"twitter:creator": "@remix_run",
"twitter:site": "@remix_run",
"twitter:title": "Remix Jokes",
"twitter:description": description,
};
};
リソースルート
ルートをUIコンポーネントとしてではなく、汎用的なエンドポイントとして使う方法。
例)
- RemixUIでサーバー側のコードを再利用するモバイルアプリ用のJSONAPI
- PDFを動的に生成する
- ブログ投稿や他のページのソーシャル画像を動的に生成する
- StripeやGitHubなどの他のサービス用のWebhook
- ユーザーの好みのテーマのカスタムプロパティを動的にレンダリングするCSSファイル
今回はRSSフィードを作る。
RSSフィードとは?
- RSS(RDF Site Summary/Rich Site Summary)はXMLを応用したデータ形式の一種
- RSSフィードは特定のURLに置かれたRSS形式のデータ
- リーダーが一定時間ごとに自動的に更新の有無をチェックし、自動的に最新情報を取得して表示する
参考
作成後の画面
JavaScriptを無効化して今までは実装していた
Optimistic UI
凄い機能!!
サーバーとの通信が重い時に、ユーザーに見せるUI。
サーバー側のバリデーションで失敗する場合は、そのままエラーが返ってくるなどエラー対応も使ってない場合と同様になる。
ドキュメント
useTransition
を使う
これについてはもうちょい深く見ていきいたい
デプロイ
fly.ioでデプロイする
サインインする
flyctl auth signup
エラーが出る
You must be authenticated to view this.
fly.ioのサイトからサインアップすれば解決。