Remix チュートリアル
小さくても機能豊富な連絡先管理アプリを作成します。データベースやその他の「本番環境対応」なものは使用しないので、Remix に集中できます。一緒に進めていれば約 30 分で完了しますが、そうでなければ素早く読める内容です。
👉 これが表示される場合は、アプリで何か操作する必要があります!
残りは情報提供と理解を深めるためのものです。早速始めましょう。
セットアップ
👉 基本テンプレートを生成する
これは非常にシンプルなテンプレートを使用しますが、CSSとデータモデルが含まれているため、Remixに集中できます。詳細を知りたい場合は、クイックスタートでRemixプロジェクトの基本的なセットアップについて知ることができます。
👉 アプリを起動する
http://localhost:5173を開くと、次のようなスタイルのない画面が表示されます。
ルートルート
app/root.tsx
のファイルに注目してください。これは「ルートルート」と呼ばれるものです。これは UI で最初にレンダリングされるコンポーネントなので、通常はページのグローバルレイアウトが含まれています。
ルートコンポーネントのコードを見るにはここをクリック
links
を使ったスタイルシートの追加
Remix アプリのスタイル設定には複数の方法がありますが、ここでは、Remix に焦点を当てるために、事前に記述されたプレーンなスタイルシートを使用します。
CSS ファイルは JavaScript モジュールに直接インポートできます。Vite はアセットのフィンガープリントを作成し、ビルドのクライアントディレクトリに保存し、モジュールに公開可能な href を提供します。
👉 アプリスタイルをインポートする
すべてのルートは links
関数をエクスポートできます。これらは収集され、app/root.tsx
でレンダリングした <Links />
コンポーネントにレンダリングされます。
アプリは、このようになっているはずです。デザイナーが CSS も書けるのは素晴らしいですね。(ジム さん、ありがとうございます 🙏)。
連絡先ルート UI
サイドバーの項目をクリックすると、デフォルトの 404 ページが表示されます。 /contacts/1
に一致するルートを作成しましょう。
👉 app/routes
ディレクトリと連絡先ルートモジュールを作成する
Remix の ルートファイル規則 において、.
は URL に /
を作成し、$
はセグメントを動的にします。 これにより、次のような URL に一致するルートが作成されました。
/contacts/123
/contacts/abc
👉 連絡先コンポーネント UI を追加する
これは単なる要素の集まりです。 コピーして貼り付けることができます。
リンクをクリックしたり、/contacts/1
にアクセスしたりしても、...何も変わりませんか?
ネストされたルートとアウトレット
Remix は React Router をベースに構築されているため、ネストされたルーティングをサポートしています。子ルートを親レイアウト内にレンダリングするには、親に Outlet
をレンダリングする必要があります。修正してみましょう。app/root.tsx
を開き、アウトレットをレンダリングします。
👉 <Outlet />
をレンダリングする
これで、子ルートはアウトレットを通じてレンダリングされるはずです。
クライアントサイドルーティング
気づいている方もいるかもしれませんが、サイドバーのリンクをクリックすると、ブラウザはクライアントサイドルーティングではなく、次のURLの完全なドキュメントリクエストを行っています。
クライアントサイドルーティングを使用すると、アプリケーションはサーバーから別のドキュメントをリクエストせずにURLを更新できます。代わりに、アプリはすぐに新しいUIをレンダリングできます。<Link>
を使って実現しましょう。
👉 サイドバーの <a href>
を <Link to>
に変更
ブラウザの開発者ツールのネットワークタブを開くと、もはやドキュメントをリクエストしていないことが確認できます。
データの読み込み
URL セグメント、レイアウト、データは、ほとんどの場合、互いに結合されています(3つ組み?)。このアプリでもすでに確認できます。
URL セグメント | コンポーネント | データ |
---|
/ | <Root> | 連絡先のリスト |
contacts/:contactId | <Contact> | 個々の連絡先 |
この自然な結合により、Remix はデータの慣習を採用し、ルートコンポーネントに簡単にデータを取得できるようにしています。
データを読み込むには、loader
と useLoaderData
の 2 つの API を使用します。 まず、ルートルートに loader
関数を定義してエクスポートし、データをレンダリングします。
👉 app/root.tsx
から loader
関数をエクスポートし、データをレンダリングする
次のコードには型エラーが含まれています。次のセクションで修正します。
これで完了です! Remix は、このデータを UI と自動的に同期させます。サイドバーは次のようになります。
型推論
TypeScript がマップ内の contact
型について文句を言っていることに気づいたかもしれません。typeof loader
を使って、データに関する型推論を得るために、簡単な注釈を追加することができます。
ローダーにおける URL パラメータ
👉 サイドバーのいずれかのリンクをクリックしてください
以前の静的な連絡先ページが再び表示されますが、1つ違いがあります。URL には、レコードの実際の ID が含まれるようになりました。
app/routes/contacts.$contactId.tsx
のファイル名の $contactId
部分を覚えていますか?これらの動的セグメントは、URL 内のその位置にある動的な(変化する)値と一致します。URL 内のこれらの値を「URL パラメータ」または簡単に「パラメータ」と呼びます。
これらの params
は、動的セグメントと一致するキーを使用してローダーに渡されます。たとえば、セグメントの名前が $contactId
の場合、値は params.contactId
として渡されます。
これらのパラメータは、ほとんどの場合、ID でレコードを見つけるために使用されます。試してみましょう。
👉 連絡先ページに loader
関数を追加し、useLoaderData
を使用してデータにアクセスします
次のコードには型エラーが含まれています。次のセクションで修正します
## パラメータの検証とレスポンスの送出
TypeScript は私たちにとても不満を持っています。TypeScript を満足させ、それが私たちに何を考えさせるのか見てみましょう。
このコードで最初に明らかになる問題は、ファイル名とコードの間でパラメータ名が間違っている可能性があることです(ファイル名を変更したのかもしれません!)。Invariant は、コードで潜在的な問題が発生した場合にカスタムメッセージ付きでエラーをスローするのに便利な関数です。
次に、useLoaderData<typeof loader>()
は、連絡先を取得したか、null
を取得したかを認識するようになりました(その ID の連絡先が存在しない可能性があります)。この潜在的な null
は、コンポーネントコードにとっては面倒で、TypeScript のエラーはまだたくさん発生しています。
コンポーネントコードで連絡先が見つからない可能性に対処できますが、ウェブらしいやり方は適切な 404 を返すことです。これはローダーで実行でき、すべての問題を一度に解決できます。
これで、ユーザーが見つからない場合、このパスでのコード実行は停止し、代わりに Remix はエラーパスをレンダリングします。Remix のコンポーネントは、ハッピーパスだけに集中できます 😁
データの変更
最初の連絡先を作成する前に、まず HTML について説明しましょう。
Remix は、HTML フォームのナビゲーションをデータ変更のプリミティブとしてエミュレートします。これは、JavaScript のカンブリア爆発以前に、唯一の方法でした。シンプルさに騙されないでください!Remix のフォームは、クライアント側でレンダリングされたアプリの UX 機能を、従来の Web モデルのシンプルさで実現します。
一部の Web 開発者にとってはなじみのないことですが、HTML の form
は、実際にはリンクをクリックするのと同じように、ブラウザでナビゲーションを引き起こします。唯一の違いはリクエストです。リンクは URL を変更するだけですが、form
はリクエストメソッド (GET
vs. POST
) とリクエストボディ (POST
フォームデータ) も変更できます。
クライアントサイドのルーティングがない場合、ブラウザは自動的に form
のデータをシリアライズし、POST
のリクエストボディとして、GET
の場合は URLSearchParams
としてサーバーに送信します。Remix は同じことを行いますが、サーバーにリクエストを送信する代わりに、クライアントサイドのルーティングを使用して、ルートの action
関数に送信します。
これは、アプリの「新規」ボタンをクリックすることで確認できます。
Remix は、このフォームナビゲーションを処理するコードがサーバーにないため、405 を返します。
連絡先の作成
ルートルートに action
関数をエクスポートすることで、新しい連絡先を作成します。ユーザーが「新規」ボタンをクリックすると、フォームはルートルートアクションに POST
します。
👉 app/root.tsx
から action
関数をエクスポートする
以上です!「新規」ボタンをクリックすると、リストに新しいレコードが追加されます 🥳
createEmptyContact
メソッドは、名前やデータなど何もない空の連絡先を作成するだけです。しかし、レコードを作成することは約束します!
🧐 えっ、ちょっと待って… サイドバーはどうやって更新されたの? どこで action
関数を呼び出したの? データを再取得するコードはどこにあるの? useState
、onSubmit
、useEffect
はどこにあるの?
これは、「従来の Web」プログラミングモデルが登場するところです。 <Form>
は、ブラウザがサーバーへのリクエストを送信するのを防ぎ、代わりに fetch
を使用してルートの action
関数に送信します。
Web のセマンティクスでは、POST
は通常、データが変更されることを意味します。慣例として、Remix はこれをヒントとして使用し、action
が完了した後、ページのデータを自動的に再検証します。
実際、すべてが HTML と HTTP であるため、JavaScript を無効にしても、すべて正常に機能します。Remix がフォームをシリアル化してサーバーに fetch
リクエストを行うのではなく、ブラウザがフォームをシリアル化してドキュメントリクエストを行います。そこから、Remix はページをサーバー側でレンダリングして送信します。結局、UI は同じです。
しかし、ファビコンの回転や静的なドキュメントよりも優れたユーザーエクスペリエンスを実現するため、JavaScript は使い続けます。
データの更新
新しいレコードの情報を埋める方法を追加しましょう。
データの作成と同様に、<Form>
を使用してデータを更新します。app/routes/contacts.$contactId_.edit.tsx
に新しいルートを作成しましょう。
👉 編集コンポーネントを作成する
$contactId_
の奇妙な _
に注目してください。デフォルトでは、ルートは同じプレフィックスを持つルートの中に自動的にネストされます。末尾に _
を追加することで、ルートが app/routes/contacts.$contactId.tsx
にネストされないように指示します。詳細については、ルートファイルの名前付け ガイドを参照してください。
👉 編集ページのUIを追加する
これまでに見たことがないものはありません。コピーして貼り付けることができます。
これで、新しいレコードをクリックし、"編集" ボタンをクリックしてください。新しいルートが表示されるはずです。
## `FormData` を使用して連絡先を更新する
作成した編集ルートはすでに form
をレンダリングしています。必要なのは action
関数を追加することだけです。Remix は form
をシリアライズし、fetch
で POST
し、自動的にすべてのデータを再検証します。
👉 編集ルートに action
関数を追加する
フォームに記入して保存ボタンを押すと、このような画面が表示されます! (見やすく、毛深い感じは少ないです。)
変異に関する議論
😑 動いたけど、何が起こっているのか全くわからない…
少し詳しく見ていきましょう…
contacts.$contactId_.edit.tsx
を開いて、form
要素を見てください。各要素に名前が付いていることに注目してください。
JavaScriptがない場合、フォームが送信されると、ブラウザはFormData
を作成し、サーバーに送信する際にリクエストの本文として設定します。前述のように、Remixはこの処理を阻止し、fetch
を使用してリクエストをaction
関数に送信することでブラウザをエミュレートします。これにはFormData
も含まれます。
form
内の各フィールドはformData.get(name)
でアクセスできます。たとえば、上記の入力フィールドの場合、次のようにして名前を取得できます。
フォームフィールドがいくつかあるため、Object.fromEntries
を使用してすべてのフィールドをオブジェクトに収集しました。これは、updateContact
関数が期待するものです。
action
関数以外に、ここで説明するAPIはRemixによって提供されていません。request
、request.formData
、Object.fromEntries
はすべてウェブプラットフォームによって提供されています。
action
関数が完了したら、最後にredirect
があることに注意してください。
action
関数とloader
関数は両方とも Response
を返せる(Request
を受け取るので当然です!)。redirect
ヘルパーは、アプリケーションに場所を変更するよう指示するResponse
を返す際に便利です。
クライアントサイドルーティングがない場合、POST
リクエスト後にサーバーがリダイレクトすると、新しいページは最新のデータを取得してレンダリングします。前に学んだように、Remixはこのモデルをエミュレートし、action
呼び出し後にページ上のデータを自動的に再検証します。これが、フォームを保存するとサイドバーが自動的に更新される理由です。追加の再検証コードは、クライアントサイドルーティングがない場合に存在せず、Remixではクライアントサイドルーティングがあるため存在する必要はありません。
最後にもう一点。JavaScriptがない場合、redirect
は通常の転送になります。ただし、JavaScriptを使用すると、クライアントサイドの転送になるため、スクロール位置やコンポーネントの状態などのクライアントの状態が失われることはありません。
新規レコードを編集ページにリダイレクトする
リダイレクトの方法がわかったところで、新しい連絡先を作成するアクションを更新して、編集ページにリダイレクトするようにしましょう。
👉 新しいレコードの編集ページにリダイレクトする
これで、「新規」をクリックすると、編集ページに移動します。
アクティブリンクのスタイリング
これでたくさんのレコードが表示されるようになりましたが、サイドバーでどのレコードを見ているのか分かりません。これを解決するために NavLink
を使用できます。
👉 サイドバーの <Link>
を <NavLink>
に置き換えてください
className
に関数を渡していることに注意してください。ユーザーが <NavLink to>
に一致する URL にいる場合、isActive
は true になります。アクティブになる 直前 の場合(データがまだロード中)、isPending
は true になります。これにより、ユーザーがどこにいて、リンクをクリックしたときにデータがロードされるのを待つ必要があり、すぐにフィードバックを提供することができます。
グローバル保留UI
ユーザーがアプリ内を移動すると、Remixは次のページのデータが読み込まれる間、古いページを表示したままにします。リスト間をクリックすると、アプリが少し反応しないように感じるかもしれません。アプリが反応していないように感じさせないように、ユーザーにフィードバックを提供しましょう。
Remixは、すべてを舞台裏で管理し、動的なWebアプリを構築するために必要な部分を明らかにします。この場合、useNavigation
フックを使用します。
👉 useNavigation
を使ってグローバル保留UIを追加しましょう
useNavigation
は、現在のナビゲーションの状態を返します。これは、"idle"
、"loading"
、または"submitting"
のいずれかです。
この場合、アイドル状態でない場合は、アプリのメイン部分に"loading"
クラスを追加します。CSSは、短い遅延後に素敵なフェードを追加して、(高速読み込みのためにUIがちらつくのを避けるためです)。スピナーを表示したり、上部にローディングバーを表示したりするなど、好きなことを何でもできます。
レコードの削除
コンタクトルートのコードを確認すると、削除ボタンは次のようになっていることがわかります。
action
が "destroy"
を指していることに注目してください。<Link to>
と同様に、<Form action>
は相対的な値を取ることができます。フォームは contacts.$contactId.tsx
でレンダリングされるため、destroy
を使った相対的なアクションは、クリック時にフォームを contacts.$contactId.destroy
に送信します。
この時点で、削除ボタンを動作させるために必要な情報はすべて揃っているはずです。先に進む前に、試してみてはいかがでしょうか?次のものが必要です。
- 新しいルート
- そのルートのアクション
app/data.ts
から deleteContact
- リダイレクト先
👉 "destroy" ルートモジュールを作成する
👉 destroy アクションを追加する
さて、レコードに移動して "Delete" ボタンをクリックしてください。動作します!
😅 まだよくわかりません
ユーザーが送信ボタンをクリックすると、次のようになります。
<Form>
はサーバーへの新しいドキュメント POST
リクエストを送信するというデフォルトのブラウザの動作を阻止しますが、代わりにクライアントサイドルーティングと fetch
を使用して、ブラウザをエミュレートし、POST
リクエストを作成します。
<Form action="destroy">
は "contacts.$contactId.destroy"
の新しいルートに一致し、リクエストを送信します。
action
がリダイレクトした後、Remix はページのデータのすべての loader
を呼び出して最新の値を取得します(これは "再検証" です)。useLoaderData
は新しい値を返し、コンポーネントを更新します!
Form
を追加し、action
を追加すれば、Remix が残りを処理します。
インデックスルート
アプリを起動すると、リストの右側には大きな空白ページが表示されます。
ルートに子ルートがある場合、親ルートのパスにいるときは、 <Outlet>
にはレンダリングする子ルートが一致しないため、何もレンダリングされません。インデックスルートは、そのスペースを埋めるためのデフォルトの子ルートとして考えることができます。
👉 ルートルートのインデックスルートを作成する
👉 インデックスコンポーネントの要素を埋める
コピー&ペーストして構いません。特別なことは何もありません。
ルート名 _index
は特別です。ユーザーが親ルートの正確なパスにいる場合、 <Outlet />
でレンダリングする他の子ルートがないため、このルートを一致させてレンダリングするように Remix に指示します。
できました!空白がなくなりました。インデックスルートには、ダッシュボード、統計情報、フィードなどを置くのが一般的です。インデックスルートは、データの読み込みにも参加できます。
キャンセルボタン
編集ページには、まだ何も動作しないキャンセルボタンがあります。ブラウザの戻るボタンと同じ動作にする必要があります。
ボタンのクリックハンドラと useNavigate
が必要になります。
👉 useNavigate
を使用してキャンセルボタンのクリックハンドラを追加
これで、ユーザーが「キャンセル」をクリックすると、ブラウザの履歴で 1 つ前のエントリに戻されます。
🧐 なぜボタンに event.preventDefault()
がないのですか?
<button type="button">
は、一見冗長ですが、ボタンがフォームを送信しないようにするための HTML の方法です。
あと 2 つの機能です。ゴールは目前です!
URLSearchParams
とGET
送信
これまで見てきたインタラクティブなUIは、URLを変更するリンクか、action
関数にデータを送信するform
でした。検索フィールドは、この両方の組み合わせで興味深いものです。form
ですが、データは変更せず、URLのみを変更します。
検索フォームを送信すると何が起こるか見てみましょう。
👉 検索フィールドに名前を入力してEnterキーを押してください
ブラウザのURLにクエリがURLSearchParams
として含まれているのがわかるはずです。
http://localhost:5173/?q=ryan
<Form method="post">
ではないので、Remixはブラウザをエミュレートし、FormData
をリクエストボディではなくURLSearchParams
にシリアライズします。
loader
関数は、request
から検索パラメータにアクセスできます。これを利用してリストをフィルターしてみましょう。
👉 URLSearchParams
があればリストをフィルターする
これはPOST
ではなくGET
なので、Remixはaction
関数を呼び出しません。GET
のform
を送信することは、リンクをクリックすることと同じです。URLだけが変更されます。
これは、通常のページナビゲーションであることも意味します。戻るボタンをクリックすると、元の場所に戻ることができます。
URL とフォームの状態を同期する
いくつかの UX の問題点があり、すぐに対応できます。
- 検索後に戻るボタンをクリックしても、フォームフィールドには入力した値が残っているのに、リストはフィルターされなくなります。
- 検索後にページを更新すると、フォームフィールドには値が入らなくなりますが、リストはフィルターされます。
つまり、URL と入力の状態が同期していません。
まずは (2) を解決し、入力に URL からの値を設定しましょう。
👉 loader
から q
を返して、入力のデフォルト値に設定します。
これで、検索後にページを更新した場合、入力フィールドにクエリが表示されます。
次に、問題 (1) について、戻るボタンをクリックして入力を更新する問題に対処します。React の useEffect
を使用して、DOM の入力値を直接操作できます。
👉 入力値を URLSearchParams
と同期させる
🤔 コントロールされたコンポーネントと React のステートを使うべきではないでしょうか?
もちろん、コントロールされたコンポーネントとして実装することもできます。同期ポイントが増えますが、どちらでも構いません。
展開して詳細を確認する
これで、戻る/進む/更新ボタンをクリックしても、入力の値が URL と結果と同期するようになります。
フォームのonChange
を提出する
ここには製品に関する意思決定が必要です。ユーザーにform
を提出して結果をフィルタリングさせたい場合もあれば、ユーザーがタイプするたびにフィルタリングしたい場合もあります。前者はすでに実装済みなので、後者について見ていきましょう。
すでにuseNavigate
を見てきましたが、今回はその仲間であるuseSubmit
を使用します。
このようにタイプすると、form
が自動的に送信されます!
submit
への引数に注目してください。submit
関数は、渡されたフォームをシリアライズして送信します。ここではevent.currentTarget
を渡しています。currentTarget
は、イベントがアタッチされているDOMノード(form
)です。
検索スピナーの追加
本番環境のアプリでは、検索は通常、一度にすべてを送信してクライアント側でフィルター処理するには大きすぎるデータベース内のレコードを検索するため、このデモではネットワーク遅延を模倣しています。
ローディングインジケーターがないと、検索は少し遅く感じられます。データベースを高速化できたとしても、ユーザーのネットワーク遅延は常に発生し、コントロールできません。
より良いユーザーエクスペリエンスのために、検索の直感的なUIフィードバックを追加しましょう。useNavigation
を再び使用します。
👉 検索中かどうかを知るための変数を追加する
何も起こっていない場合、navigation.location
はundefined
になりますが、ユーザーがナビゲートすると、データが読み込まれる間、次の場所に設定されます。その後、location.search
を使用して検索しているかどうかを確認します。
👉 新しいsearching
状態を使用して、検索フォーム要素にクラスを追加する
ボーナス:検索中はメイン画面がフェードアウトしないようにする:
これで、検索入力の左側には素敵なスピナーが表示されます。
履歴スタックの管理
フォームはキーストロークごとに送信されるため、「alex」と入力してからバックスペースで削除すると、膨大な履歴スタックが生成されます 😂。これは明らかに避けたいことです。
これを回避するには、履歴スタックにプッシュするのではなく、現在のエントリを次のページに 置き換える ことができます。
👉 submit
で replace
を使用
これが最初の検索かどうかを簡単に確認した後、置き換えを実行します。これで、最初の検索は新しいエントリを追加しますが、それ以降のキーストロークは現在のエントリを置き換えます。検索を削除するために7回戻る代わりに、ユーザーは1回戻るだけで済みます。
これまで、フォームはすべてURLを変更していました。これらのユーザーフローは一般的ですが、ナビゲーションなしでフォームを送信したい場合も同様に一般的です。
このような場合、useFetcher
が役立ちます。これにより、ナビゲーションなしで action
と loader
と通信できます。
連絡先ページの ★ ボタンは、これに対して理にかなっています。新しいレコードを作成したり削除したりするのではなく、見ているページのデータを変更したいだけです。
👉 <Favorite>
フォームをフェッチャーフォームに変更する
このフォームは、ナビゲーションを引き起こすことはなく、単に action
にフェッチします。さて... action
を作成するまでは機能しません。
👉 action
を作成する
さて、ユーザー名の横にある星をクリックする準備ができました!
確認してみてください。両方の星が自動的に更新されます。新しい <fetcher.Form method="post">
は、これまで使ってきた <Form>
とほぼ同じように機能します。アクションを呼び出し、すべてのデータが自動的に再検証されます。エラーも同様にキャッチされます。
ただし、重要な違いが1つあります。ナビゲーションではないため、URLは変更されず、履歴スタックも影響を受けません。
楽観的UI
前回のセクションで「お気に入り」ボタンをクリックしたときに、アプリが少し反応しにくかったことに気づいたかもしれません。再び、ネットワークレイテンシを追加しました。なぜなら、それは現実世界では必ず発生するからです。
ユーザーにフィードバックを与えるために、fetcher.state
(以前の navigation.state
とよく似ています)を使用して、星をローディング状態にすることができますが、今回はさらに良い方法があります。「楽観的UI」と呼ばれる戦略を使用できます。
フェッチャーは action
に送信される FormData
を認識しているので、fetcher.formData
で利用できます。これを使用して、ネットワークが完了していない場合でも、すぐに星の状態を更新します。更新が最終的に失敗した場合、UIは実際データに戻ります。
👉 fetcher.formData
から楽観的値を読み取る
これで、星はクリックするとすぐに新しい状態に変わります。
以上です!Remix を試していただきありがとうございます。このチュートリアルが、優れたユーザーエクスペリエンスを構築するための堅実なスタートになることを願っています。さらに多くのことができますので、すべての API を必ずチェックしてください 😀