Closed13

Remixの理解を深める!

nakamotonakamoto

フロントエンドのフレームワークであり、エッジランタイムへのデプロイをサポートしている。
2021年末頃から、Remixは開発者コミュニティに広く受け入れられ、CFWorkersとの統合によって、
低遅延でリッチなインタラクティブなWebアプリを構築し、エッジにデプロイできるようになった。

nakamotonakamoto

SPA Mode

https://remix.run/docs/ja/main/future/spa-mode#what-is-spa-mode
https://github.com/remix-run/remix/blob/main/CHANGELOG.md#v250
https://github.com/orgs/remix-run/projects/5/views/1?pane=issue&itemId=43814919

  • Remixv2.5SPAモードをリリース。SPAモードを使ったらサーバーサイドでのJS環境が不要でブラウザ側にすべてを任せる形になる。(Next.jsとかRemixが出る前の従来のReactはブラウザ側でのみ動かすライブラリだった)SPAだとindex.htmlのみでいろんなページを構成してた。ユーザーがリクエストを送ったらサーバーがやることはindex.htmlindex.jsを返すだけで、あとはブラウザ側でindex.htmlとJSによって頑張ってレンダリングする(サーバーから新たなhtmlが送られることはない)というのがSPA
  • でもSPAを使えるだけじゃなくてSPAモードを使ったらloader actionみたいな機能(それ自体はサーバーサイドで動かせるため使えないけれど)としてclientLoader clientActionという機能が一緒に使える。つまりRemixSPAモードを使うことによってRemixの書き方を踏襲しつつSPAで作ることができるし、将来的にRSCなどReactのフル機能を使うとなったら簡単に移行できる。
  • 例えば、管理画面はSEO OGイメージなど大事ではないためSPAで十分だしサーバーサイドの機能を使うのは持て余す。だから一回認証とったらあとはブラウザのみでいろいろ完結させた方が早いし良い。ただReactのフル機能を引き出すためにはサーバー側も必要のため、ケースバイケース。
  • また今後のRemixの計画にはCloudflareの開発がしやすくなるCloudflare Dev Proxyなるものがある。それによってWorkers D1などのCloudflareの各種機能をローカル環境で再現できる為RemixCloudflareを使った開発がやりやすくなる。Next.js x Vercelに対しRemix x Cloudflareも結構流行りそう。
nakamotonakamoto

https://remix.run/docs/en/main
https://remix.guide/templates
app/root.jsx is what we call the "Root Route". It's the root layout of your entire app. Here's the basic set of elements you'll need for any project:

  • app/root.jsxがアプリ全体のルートレイアウト。LinkがCSSを読み込んでてMetaがメタデータを読み込んでてScriptsに最終的にビルドしたJSが入る。LiveReload は開発時に自分のコードを直接開発サーバーに反映させる。
app/root.jsx
import {
  Links,
  Meta,
  Outlet,
  Scripts,
} from "@remix-run/react";

export default function App() {
  return (
    <html>
      <head>
        <link
          rel="icon"
          href="data:image/x-icon;base64,AA"
        />
        <Meta />
        <Links />
      </head>
      <body>
        <h1>Hello world!</h1>
        <Outlet />

        <Scripts />
      </body>
    </html>
  );
}

Create the app/routes directory and contact route module

mkdir app/routes
touch app/routes/contacts.\$contactId.tsx
  • Remixapp/routesというディレクトリを作って
    その中に_index.tsx about.tsx concerts.trending.tsxなど作る。
    ファイル名をカンマによって区切ると階層構造にできる。

https://interactive-remix-routing-v2.netlify.app/

nakamotonakamoto

Nested Routes and Outlets
Since Remix is built on top of React Router, it supports nested routing. In order for child routes to render inside of parent layouts, we need to render an Outlet in the parent. Let's fix it, open up app/root.tsx and render an outlet inside.

app/root.tsx
// existing imports
import {
  Form,
  Links,
  LiveReload,
  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>
  );
}
  • RemixReact Routerをベースとする為、ネストされたルーティング(入れ子になったルート)をサポートする。これは、親のレイアウト内に子のルートをレンダリングするために必要となる。子のルートを親のレイアウトの中でレンダリングするためには、親のコンポーネント内でOutletという特別なコンポーネントをレンダリングする。

