LoginSignup
40
19

Reactベースの新フルスタックフレームワーク「Remix」を読み解いてみた

Last updated at Posted at 2021-12-03

当記事は執筆当初に比べ情報が古くなってしまっておりますので、以下の記事をご参照いただければと思います。

https://qiita.com/FAL-coffee/items/ec5733904b8485b6d94d

はじめに

はじめまして!
フロントエンジニア見習いのふぁるです。
毎日楽しく生きてます。

【Twitterリンク】
https://twitter.com/@fal_engineer

注)当記事では、公式のリファレンス等を参照しながら記述を行っていますが、私自身未熟な身であるため、見解等に間違いがあった場合は指摘、コメント等どうかよろしくお願いいたします。

Remixってなに

Remixとは、2021/11/23にOSSとしてリリースされたばかりの、Reactをベースにした新しいフルスタックフレームワークです。
Reactベースのフレームワークというと、Next.jsがスタンダードですが、RemixはNext.jsやGatsbyとは一線を画す設計思想となっています。
具体的には、SSG(静的HTMLを予めジェネレートし、ブラウザにはHTMLを配信する形式)を撤廃し、SSR(サーバーにレンダリングの責務を持たせる方式)、ブラウザのFetchAPI等のエコシステムを利用し処理・描画速度の向上を図っています。

環境の構築

node.jsが既インストール済であることが前提となります。

$ npx create-remix@latest

色々聞かれるので答えていきます。
デプロイ先をNetlifyとか、VercelとかRemix app serverとかから選べるそうです。
折角なら収益化を行いたかったので、使い慣れたNetlifyを選択しました。
(利用規約では、Vercelは無料枠では商用利用が出来ません。)
image.png
.envやprisma等、色々追加はしていますが大まかにはこんな感じにディレクトリが作られます。
デプロイ先にNetlifyを指定したため、netlifyの設定ファイルが生成されたり、netlify用のディレクトリが作られたりしてます。

$ npm run dev

すると、localhost:3000にチュートリアルページが表示されます。
image.png

※ 展開先:netlify限定かどうかわかりませんが、私の環境ではpackage.json内のdevスクリプトをwatchからdevに切り替えたり、@remix-run/serveといったライブラリのインストールが必要でした。

コードを読んでいく

image.png

entry.client.tsx

entry.client.tsx
import { hydrate } from "react-dom";
import { RemixBrowser } from "remix";

hydrate(<RemixBrowser />, document);

Reactコンポーネントをdocumentにマウントし描画するためのものだと思われ......?
hydrateはreact-domの関数なので、公式リファレンスを読んでみます。

render() と同様ですが、ReactDOMServer により HTML コンテンツが描画されたコンテナをクライアントで再利用するために使用されます。React は既存のマークアップにイベントリスナをアタッチしようとします。


> React はレンダーされる内容が、サーバ・クライアント間で同一であることを期待します。React はテキストコンテンツの差異を修復することは可能ですが、その不一致はバグとして扱い、修正すべきです。開発用モードでは、React は両者のレンダーの不一致について警告します。不一致がある場合に属性の差異が修復されるという保証はありません。これはパフォーマンス上の理由から重要です。なぜなら、ほとんどのアプリケーションにおいて不一致が発生するということは稀であり、全てのマークアップを検証することは許容不可能なほど高コストになるためです。

SSRの仕組みによってコンポーネントがマウントされたコンテナをwindow.documentに描画するためのものでしょうか。

entry.server.tsx

entry.server.tsx
import { renderToString } from "react-dom/server";
import { RemixServer } from "remix";
import type { EntryContext } from "remix";

export default function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  let markup = renderToString(
    <RemixServer context={remixContext} url={request.url} />
  );

  responseHeaders.set("Content-Type", "text/html");

  return new Response("<!DOCTYPE html>" + markup, {
    status: responseStatusCode,
    headers: responseHeaders
  });
}

Request->Response時の共通処理っぽいのが書いてあるように見えます。
renderToString関数について、公式リファレンスを読んでみます。

コンポーネントを静的なマークアップとして変換できるようにします。

サーバとブラウザの両方の環境で使用できる関数のようで、JSX式で書かれたReactコンポーネントをHTMLに描画したり、初期リクエストに対してはマークアップに変換しブラウザに読み込ませることで読み込み速度を向上させSEO対策を可能にしたり、しているそうです。
SSRもSSGもイマイチ詳しくないですが、リファレンスを読む限りSSRのエコシステムを中核的な機能のように見えます。

