Closed31

Remix の チュートリアルをやろう

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

プロジェクト作成

コマンド
npx create-remix@latest --template remix-run/remix/templates/remix-tutorial
コンソール出力
 remix   v2.11.2 💿 Let's build a better website...

   dir   Where should we create your new project?
         remix-tutorial

      ◼  Template: Using remix-run/remix/templates/remix-tutorial...
      ✔  Template copied

   git   Initialize a new git repository?
         Yes

  deps   Install dependencies with npm?
         Yes

      ✔  Dependencies installed

      ✔  Git initialized

  done   That's it!

         Enter your project directory using cd ./remix-tutorial
         Check out README.md for development and deploy instructions.

         Join the community at https://rmx.as/discord
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

CSS 読み込み

app/root.tsx(一部)
import { LinksFunction } from "@remix-run/node";
import {
  Form,
  Links,
  Meta,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";

import appStylesHref from "./app.css?url";

export const links: LinksFunction = () => [
  { rel: "stylesheet", href: appStylesHref },
];


スタイルシートが適用された。

import appStylesHref from "./app.css?url"; という書き方に興味がある。

https://remix.run/docs/en/main/styling/css

https://vitejs.dev/guide/features.html#static-assets

URL としてインポートするようだ。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

コーディング

コピーして OK ですよと書いてあるけどせっかくなので入力していこう。

app/routes/contacts.\$contactId.tsx
import { Form } from "@remix-run/react";
import { FunctionComponent } from "react";
import { ContactRecord } from "~/data";

export default function Contact() {
  const contact = {
    first: "Your",
    last: "Name",
    avatar: "https://placekitten.com/200/200",
    twitter: "your_handle",
    notes: "Some notes",
    favorite: true,
  };

  return (
    <div id="contact">
      <div>
        <img
          src={contact.avatar}
          key={contact.avatar}
          alt={`${contact.first} ${contact.last} avatar`}
        />
      </div>

      <div>
        <h1>
          {contact.first || contact.last ? (
            <>
              {contact.first} {contact.last}
            </>
          ) : (
            <i>No Name</i>
          )}{" "}
          <Favorite contact={contact}></Favorite>
        </h1>

        {contact.twitter ? (
          <p>
            <a href={`https://twitter.com/${contact.twitter}`}>
              {contact.twitter}
            </a>
          </p>
        ) : null}

        {contact.notes ? <p>{contact.notes}</p> : null}

        <div>
          <Form
            action="destroy"
            method="post"
            onSubmit={(event) => {
              const response = confirm(
                "Please confirm you want to delete this record."
              );

              if (!response) {
                event.preventDefault();
              }
            }}
          >
            <button type="submit">Delete</button>
          </Form>
        </div>
      </div>
    </div>
  );
}

const Favorite: FunctionComponent<{
  contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
  const favorite = contact.favorite;

  return (
    <Form method="post">
      <button
        aria-label={favorite ? "Remove from favorites" : "Add to favorites"}
        name="favorite"
        value={favorite ? "false" : "true"}
      >
        {favorite ? "★" : "☆"}
      </button>
    </Form>
  );
};
app/root.tsx(一部)
// existing imports
import {
  Form,
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";

// existing imports & code

export default function App() {
  return (
    <html lang="en">
      {/* other elements */}
      <body>
        <div id="sidebar">{/* other elements */}</div>
        <div id="detail">
          <Outlet />
        </div>
        {/* other elements */}
      </body>
    </html>
  );
}


猫の画像が表示されていないがサーバーがダウンしているようなので仕方ない。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

クライアントサイドルーティング

https://remix.run/docs/en/main/start/tutorial#client-side-routing

app/root.tsx
// existing imports
import {
  Form,
  Link,
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";

// existing imports & exports

export default function App() {
  return (
    <html lang="en">
      {/* other elements */}
      <body>
        <div id="sidebar">
          {/* other elements */}
          <nav>
            <ul>
              <li>
                <Link to={`/contacts/1`}>Your Name</Link>
              </li>
              <li>
                <Link to={`/contacts/2`}>Your Friend</Link>
              </li>
            </ul>
          </nav>
        </div>
        {/* other elements */}
      </body>
    </html>
  );
}

変化が分かりにくいがページの再読み込みが行われないようになっている。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

データのロード

app/root.tsx(一部)
// existing imports
import { json } from "@remix-run/node";
import {
  Form,
  Link,
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
} from "@remix-run/react";

// existing imports
import { getContacts } from "./data";

// existing exports

export const loader = async () => {
  const contacts = await getContacts();
  return json({ contacts });
};

export default function App() {
  const { contacts } = useLoaderData<typeof loader>();

  return (
    <html lang="en">
      {/* other elements */}
      <body>
        <div id="sidebar">
          {/* other elements */}
          <nav>
            {contacts.length ? (
              <ul>
                {contacts.map((contact) => (
                  <li key={contact.id}>
                    <Link to={`contacts/${contact.id}`}>
                      {contact.first || contact.last ? (
                        <>
                          {contact.first} {contact.last}
                        </>
                      ) : (
                        <i>No Name</i>
                      )}{" "}
                      {contact.favorite ? (
                        <span></span>
                      ) : null}
                    </Link>
                  </li>
                ))}
              </ul>
            ) : (
              <p>
                <i>No contacts</i>
              </p>
            )}
          </nav>
        </div>
        {/* other elements */}
      </body>
    </html>
  );
}


辛抱たまらなかったので useLoaderData に先走って型推論を追加してしまった。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

詳細ページのデータロード

app/routes/contacts.$contactId.tsx
import { LoaderFunctionArgs } from "@remix-run/node";
import { Form, json, useLoaderData } from "@remix-run/react";
import { FunctionComponent } from "react";
import invariant from "tiny-invariant";
import { ContactRecord, getContact } from "~/data";

export const loader = async ({ params }: LoaderFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const contact = await getContact(params.contactId);
  return json({ contact });
};

export default function Contact() {
  const { contact } = useLoaderData<typeof loader>();

  if (!contact) {
    throw new Response("Not found", { status: 404 });
  }

  // existing code
}


Edit ボタンを忘れていることに気がついた。


Not found メッセージはどこにいったのだろう。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Data Mutations

app/root.tsx(一部)
// existing imports

import { createEmptyContact, getContacts } from "./data";

export const action = async () => {
  const contact = await createEmptyContact();
  return json({ contact });
};

// existing code


New ボタンをクリックするとページの再読み込みなしで更新されるのが素晴らしい。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Updating Data

コマンド
touch app/routes/contacts.\$contactId_.edit.tsx

アンダーバーは何なのだろう?

Note the weird _ in contactId_. By default, routes will automatically nest inside routes with the same prefixed name. Adding a trailing _ tells the route to not nest inside app/routes/contacts.contactId.tsx. Read more in the Route File Naming guide.

https://remix.run/docs/en/main/file-conventions/routes

末尾に _ をつけることでネストしないようになるようだ。

app/routes/contacts.\$contactId_.edit.tsx
import { LoaderFunctionArgs } from "@remix-run/node";
import { Form, json, useLoaderData } from "@remix-run/react";
import invariant from "tiny-invariant";
import { getContact } from "~/data";

export const loader = async ({ params }: LoaderFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const contact = await getContact(params.contactId);
  if (!contact) {
    throw new Response("Not found", { status: 404 });
  }
  return json({ contact });
};

export default function EditContact() {
  const { contact } = useLoaderData<typeof loader>();

  return (
    <Form key={contact.id} id="contact-form" method="post">
      <p>
        <span>Name</span>
        <input
          defaultValue={contact.first}
          aria-label="First name"
          name="first"
          type="text"
          placeholder="First"
        />
        <input
          aria-label="Last name"
          defaultValue={contact.last}
          name="last"
          placeholder="Last"
          type="text"
        />
      </p>
      <label>
        <span>Twitter</span>
        <input
          defaultValue={contact.twitter}
          name="twitter"
          placeholder="@jack"
          type="text"
        />
      </label>
      <label>
        <span>Avatar URL</span>
        <input
          aria-label="Avatar URL"
          defaultValue={contact.avatar}
          name="avatar"
          placeholder="https://example.com/avatar.jpg"
          type="text"
        />
      </label>
      <label>
        <span>Notes</span>
        <textarea defaultValue={contact.notes} name="notes" rows={6}></textarea>
      </label>
      <p>
        <button type="submit">Save</button>
        <button type="button">Cancel</button>
      </p>
    </Form>
  );
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Updating Contacts with FormData

app/routes/contacts.$contactId_.edit.tsx(一部)
import type {
  ActionFunctionArgs,
  LoaderFunctionArgs,
} from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
// existing imports

import { getContact, updateContact } from "../data";

export const action = async ({
  params,
  request,
}: ActionFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const formData = await request.formData();
  const updates = Object.fromEntries(formData);
  await updateContact(params.contactId, updates);
  return redirect(`/contacts/${params.contactId}`);
};

// existing code

const updates = Object.fromEntries(formData); という書き方が気になる。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

lib.dom.iterable.d.ts で定義を見つけた

lib.dom.iterable.d.ts(抜粋)
interface FormData {
    [Symbol.iterator](): IterableIterator<[string, FormDataEntryValue]>;
    /** Returns an array of key, value pairs for every entry in the list. */
    entries(): IterableIterator<[string, FormDataEntryValue]>;
    /** Returns a list of keys in the list. */
    keys(): IterableIterator<string>;
    /** Returns a list of values in the list. */
    values(): IterableIterator<FormDataEntryValue>;
}

ようやく理解できた。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

https://remix.run/docs/en/main/start/tutorial#active-link-styling

NavLink というものを使う。

https://remix.run/docs/en/main/components/nav-link

他のコンポーネントに比べてあまりプリミティブではないのでどうして公式で用意されているのか気になる。

app/root.tsx
// existing imports
import {
  Form,
  Links,
  Meta,
  NavLink,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
} from "@remix-run/react";

// existing imports and exports

export default function App() {
  const { contacts } = useLoaderData<typeof loader>();

  return (
    <html lang="en">
      {/* existing elements */}
      <body>
        <div id="sidebar">
          {/* existing elements */}
          <ul>
            {contacts.map((contact) => (
              <li key={contact.id}>
                <NavLink
                  className={({ isActive, isPending }) =>
                    isActive
                      ? "active"
                      : isPending
                      ? "pending"
                      : ""
                  }
                  to={`contacts/${contact.id}`}
                >
                  {/* existing elements */}
                </NavLink>
              </li>
            ))}
          </ul>
          {/* existing elements */}
        </div>
        {/* existing elements */}
      </body>
    </html>
  );
}


選択されているコンタクトがわかりやすくなった。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Global Pending UI

https://remix.run/docs/en/main/start/tutorial#global-pending-ui

app/root.tsx
// existing imports
import {
  Form,
  Links,
  Meta,
  NavLink,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
  useNavigation,
} from "@remix-run/react";

// existing imports & exports

export default function App() {
  const { contacts } = useLoaderData<typeof loader>();
  const navigation = useNavigation();

  return (
    <html lang="en">
      {/* existing elements */}
      <body>
        {/* existing elements */}
        <div
          className={
            navigation.state === "loading" ? "loading" : ""
          }
          id="detail"
        >
          <Outlet />
        </div>
        {/* existing elements */}
      </body>
    </html>
  );
}

変化が分かりにくかったので app/data.ts を編集して get メソッドに 1 秒間のスリープを追加したらようやく見えるようになった。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Deleting Records

https://remix.run/docs/en/main/start/tutorial#deleting-records

コマンド
touch app/routes/contacts.\$contactId.destroy.tsx
app/routes/contacts.$contactId.destroy.tsx
import { ActionFunctionArgs, redirect } from "@remix-run/node";
import invariant from "tiny-invariant";
import { deleteContact } from "~/data";

export const action = async ({ params }: ActionFunctionArgs) => {
  invariant(params.contactId, "Missing contactId params");
  await deleteContact(params.contactId);
  return redirect("/");
};

最初 404 Not Found となって色々やっていたら同じコードで動作するようになった。

変更が反映されるタイミングなどがわからない。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Cancel Button

https://remix.run/docs/en/main/start/tutorial#cancel-button

app/routes/contacts.$contactId_.edit.tsx
// existing imports
import {
  Form,
  useLoaderData,
  useNavigate,
} from "@remix-run/react";
// existing imports & exports

export default function EditContact() {
  const { contact } = useLoaderData<typeof loader>();
  const navigate = useNavigate();

  return (
    <Form key={contact.id} id="contact-form" method="post">
      {/* existing elements */}
      <p>
        <button type="submit">Save</button>
        <button onClick={() => navigate(-1)} type="button">
          Cancel
        </button>
      </p>
    </Form>
  );
}

type が button のボタンは押しても何も起こらないので preventDefault の呼び出しがいらないとのことだ。

しばらくやっていないと HTML 標準の動作を忘れてしまうな。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

URLSearchParams and GET Submissions

https://remix.run/docs/en/main/start/tutorial#urlsearchparams-and-get-submissions

app/root.tsx
import type {
  LinksFunction,
  LoaderFunctionArgs,
} from "@remix-run/node";

// existing imports & exports

export const loader = async ({
  request,
}: LoaderFunctionArgs) => {
  const url = new URL(request.url);
  const q = url.searchParams.get("q");
  const contacts = await getContacts(q);
  return json({ contacts });
};

// existing code


キーワードを入力して Enter キーを押すと部分一致で絞り込まれる

ブラウザの履歴に残るので戻るボタンで戻ることができる、便利だ。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Synchronizing URLs to Form State

https://remix.run/docs/en/main/start/tutorial#synchronizing-urls-to-form-state

先ほどの実装にはまだ不完全なところがあった、具体的には下記の 2 つ。

  1. 戻るボタンを押しても検索テキストが戻らない
  2. ページをリロードすると検索テキストが空欄になる

まず 2. の方に対処してみる。

// existing imports & exports

export const loader = async ({
  request,
}: LoaderFunctionArgs) => {
  const url = new URL(request.url);
  const q = url.searchParams.get("q");
  const contacts = await getContacts(q);
  return json({ contacts, q });
};

export default function App() {
  const { contacts, q } = useLoaderData<typeof loader>();
  const navigation = useNavigation();

  return (
    <html lang="en">
      {/* existing elements */}
      <body>
        <div id="sidebar">
          {/* existing elements */}
          <div>
            <Form id="search-form" role="search">
              <input
                aria-label="Search contacts"
                defaultValue={q || ""}
                id="q"
                name="q"
                placeholder="Search"
                type="search"
              />
              {/* existing elements */}
            </Form>
            {/* existing elements */}
          </div>
          {/* existing elements */}
        </div>
        {/* existing elements */}
      </body>
    </html>
  );
}

続いて 1. の方に対処する。

app/root.tsx
// existing imports
import { useEffect, useState } from "react";

// existing imports & exports

export default function App() {
  const { contacts, q } = useLoaderData<typeof loader>();
  const navigation = useNavigation();
  // the query now needs to be kept in state
  const [query, setQuery] = useState(q || "");

  // we still have a `useEffect` to synchronize the query
  // to the component state on back/forward button clicks
  useEffect(() => {
    setQuery(q || "");
  }, [q]);

  return (
    <html lang="en">
      {/* existing elements */}
      <body>
        <div id="sidebar">
          {/* existing elements */}
          <div>
            <Form id="search-form" role="search">
              <input
                aria-label="Search contacts"
                id="q"
                name="q"
                // synchronize user's input to component state
                onChange={(event) =>
                  setQuery(event.currentTarget.value)
                }
                placeholder="Search"
                type="search"
                // switched to `value` from `defaultValue`
                value={query}
              />
              {/* existing elements */}
            </Form>
            {/* existing elements */}
          </div>
          {/* existing elements */}
        </div>
        {/* existing elements */}
      </body>
    </html>
  );
}

無事に解決した。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Submitting Form's onChange

app/root.tsx
// existing imports
import {
  Form,
  Links,
  Meta,
  NavLink,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
  useNavigation,
  useSubmit,
} from "@remix-run/react";
// existing imports & exports

export default function App() {
  const { contacts, q } = useLoaderData<typeof loader>();
  const navigation = useNavigation();
  const submit = useSubmit();

  // existing code

  return (
    <html lang="en">
      {/* existing elements */}
      <body>
        <div id="sidebar">
          {/* existing elements */}
          <div>
            <Form
              id="search-form"
              onChange={(event) =>
                submit(event.currentTarget)
              }
              role="search"
            >
              {/* existing elements */}
            </Form>
            {/* existing elements */}
          </div>
          {/* existing elements */}
        </div>
        {/* existing elements */}
      </body>
    </html>
  );
}

event.currentTarget については下記の通り、イベントハンドラが設定された要素になるようだ。

https://developer.mozilla.org/ja/docs/Web/API/Event/currentTarget

event.target だと恐らく input 要素になるのだろう。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Adding Search Spinner

https://remix.run/docs/en/main/start/tutorial#adding-search-spinner

app/root.tsx
// existing imports & exports

export default function App() {
  const { contacts, q } = useLoaderData<typeof loader>();
  const navigation = useNavigation();
  const submit = useSubmit();
  const searching =
    navigation.location &&
    new URLSearchParams(navigation.location.search).has(
      "q"
    );

  return (
    <html lang="en">
      {/* existing elements */}
      <body>
        <div id="sidebar">
          {/* existing elements */}
          <div>
            <Form
              id="search-form"
              onChange={(event) =>
                submit(event.currentTarget)
              }
              role="search"
            >
              <input
                aria-label="Search contacts"
                className={searching ? "loading" : ""}
                defaultValue={q || ""}
                id="q"
                name="q"
                placeholder="Search"
                type="search"
              />
              <div
                aria-hidden
                hidden={!searching}
                id="search-spinner"
              />
            </Form>
            {/* existing elements */}
          </div>
          {/* existing elements */}
        </div>
        {/* existing elements */}
      </body>
    </html>
  );
}

hidden 属性とは知らなかった。

https://developer.mozilla.org/ja/docs/Web/HTML/Global_attributes/hidden

これは何に使うのだろう?

続きは Bonus points から。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida
app/root.tsx
// existing imports & exports

export default function App() {
  // existing code

  return (
    <html lang="en">
      {/* existing elements */}
      <body>
        {/* existing elements */}
        <div
          className={
            navigation.state === "loading" && !searching
              ? "loading"
              : ""
          }
          id="detail"
        >
          <Outlet />
        </div>
        {/* existing elements */}
      </body>
    </html>
  );
}

これにより、検索時にフェードアウトしないようになった。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Managing the History Stack

https://remix.run/docs/en/main/start/tutorial#managing-the-history-stack

このセクションでは検索キーワードの変更がブラウザバックの履歴に残らないようにするようだ。

app/root.tsx
// existing imports & exports

export default function App() {
  // existing code

  return (
    <html lang="en">
      {/* existing elements */}
      <body>
        <div id="sidebar">
          {/* existing elements */}
          <div>
            <Form
              id="search-form"
              onChange={(event) => {
                const isFirstSearch = q === null;
                submit(event.currentTarget, {
                  replace: !isFirstSearch,
                });
              }}
              role="search"
            >
              {/* existing elements */}
            </Form>
            {/* existing elements */}
          </div>
          {/* existing elements */}
        </div>
        {/* existing elements */}
      </body>
    </html>
  );
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Forms Without Navigation

https://remix.run/docs/en/main/start/tutorial#forms-without-navigation

このセクションではナビゲーションを行わずにフォームを送信する方法について解説するようだ。

そういえばこれまでのフォームは全てナビゲーションが行われていたようだ。

app/routes/contacts.$contactId.tsx
// existing imports
import {
  Form,
  useFetcher,
  useLoaderData,
} from "@remix-run/react";
// existing imports & exports

// existing code

const Favorite: FunctionComponent<{
  contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
  const fetcher = useFetcher();
  const favorite = contact.favorite;

  return (
    <fetcher.Form method="post">
      <button
        aria-label={
          favorite
            ? "Remove from favorites"
            : "Add to favorites"
        }
        name="favorite"
        value={favorite ? "false" : "true"}
      >
        {favorite ? "★" : "☆"}
      </button>
    </fetcher.Form>
  );
};
app/routes/contacts.$contactId.tsx
import type {
  ActionFunctionArgs,
  LoaderFunctionArgs,
} from "@remix-run/node";
// existing imports

import { getContact, updateContact } from "../data";
// existing imports

export const action = async ({
  params,
  request,
}: ActionFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const formData = await request.formData();
  return updateContact(params.contactId, {
    favorite: formData.get("favorite") === "true",
  });
};

// existing code
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Optimistic UI

https://remix.run/docs/en/main/start/tutorial#optimistic-ui

このセクションではお気に入りに登録したら、素早く星形アイコンが切り替わるようにするようだ。

app/routes/contacts.$contactId.tsx
// existing code

const Favorite: FunctionComponent<{
  contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
  const fetcher = useFetcher();
  const favorite = fetcher.formData
    ? fetcher.formData.get("favorite") === "true"
    : contact.favorite;

  return (
    <fetcher.Form method="post">
      <button
        aria-label={
          favorite
            ? "Remove from favorites"
            : "Add to favorites"
        }
        name="favorite"
        value={favorite ? "false" : "true"}
      >
        {favorite ? "★" : "☆"}
      </button>
    </fetcher.Form>
  );
};
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

チュートリアル完了

ほとんどハマることのない素晴らしいチュートリアルだった。

Remix は Web 標準に則っているので予測しやすくてありがたい。

まだまだわからないことはたくさんあるけど魅力的なフレームワークに感じた。

他にも Remix について学びたいことはたくさんあるが、一旦これで Remix の学習は中断しよう。

次は未完了になっている Rust 学習のスクラップを終わらせようかな。

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