Client Side Routing
You may or may not have noticed, but when we click the links in the sidebar, the browser is doing a full document request for the next URL instead of client side routing.

Client side routing allows our app to update the URL without requesting another document from the server. Instead, the app can immediately render new UI. Let's make it happen with <Link>.

Change the sidebar <a href> to <Link to>

app/root.tsx
// existing imports
import {
  Form,
  Link,
  Links,
  LiveReload,
  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>
  );
}
  • サイドバーの<a href><Link to>に変更する。これにより、サーバーへの新しいドキュメントリクエストを行う代わりに、クライアントサイドでページの内容を動的に更新できる。
nakamotonakamoto

There are two APIs we'll be using to load data, loader and useLoaderData. First we'll create and export a loader function in the root route and then render the data.

Export a loader function from app/root.tsx and render the data

You may have noticed TypeScript complaining about the contact type inside the map. We can add a quick annotation to get type inference about our data with typeof loader:

app/root.tsx
// existing imports
import { json } from "@remix-run/node";
import {
  Form,
  Link,
  Links,
  LiveReload,
  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>
  );
}
  • データをロードするために使用するAPIはloaderuseLoaderDataの2つがある。
    loaderはサーバーサイドでデータを読み込むために使用され、そのあとフロントエンドでuseLoaderDataを通じて取得されたデータをレンダリングする。また<typeof loader>のように簡単に型定義できる。

Remember the contactId part of the file name at app/routes/contacts.contactId.tsx? These dynamic segments will match dynamic (changing) values in that position of the URL. We call these values in the URL "URL Params", or just "params" for short.

These params are passed to the loader with keys that match the dynamic segment. For example, our segment is named $contactId so the value will be passed as params.contactId.

These params are most often used to find a record by ID. Let's try it out.

Add a loader function to the contact page and access data with useLoaderData

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

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

export const loader = async ({ params }) => {
  const contact = await getContact(params.contactId);
  return json({ contact });
};

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

  // existing code
}

// existing code
  • app/routes/contacts.$contactId.tsxのファイル名にある$contactIdは動的セグメントで、URLのその位置にある動的な値に一致する。これらの値を「URLパラメータ」又は「パラメータ」と呼ぶ。これらのパラメータは、動的セグメントに一致するキーを持ってローダーに渡される。$contactIdなら値はparams.contactIdとして渡される。これらのパラメータは、IDによるrecordの検索によく使われる。loaderを追加しuseLoaderDataでデータにアクセスできる。
nakamotonakamoto

First problem this highlights is we might have gotten the param's name wrong between the file name and the code (maybe you changed the name of the file!). Invariant is a handy function for throwing an error with a custom message when you anticipated a potential issue with your code.

Next, the useLoaderData<typeof loader>() now knows that we got a contact or null (maybe there is no contact with that ID). This potential null is cumbersome for our component code and the TS errors are flying around still.

We could account for the possibility of the contact being not found in component code, but the webby thing to do is send a proper 404. We can do that in the loader and solve all of our problems at once.

app/routes/contacts.$contactId.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
// existing imports
import invariant from "tiny-invariant";

// existing imports

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 });
};

// existing code

  • invariantによってparams.contactIdがなかったらエラーメッセージをスローできる。
    それによってstringのidが入ることを保証できるけれどもcontactがなかったら404とする。
nakamotonakamoto

Creating Contacts
We'll create new contacts by exporting an action function in our root route. When the user clicks the "new" button, the form will POST to the root route action.

Export an action function from app/root.tsx

app/root.tsx
// existing imports

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

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

// existing code

The createEmptyContact method just creates an empty contact with no name or data or anything. But it does still create a record, promise!

Wait a sec ... How did the sidebar update? Where did we call the action function? Where's the code to re-fetch the data? Where are useState, onSubmit and useEffect?!

This is where the "old school web" programming model shows up. <Form> prevents the browser from sending the request to the server and sends it to your route's action function instead with fetch.

In web semantics, a POST usually means some data is changing. By convention, Remix uses this as a hint to automatically revalidate the data on the page after the action finishes.

In fact, since it's all just HTML and HTTP, you could disable JavaScript and the whole thing will still work. Instead of Remix serializing the form and making a fetch request to your server, the browser will serialize the form and make a document request. From there Remix will render the page server side and send it down. It's the same UI in the end either way.