RemixServer関数は、contextとurlを受け取り、ReactElement型を返す関数であるようです。
renderToString関数にcomponentとして渡されているあたり、urlやcontextからroutes内のコンポーネントをマッピングし返す役割でも持っているのでしょうか?

returnあたりはレスポンスを整形しているっぽく見えます。

root.tsx

**root.tsx(長いので注意)**
root.tsx
import {
  Link,
  Links,
  LiveReload,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  useCatch
} from "remix";
import type { LinksFunction } from "remix";

import globalStylesUrl from "~/styles/global.css";
import darkStylesUrl from "~/styles/dark.css";

// https://remix.run/api/app#links
export let links: LinksFunction = () => {
  return [
    { rel: "stylesheet", href: globalStylesUrl },
    {
      rel: "stylesheet",
      href: darkStylesUrl,
      media: "(prefers-color-scheme: dark)"
    }
  ];
};

// https://remix.run/api/conventions#default-export
// https://remix.run/api/conventions#route-filenames
export default function App() {
  return (
    <Document>
      <Layout>
        <Outlet />
      </Layout>
    </Document>
  );
}

// https://remix.run/docs/en/v1/api/conventions#errorboundary
export function ErrorBoundary({ error }: { error: Error }) {
  console.error(error);
  return (
    <Document title="Error!">
      <Layout>
        <div>
          <h1>There was an error</h1>
          <p>{error.message}</p>
          <hr />
          <p>
            Hey, developer, you should replace this with what you want your
            users to see.
          </p>
        </div>
      </Layout>
    </Document>
  );
}

// https://remix.run/docs/en/v1/api/conventions#catchboundary
export function CatchBoundary() {
  let caught = useCatch();

  let message;
  switch (caught.status) {
    case 401:
      message = (
        <p>
          Oops! Looks like you tried to visit a page that you do not have access
          to.
        </p>
      );
      break;
    case 404:
      message = (
        <p>Oops! Looks like you tried to visit a page that does not exist.</p>
      );
      break;

    default:
      throw new Error(caught.data || caught.statusText);
  }

  return (
    <Document title={`${caught.status} ${caught.statusText}`}>
      <Layout>
        <h1>
          {caught.status}: {caught.statusText}
        </h1>
        {message}
      </Layout>
    </Document>
  );
}

function Document({
  children,
  title
}: {
  children: React.ReactNode;
  title?: string;
}) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width,initial-scale=1" />
        {title ? <title>{title}</title> : null}
        <Meta />
        <Links />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
        {process.env.NODE_ENV === "development" && <LiveReload />}
      </body>
    </html>
  );
}

function Layout({ children }: { children: React.ReactNode }) {
  return (
    <div className="remix-app">
      <header className="remix-app__header">
        <div className="container remix-app__header-content">
          <Link to="/" title="Remix" className="remix-app__header-home-link">
            <RemixLogo />
          </Link>
          <nav aria-label="Main navigation" className="remix-app__header-nav">
            <ul>
              <li>
                <Link to="/">Home</Link>
              </li>
              <li>
                <a href="https://remix.run/docs">Remix Docs</a>
              </li>
              <li>
                <a href="https://github.com/remix-run/remix">GitHub</a>
              </li>
            </ul>
          </nav>
        </div>
      </header>
      <div className="remix-app__main">
        <div className="container remix-app__main-content">{children}</div>
      </div>
      <footer className="remix-app__footer">
        <div className="container remix-app__footer-content">
          <p>&copy; You!</p>
        </div>
      </footer>
    </div>
  );
}

function RemixLogo() {
  return (
    <svg
      viewBox="0 0 659 165"
      version="1.1"
      xmlns="http://www.w3.org/2000/svg"
      xmlnsXlink="http://www.w3.org/1999/xlink"
      aria-labelledby="remix-run-logo-title"
      role="img"
      width="106"
      height="30"
      fill="currentColor"
    >
      <title id="remix-run-logo-title">Remix Logo</title>
      <path d="M0 161V136H45.5416C53.1486 136 54.8003 141.638 54.8003 145V161H0Z M133.85 124.16C135.3 142.762 135.3 151.482 135.3 161H92.2283C92.2283 158.927 92.2653 157.03 92.3028 155.107C92.4195 149.128 92.5411 142.894 91.5717 130.304C90.2905 111.872 82.3473 107.776 67.7419 107.776H54.8021H0V74.24H69.7918C88.2407 74.24 97.4651 68.632 97.4651 53.784C97.4651 40.728 88.2407 32.816 69.7918 32.816H0V0H77.4788C119.245 0 140 19.712 140 51.2C140 74.752 125.395 90.112 105.665 92.672C122.32 96 132.057 105.472 133.85 124.16Z" />
      <path d="M229.43 120.576C225.59 129.536 218.422 133.376 207.158 133.376C194.614 133.376 184.374 126.72 183.35 112.64H263.478V101.12C263.478 70.1437 243.254 44.0317 205.11 44.0317C169.526 44.0317 142.902 69.8877 142.902 105.984C142.902 142.336 169.014 164.352 205.622 164.352C235.83 164.352 256.822 149.76 262.71 123.648L229.43 120.576ZM183.862 92.6717C185.398 81.9197 191.286 73.7277 204.598 73.7277C216.886 73.7277 223.542 82.4317 224.054 92.6717H183.862Z" />
      <path d="M385.256 66.5597C380.392 53.2477 369.896 44.0317 349.672 44.0317C332.52 44.0317 320.232 51.7117 314.088 64.2557V47.1037H272.616V161.28H314.088V105.216C314.088 88.0638 318.952 76.7997 332.52 76.7997C345.064 76.7997 348.136 84.9917 348.136 100.608V161.28H389.608V105.216C389.608 88.0638 394.216 76.7997 408.04 76.7997C420.584 76.7997 423.4 84.9917 423.4 100.608V161.28H464.872V89.5997C464.872 65.7917 455.656 44.0317 424.168 44.0317C404.968 44.0317 391.4 53.7597 385.256 66.5597Z" />
      <path d="M478.436 47.104V161.28H519.908V47.104H478.436ZM478.18 36.352H520.164V0H478.18V36.352Z" />
      <path d="M654.54 47.1035H611.788L592.332 74.2395L573.388 47.1035H527.564L568.78 103.168L523.98 161.28H566.732L589.516 130.304L612.3 161.28H658.124L613.068 101.376L654.54 47.1035Z" />
    </svg>
  );
}

「うぇ、Remix」って感じがしますが、落ち着いて読んでいこうと思います。

export let links: LinksFunction = () => {
  return [
    { rel: "stylesheet", href: globalStylesUrl },
    {
      rel: "stylesheet",
      href: darkStylesUrl,
      media: "(prefers-color-scheme: dark)"
    }
  ];
};

まず、最上部のlinksとやらをexportしている部分について。
letで宣言されていますね。
Remixの公式リファレンスを何日か前に読んでいた時は、「letは三文字で楽だからconstじゃなくてlet使ってるぜ!まぁconst使ってくれてもいいけどな!」みたいな陽気な事が書いてあり印象によく残っているのですが、記事執筆時に原文を探しに行くと見つかりませんでした。
もしかして消された?

まず、ぱっと見で見て取れるのは、linksは外部のcssを読み込んでいるようであるという事です。
prefers-color-schemeというのは、ユーザーがシステムに要求したカラーテーマが明色か暗色かを検出するためのもののようです。
グローバルにスタイルを読み込ませるためにroot.tsxにLinksFunctionという型を使っているあたり、root.tsxはReactやVueでいうところのdefaultLayout的なものではないでしょうか。

export default function App() {
  return (
    <Document>
      <Layout>
        <Outlet />
      </Layout>
    </Document>
  );
}

Appの既定エクスポートです。
Document、Layoutは以下に出てくるようですが、OutletはReact Router v6の新機能ですね。
子インデックスコンポーネントをレンダリングするものです。
例えば、app/hoge.tsxのようなパスに存在するコンポーネントでOutletを呼び出した場合、app/hoge/index.tsxの既定エクスポートが呼び出され、描画されます。
子インデックスコンポーネントが存在しない場合はレンダリングは行われません。
今回だと、デフォルトではroutes/index.tsxが呼び出されることになります。

export function ErrorBoundary({ error }: { error: Error }) {
  console.error(error);
  return (
    <Document title="Error!">
      <Layout>
        <div>
          <h1>There was an error</h1>
          <p>{error.message}</p>
          <hr />
          <p>
            Hey, developer, you should replace this with what you want your
            users to see.
          </p>
        </div>
      </Layout>
    </Document>
  );
}