We'll keep JavaScript around though because we're going to make a better user experience than spinning favicons and static documents.

  • root.tsxによってaction関数をエクスポートする。すると「新規」ボタンをクリックしたときフォームはrootactionに POST される。<Form> はブラウザがサーバーにリクエストを送信するのを防ぎ、その代わりにfetchを使用してrootaction関数にリクエストを送信する。Web セマンティクスでは、POST は通常、一部のデータが変更されていることを意味する。 慣例的にRemix はこれをヒントとして使用しactionの終了後にページ上のデータを自動的に再検証する。 だから先述のuseLoaderDataなどによってUIが自動更新される。Remixのいいところ!
nakamotonakamoto

Updating Data
Let's add a way to fill the information for our new record.

Just like creating data, you update data with <Form>. Let's make a new route at app/routes/contacts.$contactId_.edit.tsx.

Create the edit component

touch app/routes/contacts.$contactId_.edit.tsx
Copy code to clipboard
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.

Add the edit page UI

app/routes/contacts.$contactId_.edit.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, 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}
        />
      </label>
      <p>
        <button type="submit">Save</button>
        <button type="button">Cancel</button>
      </p>
    </Form>
  );
}
  • 通常Remixはファイル名が同じ接頭辞を持つルートが自動的にネストされる。たとえば、contacts.$contactId.tsxcontacts.$contactId.edit.tsxという2つのルートがある場合は、後者は前者の中にネストされる。しかし、ファイル名の末尾にアンダースコア _を追加すると、このデフォルトの挙動を無効にし、ネストされないようにできる。つまり$contactId_ を使うとapp/routes/contacts.$contactId.tsxの中にネストされず独立したルートとして機能する。
nakamotonakamoto

Active Link Styling
Now that we have a bunch of records, it's not clear which one we're looking at in the sidebar. We can use NavLink to fix this.

Replace <Link> with <NavLink> in the sidebar

Note that we are passing a function to className. When the user is at the URL that matches <NavLink to>, then isActive will be true. When it's about to be active (the data is still loading) then isPending will be true. This allows us to easily indicate where the user is and also provide immediate feedback when links are clicked but data needs to be loaded.

app/root.tsx
// existing imports
import {
  Form,
  Links,
  LiveReload,
  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>
  );
}
  • Active Link Styling(アクティブリンクのスタイリング)多くのレコードがあるとサイドバーでどのレコードを見ているかはっきりしない。これを修正するためにNavLinkを使用できる。サイドバーの<Link><NavLink>に置き換える。classNameに関数を渡していることに着目。ユーザーが<NavLink to>に一致するURLにいる場合isActivetrueになる。データのロード中であれば(アクティブになろうとしている場合)isPendingtrueになる。これによりユーザーがどこにいるかを簡単に示し、リンクがクリックされデータのロードが必要な場合に即時フィードバックを提供できる。
nakamotonakamoto

Global Pending UI
As the user navigates the app, Remix will leave the old page up as data is loading for the next page. You may have noticed the app feels a little unresponsive as you click between the list. Let's provide the user with some feedback so the app doesn't feel unresponsive.

Remix is managing all the state behind the scenes and reveals the pieces you need to build dynamic web apps. In this case, we'll use the useNavigation hook.

Use useNavigation to add global pending UI

app/root.tsx
// existing imports
import {
  Form,
  Links,
  LiveReload,
  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>
  );
}

useNavigation returns the current navigation state: it can be one of "idle", "loading" or "submitting".

In our case, we add a "loading" class to the main part of the app if we're not idle. The CSS then adds a nice fade after a short delay (to avoid flickering the UI for fast loads). You could do anything you want though, like show a spinner or loading bar across the top.

  • Global Pending UI(グローバルなペンディング UI)。ユーザーがアプリを操作する際にRemixは次のページのデータがロードされるまで古いページを表示したまま。リスト間をクリックする際にアプリが少し反応しないように感じるかも。アプリが反応しないように感じないために、ユーザーにフィードバックを提供する。Remixは裏側ですべての状態を管理しダイナミックなウェブアプリを構築するために必要な部分を提示する。このケースではuseNavigationフックを使用します。useNavigationを使用して、グローバルなペンディング UI を追加する。useNavigationは現在のナビゲーション状態を返す。これは "idle" "loading" "submitting"のいずれかになる。この場合、アイドル状態でない場合はアプリのメイン部分に"loading"クラスを追加する。CSSはそのあと低遅延でフェードを追加する。スピナーやローディングバーを表示するなど何でも行える。