ErrorBoundaryコンポーネントは、サーバー、フロント両方のエラーをキャッチし、既定エクスポートのコンポーネントに対しマウントを行うことが出来ます。
公式リファレンスを参照します。

Remix sets a new precedent in web application error handling that you are going to love. Remix automatically catches most errors in your code, on the server or in the browser, and renders the closest ErrorBoundary to where the error occurred. If you're familiar with React's componentDidCatch and getDerivedStateFromError class component hooks, it's just like that but with some extra handling for errors on the server.

ページ全体を読んで、和訳すると以下のような内容が記述されているようです。
Remixは全てのエラーをキャッチし、ErrorBoundaryをレンダリングします。ErrorBoundaryが複数コンポーネントに配置されている場合、最も近いErrorBoundaryがマウントされます。
ブラウザでのレンダリング、サーバーでのレンダリング、サーバー処理、クライアントサイドでの処理中の全てのエラーの全てをキャッチすることが可能です。
rootにおいてエラーが起きた場合、画面全体(root以下のOutlet全体)がErrorBoundaryがレンダリングされ、
子孫コンポーネント内でエラーが起きた場合は子孫コンポーネント内のErrorBoundaryのみが子孫コンポーネントにマウントされます。(子孫コンポーネントにErrorBoundaryが宣言されていない場合は、rootのErrorBoundaryが適用されます。)
子孫コンポーネント内のErrorBoundaryがマウントされた場合、それ以外の箇所は正常にレンダリングされているのが特徴です。

export function CatchBoundary() {
  let caught = useCatch();

  let message;
  switch (caught.status) {
    case 401:
      message = (
        <p>
          Oops! Looks like you tried to visit a page that you do not have access
          to.
        </p>
      );
      break;
    case 404:
      message = (
        <p>Oops! Looks like you tried to visit a page that does not exist.</p>
      );
      break;

    default:
      throw new Error(caught.data || caught.statusText);
  }

  return (
    <Document title={`${caught.status} ${caught.statusText}`}>
      <Layout>
        <h1>
          {caught.status}: {caught.statusText}
        </h1>
        {message}
      </Layout>
    </Document>
  );
}

CatchBoundaryは、loaderやルーティングによってキャッチした応答結果を元に、をキャッチしマウントを行うようです。
基本的にはErrorBoundaryと同様です。
主観ですが、これらは本当にとても強力に見えます。コンポジションAPIの究極形みたいな印象を受けます。

function Document({
  children,
  title
}: {
  children: React.ReactNode;
  title?: string;
}) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width,initial-scale=1" />
        {title ? <title>{title}</title> : null}
        <Meta />
        <Links />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
        {process.env.NODE_ENV === "development" && <LiveReload />}
      </body>
    </html>
  );
}

既定エクスポート等で使用されているDocumentコンポーネントの宣言箇所です。
Childrenとして子コンポーネントを受け取ったり、titleタグを出力しています。

ScrollRestorationはReact Routerのスクロール制御を踏襲したものかと思われます。
公式リファレンスによると、画面遷移時のブラウザのスクロール位置を復元するものだそうです。(ページ遷移を行い、該当の画面に戻ってきたとき、最後に該当のページに留まっていた際のスクロール位置を復元するということだと存じます。)
Scriptsタグの直前に配置するようです。

開発環境時のみ、LiveReloadコンポーネントレンダリングします。
LiveReloadコンポーネントを配置しておくことで、開発中に変更を加えた場合ブラウザを自動更新します。

function Layout({ children }: { children: React.ReactNode }) {
  return (
    <div className="remix-app">
      <header className="remix-app__header">
        <div className="container remix-app__header-content">
          <Link to="/" title="Remix" className="remix-app__header-home-link">
            <RemixLogo />
          </Link>
          <nav aria-label="Main navigation" className="remix-app__header-nav">
            <ul>
              <li>
                <Link to="/">Home</Link>
              </li>
              <li>
                <a href="https://remix.run/docs">Remix Docs</a>
              </li>
              <li>
                <a href="https://github.com/remix-run/remix">GitHub</a>
              </li>
            </ul>
          </nav>
        </div>
      </header>
      <div className="remix-app__main">
        <div className="container remix-app__main-content">{children}</div>
      </div>
      <footer className="remix-app__footer">
        <div className="container remix-app__footer-content">
          <p>&copy; You!</p>
        </div>
      </footer>
    </div>
  );
}