nakamotonakamoto

Deleting Records
If we review code in the contact route, we can find the delete button looks like this:

src/routes/contacts.$contactId.tsx
<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>

Note the action points to "destroy". Like <Link to>, <Form action> can take a relative value. Since the form is rendered in contacts.contactId.tsx, then a relative action with destroy will submit the form to contacts.contactId.destroy when clicked.

At this point you should know everything you need to know to make the delete button work. Maybe give it a shot before moving on? You'll need:

A new route
An action at that route
deleteContact from app/data.ts
redirect to somewhere after

app/routes/contacts.$contactId.destroy.tsx
import type { ActionFunctionArgs } from "@remix-run/node";
import { 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 param");
  await deleteContact(params.contactId);
  return redirect("/");
};

Alright, navigate to a record and click the "Delete" button. It works!

I'm still confused why this all works

When the user clicks the submit button:

<Form> prevents the default browser behavior of sending a new document POST request to the server, but instead emulates the browser by creating a POST request with client side routing and fetch
The <Form action="destroy"> matches the new route at "contacts.$contactId.destroy" and sends it the request
After the action redirects, Remix calls all the loaders for the data on the page to get the latest values (this is "revalidation"). useLoaderData returns new values and causes the components to update!
Add a Form, add an action, Remix does the rest.

  • actiondestroyを指す。<Link to>のように、<Form action>は相対的な値を取れる。このフォームがcontacts.$contactId.tsxでレンダリングされるためdestroyという相対アクションをクリックすると contacts.$contactId.destroyにフォームを送信する。送信ボタンをクリックすると<Form> は、サーバーへ新しいドキュメントのPOSTリクエストを送信するというブラウザのデフォルトの動作を防ぎ、代わりにクライアントサイドのルーティングとfetchを使用してPOSTリクエストを作成し、ブラウザをエミュレートする。<Form action="destroy">contacts.$contactId.destroyと一致しリクエストを送信する。actionがリダイレクトされた後Remixはページ上のデータのすべてのローダーを呼び出し、最新の値を取得する。useLoaderDataは新しい値を返し、コンポーネントを更新する。
nakamotonakamoto

Index Routes
When we load up the app, you'll notice a big blank page on the right side of our list.

When a route has children, and you're at the parent route's path, the <Outlet> has nothing to render because no children match. You can think of index routes as the default child route to fill in that space.

app/routes/_index.tsx
export default function Index() {
  return (
    <p id="index-page">
      This is a demo for Remix.
      <br />
      Check out{" "}
      <a href="https://remix.run">the docs at remix.run</a>.
    </p>
  );
}

The route name _index is special. It tells Remix to match and render this route when the user is at the parent route's exact path, so there are no other child routes to render in the <Outlet />.

Voilà! No more blank space. It's common to put dashboards, stats, feeds, etc. at index routes. They can participate in data loading as well.

  • 子ルートがあったら親ルートのパスにいるとき<Outlet>には何もレンダリングするものがない。なぜなら、どの子も一致しないため。_indexルートを、そのスペースを埋めるデフォルトの子ルートと考える。_indexというルートは特別。これは、ユーザーが親ルートの正確なパスにいる場合に、このルートが一致してレンダリングされることをRemixに指示する。
nakamotonakamoto

Cancel Button
On the edit page we've got a cancel button that doesn't do anything yet. We'd like it to do the same thing as the browser's back button.

We'll need a click handler on the button as well as useNavigate.

Add the cancel button click handler with useNavigate

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>
  );
}

Now when the user clicks "Cancel", they'll be sent back one entry in the browser's history.

Why is there no event.preventDefault() on the button?

A <button type="button">, while seemingly redundant, is the HTML way of preventing a button from submitting its form.

Two more features to go. We're on the home stretch!

  • キャンセルボタンはクリックハンドラとuseNavigateが必要。useNavigateでキャンセルボタンのクリックハンドラを追加する。これでユーザーが「キャンセル」をクリックすると、ブラウザの履歴の一つ前に戻る。なぜボタンにevent.preventDefault()がないのか?<button type="button">は、一見冗長に見えるが、HTMLでフォームを送信するボタンの動作を防ぐ。
このスクラップは2024/01/31にクローズされました