Layoutコンポーネントは、この中で一番defaultLayoutっぽい箇所です。
システム全体の共通のレイアウトがまとまっているように見えます。
Documentコンポーネント、あるいは、ErrorBoundary内でのエラー時処理、あるいは子インデックスルートのコンポーネント等のReact.nodeを受け取り、描画を行う際の共通の処理について記述を行っています。
ここでは、ヘッダー、フッターを定義しているようです。

function RemixLogo() {
  return (
    <svg
      viewBox="0 0 659 165"
      version="1.1"
      xmlns="http://www.w3.org/2000/svg"
      xmlnsXlink="http://www.w3.org/1999/xlink"
      aria-labelledby="remix-run-logo-title"
      role="img"
      width="106"
      height="30"
      fill="currentColor"
    >
      <title id="remix-run-logo-title">Remix Logo</title>
      <path d="M0 161V136H45.5416C53.1486 136 54.8003 141.638 54.8003 145V161H0Z M133.85 124.16C135.3 142.762 135.3 151.482 135.3 161H92.2283C92.2283 158.927 92.2653 157.03 92.3028 155.107C92.4195 149.128 92.5411 142.894 91.5717 130.304C90.2905 111.872 82.3473 107.776 67.7419 107.776H54.8021H0V74.24H69.7918C88.2407 74.24 97.4651 68.632 97.4651 53.784C97.4651 40.728 88.2407 32.816 69.7918 32.816H0V0H77.4788C119.245 0 140 19.712 140 51.2C140 74.752 125.395 90.112 105.665 92.672C122.32 96 132.057 105.472 133.85 124.16Z" />
      <path d="M229.43 120.576C225.59 129.536 218.422 133.376 207.158 133.376C194.614 133.376 184.374 126.72 183.35 112.64H263.478V101.12C263.478 70.1437 243.254 44.0317 205.11 44.0317C169.526 44.0317 142.902 69.8877 142.902 105.984C142.902 142.336 169.014 164.352 205.622 164.352C235.83 164.352 256.822 149.76 262.71 123.648L229.43 120.576ZM183.862 92.6717C185.398 81.9197 191.286 73.7277 204.598 73.7277C216.886 73.7277 223.542 82.4317 224.054 92.6717H183.862Z" />
      <path d="M385.256 66.5597C380.392 53.2477 369.896 44.0317 349.672 44.0317C332.52 44.0317 320.232 51.7117 314.088 64.2557V47.1037H272.616V161.28H314.088V105.216C314.088 88.0638 318.952 76.7997 332.52 76.7997C345.064 76.7997 348.136 84.9917 348.136 100.608V161.28H389.608V105.216C389.608 88.0638 394.216 76.7997 408.04 76.7997C420.584 76.7997 423.4 84.9917 423.4 100.608V161.28H464.872V89.5997C464.872 65.7917 455.656 44.0317 424.168 44.0317C404.968 44.0317 391.4 53.7597 385.256 66.5597Z" />
      <path d="M478.436 47.104V161.28H519.908V47.104H478.436ZM478.18 36.352H520.164V0H478.18V36.352Z" />
      <path d="M654.54 47.1035H611.788L592.332 74.2395L573.388 47.1035H527.564L568.78 103.168L523.98 161.28H566.732L589.516 130.304L612.3 161.28H658.124L613.068 101.376L654.54 47.1035Z" />
    </svg>
  );
}

Header内で使用されているRemixのロゴです。
svgってみんなこれわかって書いているんですか?
ジェネレータとかあるんでしょうか......?

ここまでがエントリーポイントであるrootの解析でした。

routes/index.tsx

root.tsxは全てのコードに着目しましたが、routes内は気になるところをピックアップして見ていこうと思います。

routes/index.tsx
export let loader: LoaderFunction = () => {
    let data: IndexData = {
        resources: [
            {
                name: "Remix Docs",
                url: "https://remix.run/docs",
            },
            {
                name: "React Router Docs",
                url: "https://reactrouter.com/docs",
            },
            {
                name: "Remix Discord",
                url: "https://discord.gg/VBePs6d",
            },
        ],
        demos: [
            {
                to: "demos/actions",
                name: "Actions",
            },
            {
                to: "demos/about",
                name: "Nested Routes, CSS loading/unloading",
            },
            {
                to: "demos/params",
                name: "URL Params and Error Boundaries",
            },
        ],
    };

    return json(data);
};

LoaderFunction型の関数が出現しました。
パッケージ内のコードを読むと、LoaderFunction型はPromise、Response、AppDataをreturnします。
よって、該当のlet loader Functionは本来バックエンド側の処理を行うような使い方をされるものではないかと推測されます。
前述したentry.server.tsxを共通処理として、エントリーポイントであるindex.tsx内の/のリクエストであることから、
GET / 200(status code)をResponseとして返しているように読めます。
image.png

routes/index.tsx
export default function Index() {
    let data = useLoaderData<IndexData>();

    return (
        <div className="remix__page">
       {// この辺に<main>~なんちゃらかんちゃらありました。省略します。}
            <aside>
                <h2>Demos In This App</h2>
                <ul>
                    {data.demos.map((demo) => (
                        <li key={demo.to} className="remix__page__resource">
                            <Link to={demo.to} prefetch="intent">
                                {demo.name}
                            </Link>
                        </li>
                    ))}
                </ul>
                <h2>Resources</h2>
                <ul>
                    {data.resources.map((resource) => (
                        <li
                            key={resource.url}
                            className="remix__page__resource"
                        >
                            <a href={resource.url}>{resource.name}</a>
                        </li>
                    ))}
                </ul>
            </aside>
        </div>
    );
}

index.tsxの既定エクスポートです。
先ほどのloaderファンクション内で GET / 200 とResponseが書いてある様子を記述しましたが、肝心のreturnの受け取り方がここに書かれています。
useLoaderData関数はルートローダー関数からJSONを受け取るフックです。
この場合は、loaderからreturn json(data)で返されている値を let data にIndexData型として受け取っています。

routes/index.tsx
export let meta: MetaFunction = () => {
    return {
        title: "Remix Starter",
        description: "Welcome to remix!",
    };
};

MetaFunction型を使うと、metaタグをオーバーライド出来るようです。

Prismaを導入し、DBに対して取得・作成処理を行ってみる

ORMはPrisma、DBはSQLiteを利用し、サーバー側の処理をコンポーネント内に書いてみたりしてみようと思います。

とりあえずprisma関連のライブラリをインストールします。

$ npm install --save-dev prisma
$ npm install @prisma/client

チュートリアルに従い、Jokesというテーブルを作るため、以下のファイルを作成します。

prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model Joke {
  id         String   @id @default(uuid())
  createdAt  DateTime @default(now())
  updatedAt  DateTime @updatedAt
  name       String
  content    String
}

prismaのマイグレーションファイルは初めて書きましたが、書き方とか独特だなーって思います。
DATABASE_URLをenvに記述します。

.env
DATABASE_URL="file:./dev.db"

seedファイルも作成します。

prisma/seed.ts
import { PrismaClient } from "@prisma/client";
let db = new PrismaClient();

async function seed() {
  await Promise.all(
    getJokes().map(joke => {
      return db.joke.create({ data: joke });
    })
  );
}

seed();

function getJokes() {
  return [
    {
      name: "シードで作成したJoke Data",
      content: `このデータは開発チュートリアル用にseedしています。`
    },
    {
      name: "シードで作成したJoke Data 02",
      content: `このデータは開発チュートリアル用にseedしています。02`
    },
  ];
}

まあまあ、ふーんって感じです。
色々調べてみましたが、prismaの記述は情報量も多いので、割愛します。
とりあえずマイグレーションしてシーダーを実行してみます。

$ npx prisma init --datasource-provider sqlite
$ npx prisma db push
$ npm install --save-dev esbuild-register
$ node --require esbuild-register prisma/seed.ts

順に、prismaをsqliteでinitし、dbをsqliteに出力、esbuild-registerでseedファイルを実行しています。

DBからの取得

app/routes/demos/にjokes.tsxを作成しました。

jokes.tsx
import type { LoaderFunction } from "remix";
import { Outlet } from "react-router";
import type { Joke } from "@prisma/client";
import { useLoaderData, json, Link } from "remix";
import { db } from "~/utils/db.server";

type LoaderData = {
    jokeListItems: Array<{ id: string; name: string; content: string }>;
};

export let loader: LoaderFunction = async () => {
    let data: LoaderData = {
        jokeListItems: await db.joke.findMany({
            // take: 5,
            select: { id: true, name: true, content: true },
            orderBy: { createdAt: "desc" },
        }),
    };
    return data;
};
export default function Jokes() {
    let data = useLoaderData<LoaderData>();
    return (
        <>
            <ul>
                {data.jokeListItems.map((joke) => (
                    <li>
                        {joke.name}<p>{joke.content}</p>
                    </li>
                ))}
            </ul>
            <Outlet />
        </>
    );
}

処理を分割して読んでいこうと思います。

type LoaderData = {
    jokeListItems: Array<{ id: string; name: string; content: string }>;
};

取得し、受け取る情報の型を定義します。

export let loader: LoaderFunction = async () => {
    let data: LoaderData = {
        jokeListItems: await db.joke.findMany({
            // take: 5,
            select: { id: true, name: true, content: true },
            orderBy: { createdAt: "desc" },
        }),
    };
    return data;
};

root.tsxにも出てきたloaderFunctionですが、DBからの取得を行う場合はこれでデータが取得できます。
type LoaderDataで定義したjokeListItemsに、db.jokeと記述することでデータのセットが可能です。
findMany関数を利用する場合は、様々な条件を設定できるようです。
take = 取得するデータ数
select = 取得するカラム
orderBy = 整列順序
etc...
超簡単で手軽ですね。

エラーハンドリングを行っていない理由としては、エラーが起きた場合にBoudaryが勝手に起動してくれることです。
root.tsxで出てきたErrorBoundary、CatchBoundaryは、サーバーサイドの処理中のエラーもキャッチしてくれます。
また、jokes.tsx限定のErrorBoundary、CatchBoundaryを記述することも可能です。

export default function Jokes() {
    let data = useLoaderData<LoaderData>();
    return (
        <>
            <ul>
                {data.jokeListItems.map((joke) => (
                    <li>
                        {joke.name}<p>{joke.content}</p>
                    </li>
                ))}
            </ul>
            <Outlet />
        </>
    );
}

rootの時と同様、useLoaderDataを使って情報を受け取ります。
⇑で、localhost:xxx/demos/jokesにアクセスした場合にdb.jokeのデータを全表示するところまでが実装出来ました。

作成

localhost:xxx/demos/jokes画面の下らへんに作成用のインプットフィールドを作成しようと思います。
(jokes.tsxのOutletに嵌まるコンポーネントを作成します。)
app/routes/demos/jokes/index.tsxを作成しました。

app/routes/demos/jokes/index.tsx
import type { ActionFunction } from "remix";
import { redirect } from "remix";
import { db } from "~/utils/db.server";

export let action: ActionFunction = async ({ request }) => {
    let form = await request.formData();
    let name = form.get("name");
    let content = form.get("content");
    // we do this type check to be extra sure and to make TypeScript happy
    // we'll explore validation next!
    if (typeof name !== "string" || typeof content !== "string") {
        throw new Error(`Form not submitted correctly.`);
    }

    let fields = { name, content };

    let joke = await db.joke.create({ data: fields });
};

export default function JokesIndex() {
    return (
        <div>
            <p>Add your own hilarious joke</p>
            <form method="post">
                <div>
                    <label>
                        Name: <input type="text" name="name" />
                    </label>
                </div>
                <div>
                    <label>
                        Content: <textarea name="content" />
                    </label>
                </div>
                <div>
                    <button type="submit" className="button">
                        Add
                    </button>
                </div>
            </form>
        </div>
    );
}

読んでいきます。

export let action: ActionFunction = async ({ request }) => {
    let form = await request.formData();
    let name = form.get("name");
    let content = form.get("content");

    let fields = { name, content };

    let joke = await db.joke.create({ data: fields });
};

ActionFunction型は、引数としてDataFunctionArgs型を受け取り、Response、Promise、AppDataをreturnします。
formタグ内からsubmitされaction関数が呼び出された時、既定エクスポート内のformタグ内のデータを受け取ります。
formタグ内のデータをname,contentにセットし、db.joke.createします。
これで、作成が可能です。

総評

リリース直後で情報量が少ないことが大きなデメリットではありますが、remix自体はとても強力なフレームワークだと考えます。
エラー処理をイチイチ書かず、キャッチしたらそのままreactコンポーネントをレンダリングすることが出来る点や、
typescriptでサーバーサイドの記述が可能で、reactがベースである事そのもの等です。
機会があれば、またremixの記事も出していきたいと思っています。
フォロー等、よろしくお願いします。

40
19
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
40
19