白猫のメモ帳

C#とかJavaとかJavaScriptとかHTMLとか機械学習とか。

Remix+Prisma+Storybookでお手軽Webアプリ

こんにちは。
まだ梅雨入りもしていないですが、そろそろ夏が来たって感じがしますね。

さて、せっかくRemixのチュートリアルをやったので試しにWebアプリを作ってみます。
今回はORMとしてPrismaを、UIコンポーネントの確認ツールとしてStorybookを使います。
お手軽と書いてはみたものの、それなりに悩んだり躓いたりしたので、そのあたりをメインに記事にします。

今回作ったものはこちらのGitHubリポジトリにコードの全量があるので、よろしければどうぞ。
あくまで学習目的のため、例えばチェック処理がまるっとないなどご了承ください。(気が向いたら機能追加します)

github.com

ちなみにこんな感じのレシピを管理するためのアプリになっています。(白ご飯.comさんの画像そのまま使わせてもらっています)

こっちは登録画面。

Remixまわりのあれこれ

コンポーネントの置き場所と書き方

チュートリアルでは「app/routes」下に全部のtsxファイルが収まっています。
実際には単体ではページとして利用しない通常のコンポーネントを作ることになるかと思いますので、これをどこに置くのかがちょっと悩みました。

答えとしては「app/components」みたいなディレクトリを作るで問題ありません。
「app/routes」下にファイルを作ってしまうとRemixが勝手にパスを生成してくれてしまうので注意です。
ちなみにFormなどのRemixコンポーネントは普通に利用できますが、エントリポイントではないのでloaderやactionは書けないので注意です。

ベースパスの変更

リバースプロキシの背後にアプリを置く場合などにベースのパスを変更することになりますが、これもちょっとハマりました。
結論としてはvite.config.tsを以下のような設定にします。

export default defineConfig({
  base: "/recipe/",
  plugins: [
    remix({
      basename: "/recipe/",
      future: {
        v3_fetcherPersist: true,
        v3_relativeSplatPath: true,
        v3_throwAbortReason: true,
      },
    }),
    tsconfigPaths(),
  ],
});

viteの設定であるbaseとremixの設定であるbasenameの両方に値を設定しないといけないのがポイントです。
調べたら出てきた設定を片方ずつ反映して、どっちもうまくいかないと困惑していました。

複雑なPOST

Remixはformを使ってWeb標準っぽくリクエストが投げられるのが素敵なのですが、それなりに複雑なデータをPOSTする場合にどうするのかちょっと悩みました。
通常の値を取得する際にはこんな感じでformDataからgetでデータを取得します。

const formData = await request.formData();
console.log(formData.get("hoge"));  // hoge

同じ値が複数取れる場合にはgetAllで配列で取れます。

const formData = await request.formData();
console.log(formData.getAll("fuga")); // [ "fuga1", "fuga2" ]

が、複雑な構造になってくるとgetAllで取得したものをzipして要素を作っていくのはちょっと怪しそうです。
そういう場合には普通にuseStateを使ってオブジェクトを管理し、useSubmitを使ってsubmit時にJSON化して送ってしまうのがシンプルそうです。

const  submitMenu = // 略
submit({ "json": JSON.stringify(submitMenu), "type": "update" }, { method: "post" });

受け取る側はJSON.parseで復元します。

const menu = JSON.parse(formData.get("json")!.toString()) as MenuAll;

Prismaまわりのあれこれ

migrateの挙動

Prismaを使う場合、既存のDBから情報を取ってくる「prisma db pull」と、Prisma側からDBを更新する「prisma migrate」のどちらを使うかというところが最初に気になります。
特にmigrateは破壊的な挙動になるので、間違った知識で実行してしまうと大変な事故になりかねないので挙動を確認しました。
結論から言えば、対象のデータベース(スキーマ)をこのアプリケーション用に新しく作るのであればmigrateをメインに使い、そうじゃない場合にはやめたほうが良さそうだなという感覚です。
(もちろん十分に気をつけてちゃんと管理すれば既存のデータベースに対しても利用できるとは思います)

通常、Prismaは接続先のデータベースのすべてを対象にします。なので、例えば「prisma db pull」で全テーブルの定義を引っ張ってきて必要なテーブルだけを残し、「prisma migrate」を実行すると他のテーブルは消えます。
しかもすごく恐ろしい話なのですが、「--create-only」というオプションを付けると安全なように見えて、同様に消えます。怖い。
そして、テーブルに変更がなくても初回にmigrateした時点でテーブルが作り直されてデータは消えます。怖い。
バックアップして戻すみたいな運用をする感じなのでしょうか。大量にデータが入っててレプリケーションとかしてる場合結構つらそう…。

DB定義を直接変更して「prisma db pull」してきたあとでも同じことが起きると思うので、migrateを使う場合にはDB定義を必ずPrisma経由に徹底する必要がありそうですね。
ちなみに定義情報はアプリケーションではなく自動生成される「_prisma_migrations」テーブルに保存されるので、Prismaで統一さえしていれば複数のアプリケーションで触ってもOKとかあるんでしょうか。未検証です。
なんか思ったよりmigrateを使うのが難しそうなので、なにか勘違いしていることがあるかもしれないです。今度改めて細かいことも確認してみたいです。

型をそのまま使ってしまいがち

全体的な感想としては型がガッツリ効くのがすごく良い感じです。
例えばこんな風に書くと、

const result = await prisma.menu.findUnique({
    where: { menuId: menuId },
});

こういう型で返ってくるのですが、

const result: {
    menuId: number;
    name: string;
    imageUrl: string | null;
} | null

こんな感じでincludeの指定をすると、

const result = await prisma.menu.findUnique({
    where: { menuId: menuId },
    include: { 
        steps: true,
        tags: true,
        links: true,
    },
});

型が変わります。

const result: ({
    links: {
        menuId: number;
        url: string;
        title: string;
    }[];
    steps: {
        menuId: number;
        seqNo: number;
        text: string;
    }[];
    tags: {
        menuId: number;
        tag: string;
    }[];
} & {
    menuId: number;
    name: string;
    imageUrl: string | null;
}) | null

ただ、Prismaがネストしたモデルを自動生成してくれるので、ついついそのまま画面側まで持っていってしまいたい気持ちになります。
今回まさにそうしてしまったのですが、これは素直にちゃんと型変換をしたほうがいいと思います。
includeの指定で返却される型が動的に変わる機能はとても素敵なのですが、無名の直積型のような型(?)で返ってくるので、DBから遠いところまでそのまま持っていくと型のエラーが何を言っているのかが正直全然良くわかりません。

どんなクエリが発行されているか謎い

普通にSQLを書いたら1回で取ってくるようなデータも別々のクエリで引っ張ってきて合体させているような挙動をよく取りがちです。
previewFeaturesのrelationLoadStrategyとかを使うとjoinを使ってくれるようになるようですが、ひとまずはどんなクエリが発行されているかは1回は確認しておいたほうが良いと思います。

PrismaClientを作成する際のオプションに以下のように設定すると、ログに発行するクエリが出るので随時確認しておきたいところです。

const prisma = new PrismaClient({
    log: ['query'],
});

なかなかがっつりなクエリです…。

prisma:query SELECT `db`.`menu`.`menuId`, `db`.`menu`.`name`, `db`.`menu`.`imageUrl` FROM `db`.`menu` WHERE (`db`.`menu`.`menuId` = ? AND 1=1) LIMIT ? OFFSET ?
prisma:query SELECT `db`.`menu_ingredient_group`.`menuId`, `db`.`menu_ingredient_group`.`groupId`, `db`.`menu_ingredient_group`.`groupName` FROM `db`.`menu_ingredient_group` WHERE `db`.`menu_ingredient_group`.`menuId` IN (?)
prisma:query SELECT `db`.`menu_ingredient`.`menuId`, `db`.`menu_ingredient`.`groupId`, `db`.`menu_ingredient`.`seqNo`, `db`.`menu_ingredient`.`name`, `db`.`menu_ingredient`.`amount` FROM `db`.`menu_ingredient` WHERE (`db`.`menu_ingredient`.`menuId`,`db`.`menu_ingredient`.`groupId`) IN ((?,?),(?,?),(?,?))
prisma:query SELECT `db`.`menu_step`.`menuId`, `db`.`menu_step`.`seqNo`, `db`.`menu_step`.`text` FROM `db`.`menu_step` WHERE `db`.`menu_step`.`menuId` IN (?)
prisma:query SELECT `db`.`menu_tag`.`menuId`, `db`.`menu_tag`.`tag` FROM `db`.`menu_tag` WHERE `db`.`menu_tag`.`menuId` IN (?)
prisma:query SELECT `db`.`menu_link`.`menuId`, `db`.`menu_link`.`url`, `db`.`menu_link`.`title` FROM `db`.`menu_link` WHERE `db`.`menu_link`.`menuId` IN (?)

Storybookまわりのあれこれ

エラーです
npx storybook@latest init

でインストール自体は問題なく進むのですが、起動するといきなりエラーになります。

github.com
このあたりのページを参考にしましたが、どうやらViteをサーバに使ったRemixではvite.config.tsの設定を変えてあげる必要があるらしいです。

sb-vite.config.tsというファイルを別途作成して中身は以下の通りにします。

import { defineConfig, loadEnv } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), '');
  process.env = { ...process.env, ...env };
  return {
    plugins: [tsconfigPaths()],
  };
});

そして.storybook/main.tsを以下の通り更新します。読み込ませる設定を変更するのと、storiesを更新してapp下のファイルを探すようにします。

import type { StorybookConfig } from "@storybook/react-vite";

const config: StorybookConfig = {
  stories: [
    "../app/**/*.stories.@(js|jsx|ts|tsx)"
  ],
  addons: [
    "@storybook/addon-onboarding",
    "@storybook/addon-links",
    "@storybook/addon-essentials",
    "@chromatic-com/storybook",
    "@storybook/addon-interactions",
  ],
  framework: {
    name: "@storybook/react-vite",
    options: {
      builder: {
        viteConfigPath: 'sb-vite.config.ts',
      },
    },
  },
};
export default config;

さらにRemixのコンポーネントをスタブにする必要があるため、対象のstoriesファイルでdecoratorsを指定します。
例えばこんな感じです。ユニットテストを書く場合にも同様にスタブ化する感じでしょうか。

const meta: Meta<typeof Sidebar> = {
    component: Sidebar,
    title: 'Sidebar',
    decorators: [
        Story => {
            const Stub = createRemixStub([
                {
                    path: '/',
                    Component: () => <Story />, 
                },
            ]);
            return <Stub />;
        },
    ],
};
export default meta;
スタイルが効かない

とりあえず表示できるようになったのですが、CSSを読み込んでいないのでスタイルが効きません。

import "../styles/common.css";

のように書くとcssファイルが読み込めます。
今回でいうと.containerのスタイルも併せて反映させたいので、Storyコンポーネントをwrapしてあげます。

Story => {
    const Stub = createRemixStub([
        {
            path: '/',
            Component: () => <div className="container"><Story /></div>, 
        },
    ]);
    return <Stub />;
},


routes下の場合の設定

コンポーネントの場合はデータは引数で受け取るので上記までで問題なのですが、routes下の場合にはloader関数がないとデータが取れなくてエラーになってしまいます。
その場合はdecoratorsに更にloader関数を足してあげます。

export const Empty: Story = {
    decorators: [
        Story => {
            const Stub = createRemixStub([
                {
                    path: '/',
                    loader: () => ({
                        menu: { 
                            menuId: 1, 
                            name: "メニュー1", 
                            steps: [] as MenuStep[], 
                            tags: [] as MenuTag[], 
                            links: [] as MenuLink[],
                            ingredientGroups: [] as IngredientGroup[],
                        } as MenuAll,
                        buttonName: "登 録",
                    }),
                    Component: () => <div className="container"><Story /></div>,
                },
            ]);
            return <Stub />;
        },
    ],
};
CSSが全部読み込まれる

importを使ってコンポーネントごとに別々のCSSを読み込んでいるつもりなのですが、よく見ると全部のコンポーネントCSSがまとめて読み込まれてしまっています。
互いに干渉しなければこのままでもいいのですが、そうじゃない場合には困ります。とりあえず名前付きでimportしてstyleタグに直接書き込むという方法でなんとかなりましたが、本当にこれでいいのかはとても謎です。

import menuCss from "../styles/menu.css?raw";

// 略

export const Empty: Story = {
    decorators: [
        Story => {
            const Stub = createRemixStub([
                {
                    path: '/',
                    loader: () => ({
                        menu: { 
                            menuId: 1, 
                            name: "メニュー1", 
                            steps: [] as MenuStep[], 
                            tags: [] as MenuTag[], 
                            links: [] as MenuLink[],
                            ingredientGroups: [] as IngredientGroup[],
                        } as MenuAll
                    }),
                    Component: () => 
                        <>
                            <style>${menuCss}</style>
                            <div className="container"><Story /></div>
                        </>,
                },
            ]);
            return <Stub />;
        },
    ],
};

終わりに

そんなに高機能なアプリじゃないのですが、知らないことが多かったのでなんだかんだ時間がかかりました。
ただ、作りたいものに対してうまくいかないをなんとかするというのが一番理解が進む瞬間だと思うので、簡単なものでもいいからこういうのが作りたいと思って手を動かすのが良いと思います。

それぞれ単品でも便利に使えるものなので、うまく使っていければと思います。
それでは。

Remixのチュートリアルをやってみる

こんばんは。

App Routerが出るくらいの頃からNext.jsをちらほら使っていたのですが、今回はRemixのチュートリアルをやります。
Next.jsがちょっと重たいなと思うのと(処理がではなくフレームワークの規模的に)、微妙に融通が効かないと思うことがあるので、もう少しシンプルなフロントエンドフレームワークを触ってみようかなという思惑です。

Remixとは

RemixはReactベースのフルスタックWebフレームワークです。
主にSSRですが、最近SPAモードにも対応したとかなんとか。
基本方針としてWeb標準をかなり重視しているらしいです。

今回のチュートリアル

Tutorial(30m)だそうです。ほんとに30分で終わるんでしょうか。
ちなみにチュートリアルの中身を全部書いても仕方がないので、ところどころ省略します。詳しく見たい場合は必ずオリジナルのページを参照してください。
その代わりに感想やらなにやら書いていこうと思います。
remix.run

プロジェクトを作ります

まずはnpxコマンドでチュートリアル用のプロジェクトを作ります。

npx create-remix@latest --template remix-run/remix/templates/remix-tutorial

基本Enter押していけばいいと思いますが、せっかくなのでプロジェクト名は変えました。

 remix   v2.9.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

できるファイルは結構シンプルですね。

作ったフォルダに潜ってとりあえずサーバーを立ち上げます。

cd remix-tutorial
npm run dev

http://localhost:5173/ にアクセスすると、Remixのデフォルトページが表示されます。
はい。チュートリアルのとおりですね。

tsxファイル1つしかないので、とりあえずroot.tsxを覗いてみます。こんな感じ。

import {
  Form,
  Links,
  Meta,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";

export default function App() {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />  <!-- 子ページからmetaの情報が差し込まれる? -->
        <Links />  <!-- ここにCSSが入ってくるのかな? -->
      </head>
      <body>
        <!-- 長いので省略 -->
        <ScrollRestoration /> <!-- これはReactのスクロール復元するやつ -->
        <Scripts />  <!-- ここにReactの処理が入ってくるのかな -->
      </body>
    </html>
  );
}

スタイルシートの追加

これを足してみよとのこと。CSSが読み込まれてなかったのでシンプルな画面だったんですね。
このlinksがコンポーネントに収まるみたいです。

cssのうしろの?urlはなんなんだろうと思って外してみたら、CSSがstyleタグでインラインに展開されました…。
つけておいたほうが良さそうですね。

import type { LinksFunction } from "@remix-run/node";
// existing imports

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

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

ということはtitleとかのmetaタグはこうですね。(寄り道しがち)

import type { LinksFunction, MetaFunction } from "@remix-run/node";

export const meta: MetaFunction = () => [
  { title: "Remix Contacts" },
  { description: "A contacts app built with Remix" },
];

おしゃれになりました。が、この段階ではリンクを押しても404です。

ルーティングの追加

リンク先は http://localhost:5173/contacts/1http://localhost:5173/contacts/2 です。
これらのルーティングを足していきます。

まずは「app/routes/contacts.$contactId.tsx」ファイルを作ります。
$は変数なのでNext.jsでいうところの[]にあたりそうです。routes/の下がパスになるってことですね。
「.」はパスでは「/」になる…ということはフォルダは使わないでファイルをひたすら平らに並べるのかな。

ファイルの中身はチュートリアルページからコピペします。

import { Form } from "@remix-run/react";
import type { FunctionComponent } from "react";

import type { 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>

// 長いので省略

これでリンク先が表示されるようになりました。(中身ないけど)

アウトレットの追加

多分ショッピングモール的なあれではないですよね。
見た感じroot.tsxがNext.jsのlayout.tsxにあたるもので、Outletは{children}の変わりみたいな感じですね。

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

アバター画像のURLが404なのは何故なのでしょう。

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

SSRは初回レンダリングをサーバサイドで行いますが、以降のページ遷移はクライアントサイドで行うことができます。
aタグを使うとリクエストがサーバに飛んでしまうので、Linkコンポーネントを使います。

ややこしい話なんですが、SSRは文字通りサーバサイドでのレンダリングなので、普通に作るとMPAになります。
SSRで生成したページからクライアントサイドに続きを任せるのは本来はユニバーサルレンダリングとか言われるものです。
ただ、最近はNext.jsやNuxt.jsでも当たり前にできているので、SSRにこの意味が内包されているような気もします。

// existing imports
import {
  Form,
  Links,
+  Link,
  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 */}
          <nav>
            <ul>
              <li>
-                <a href={`/contacts/1`}>Your Name</a> 
+                <Link to={`/contacts/1`}>Your Name</Link>
              </li>
              <li>
-               <a href={`/contacts/2`}>Your Friend</a>
+               <Link to={`/contacts/2`}>Your Friend</Link>
              </li>
            </ul>
          </nav>
        </div>
        {/* other elements */}
      </body>
    </html>
  );
}

データ取得

サーバサイドでデータ取得をします。ちょっと変更箇所が多いので、追加分だけ。
チュートリアルでは一旦コンパイルエラーにしてから修正してますがまとめてやってしまいます。

jsonはRemix用にJSON化してくれる関数です。
getContactsはdata.tsにあるデータ取得関数です。

import { json } from "@remix-run/node";
import { getContacts } from "./data";

loaderが実際のデータ取得の関数です。
名前は固定みたいですね。名前変えたらエラーになりました。

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

loader関数を使ってデータを取得します。
loaderじゃなくてtypeof loaderなのは、loader関数の型が欲しいからですね。

+ const { contacts } = useLoaderData<typeof loader>();

読み込んだデータからコンテンツを作るところ。普通にJSXですね。

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

パラメタを受け取ってリンク先を表示できるようにする

ここも一旦コンパイルエラーにしていますが、まとめてやってしまいます。
$contactIdを変数として受け取れるので、これを使ってデータ取得します。

tiny-invariantはエラーチェック用のライブラリです。

import { json } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/node";
import invariant from "tiny-invariant";
import { getContact } from "../data";

loader関数の中でパラメタを受け取ります。
contactのチェックをしないとContactRecord | nullになってしまうので、404エラーに落とします。

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

いい感じです。

404エラーは凄くシンプル…。

データの追加

ここまでで参照系ができたので、次は更新系です。
root.tsxにloaderと同じように今度はaction関数を追加します。これも名前は固定ですね。
Remixがシンプルにformのsubmit時にこの処理を呼んでくれるようです。
(というよりはPOSTのときに呼ばれるだけで、自身のパスにPOSTしてるからってことかな…)

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

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

これで一応Newボタンは押せるようになりましたが、空っぽのデータです。

データの更新

更新画面を作るために新しいファイル「app/routes/contacts.$contactId_.edit.tsx」を足します。ファイルの中身は長いので省略。

ここで「_」がつくのが何故なのかややこしいですが、「_」がないと「contacts.$contactId.tsx」をレイアウトとみなしたネストしたページとみなされてしまうようです。
基本的にはURLをネストしたらレイアウトもネストされるという考え方みたいですね。
正直この辺りルーティングはかなり命名規則で縛ってる感じなので、ちゃんと理解しないとつらそうです…。

Editボタンで編集画面に行けるようになりましたが、これはformのaction="edit"だからってことですよね。
となりのDeleteボタンはaction="destroy"だけどこっちは画面遷移しなそうだけどどうするんでしょう。
export defaultなしでactionだけ定義するのかな。(たぶん後でわかると思う)

<Form action="edit">
  <button type="submit">Edit</button>
</Form>

更新系の処理も追加すると更新できるようになりました。
チュートリアルではチュートリアルを書いてくれている人を登録していますが、髪型似合ってると思います。

Fill out the form, hit save, and you should see something like this! (Except easier on the eyes and maybe less hairy.)
フォームに記入して保存すると、次のような画面が表示されます。(目に優しく、毛が少ないことを除いては)

更新処理をもうちょっとよく見てみます。
Object.fromEntriesでformDataをオブジェクトに変換、最後にリダイレクトして更新した人のページに飛ばしているようです。
requestがWeb標準のRequestオブジェクトなのが素敵です。

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 imports
import { json, redirect } from "@remix-run/node";
// existing imports

export const action = async () => {
  const contact = await createEmptyContact();
  return redirect(`/contacts/${contact.id}/edit`);
};

// existing code

見た目の調整

選択中のリンクの色変えと、ローディング中に半透明にする処理をroot.tsxに追加します。
NavLinkコンポーネントはアクティブ状態とペンディング状態を操作し、useNavigation()は現在のページの状態を取得します。
(だんだん変更箇所増えてきてつらいので、変更の際はimport省略します)

export default function App() {

  const { contacts } = useLoaderData<typeof loader>();
+  const navigation = useNavigation();

  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>
        <div 
+          className={
+            navigation.state === "loading" ? "loading" : ""
+          }
          id="detail">
          <Outlet />
        </div>
      </body>
    </html>
  );
}

色変えはわかりやすいですが、ローディングはよくわからないですね。
data.tsのgetContactで遅延を入れてみると半透明になるのがわかりました。

export async function getContact(id: string) {
+  await new Promise((resolve) => setTimeout(resolve, 1000));
  return fakeContacts.get(id);
}


データの削除

更新と同様に新しいファイル「app/routes/contacts.$contactId_.destroy.tsx」を足します。
中身はこんな感じです。想定通りactionだけ定義するとAPI的に使えるということですね。
(何故にdeleteではなくdestroyなんでしょうか)

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("/");
};

ルート画面の賑やかし

ルート画面のメインコンテンツが何もないのが寂しいのでコンテンツを追加するらしいです。
これはつまり/にアクセスするとOutletコンポーネントが空の状態だからです。

「app/routes/_index.tsx」を追加すると、/にアクセスしたときに表示されるようになります。
「_index.tsx」もまた特別な名前ですね。このチュートリアルでは出てこないですが、前につくアンダースコアはパスを隠すような使い方をするらしいです。
例えば「/hoge」は「/app/routes/_fuga.hoge.tsx」を呼び出し、レイアウトに「/app/routes/_fuga.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>
  );
}


キャンセルボタン

編集画面のキャンセルボタンが何も起こらないので処理を追加します。
「app/routes/contacts.$contactId_.edit.tsx」を編集します。
navigate(-1)は前の画面に戻る処理です。historyAPIをシンプルに使えるのはいいですね。

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

検索

検索フォームが最初からあるのですが、入力しても何も起きません。
「app/routes/_root.tsx」を編集します。
ここも無理にラップされずにRequestのurlからURLオブジェクトを作ってとWeb標準っぽくてよい感じです。

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


URLと検索フォームの同期

検索後にページを更新すると、検索フォームが空ですが、リストがフィルタリングされている状態になります。
直前でloaderがパラメタアクセスするようにしているので、検索フォームの値をURLに反映させることで解決します。

一方で検索後に「戻る」をクリックすると、URLが変わってフィルタリングされなくなりますが、検索フォームの入力が残ったままになります。
これにはReactのuseEffectを使って検索フォームの値をURLに反映させることで解決します。
(document.getElementByIdで直接値を取っていますが、useStateを使うのもいいねともこっそり書いてありますね)

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 });
+  return json({ contacts, q });
};

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

+  useEffect(() => {
+    const searchField = document.getElementById("q");
+    if (searchField instanceof HTMLInputElement) {
+      searchField.value = 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"
+                defaultValue={q || ""}
                id="q"
                name="q"
                placeholder="Search"
                type="search"
              />
              {/* existing elements */}
            </Form>
            {/* existing elements */}
          </div>
          {/* existing elements */}
        </div>
        {/* existing elements */}
      </body>
    </html>
  );
}

入力中のリアルタイム検索

現在は検索フォーム上でEnterを押すとリストが更新されますが、リアルタイムで検索したいです。
onChangeイベントを使って検索フォームの値が変更されるたびにフォームを送信するようにします。

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"
+    );

  // 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"
            >
              <input
                aria-label="Search contacts"
                className={searching ? "loading" : ""}
                defaultValue={q || ""}
                id="q"
                name="q"
                placeholder="Search"
                type="search"
              />
              <div
                aria-hidden
-                hidden={true}
+                hidden={!searching}
                id="search-spinner"
              />
            </Form>
            {/* existing elements */}
          </div>
          {/* existing elements */}
        </div>
        <div 
          className={
-            navigation.state === "loading"
+            navigation.state === "loading" && !searching
              ? "loading"
              : ""
          }
          id="detail">
          <Outlet />
        </div>
        {/* existing elements */}
      </body>
    </html>
  );
}

ちなみに連打されるので、こういうのキャッシュをちゃんとしないとすごいことになりますよね。
jsonの引数にheaderの設定ができるので、ここに「Cache-Control」を入れるとキャッシュもできそう。

return json(
    { contact }, 
    { headers: { "Cache-Control": "public, max-age=3600" } 
});


検索履歴の管理

useEffectを使って戻るができるようになりましたが、1文字入力するごとにURLが変わるので戻っても1文字分しか戻れなくなってしまいました。
submitする際にreplaceを使うとhistoryにpushではなくreplaceされるので、戻るができるようになります。

<Form
   id="search-form"
-   onChange={(event) => 
-      submit(event.currentTarget)
-   }
+   onChange={(event) => {
+      const isFirstSearch = q === null;
+      submit(event.currentTarget, {
+         replace: !isFirstSearch,
+      });
+   }}
   role="search"
>

ナビゲーションしないform

これまではすべてURLが変更されるformを扱ってきましたが、ナビゲーションせずにデータを送信したい場合もあります。
この場合にはuseFetcher()を使うと良いらしいです。「app/routes/contacts.$contactId.tsx」を編集します。
ただ、この場合は「contacts.$contactId.tsx」のアクションがこの機能に使われてしまうのでちょっと気になりますね。
別の「contacts.$contactId_.fav.tsx」とか作ってもいいような気もします。

+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",
+  });
+};

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

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

楽観的なUI(Optimistic UI)

星マークをクリックしてから反映されるまでに少し時間がかかります。
fetcher.formDataはpost時点でそのデータを知っているので、参照すれば即座に反映できます。
(formを送信すると再レンダリングがかかるのか、fetcher.formDataを参照してるから再レンダリングかかかるのか…はて)
ちなみにここで反映されるのは詳細の方で、リストの★はactionのupdateContactで反映されます。

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

// existing code


おしまい

チュートリアル終了です。ずいぶん長くなってしまいました。
脇道に逸れてドキュメント読んでいたり、アウトプットもしていたので全然30分じゃ終わりません。
「ふんふん」って思いながらコピペして動作確認してだけやってれば30分くらいかもですね。
でもせっかくやるなら絶対手を動かしたほうがいいと思います。

なんとなくベースの部分はわかった気がするし、書いてる感じもわりとしっくりくる感じだったので次はなにか作ってみようかなと思います。
それでは。

ベクトル検索に思う学びとの付き合い方

はじめに

キーワード検索に替わる、もしくは補うセマンティック検索としてベクトル検索が登場してしばらく経ちますが、最近の生成AIの盛り上がり、特にRAGパターンの流行によってその知名度は一気に上がったように思います。

あらゆる「モノ」「コト」が数値によって表すことができる(かもしれない)というのは、コンピュータにとって非常に大きな意味を持ちますが、コンピュータ以外の文脈で考えてみてもなかなかに興味深いものです。

ベクトル検索とは

この記事はベクトル検索についての技術的な解説を目的としているわけではないので、解説に関しては最小限に留めておきます。
誤解を恐れずにごくごく簡単に説明すると、

  • ベクトルは、例えば単語や文章を数値化したもの
  • ベクトル検索は、あるベクトルに近いベクトルを検索する技術

です。

ベクトルというと遠い昔の数学の記憶から「大きさと向きを持った量」のイメージが強いですが、コンピュータの世界ではそのような物理的な意味合いよりも、「データ(数値)の集合」としての意味合いが強いです。
ベクトル検索におけるベクトルも後者の意味で使われています。(3次元の座標を3次元のベクトルと呼んで、3次元の配列で表したら一緒だよねみたいな)


知識をベクトルで表現すると

ベクトル化の最大の特徴は、あらゆる「モノ」「コト」を数値化できるという点です。
ただ、その次元数はかなり高次元になることが一般的で、例えばOpenAIのembedding-ada-002は1,536次元です。
高次元を可視化するのはとても難しいので、ここでは物凄くシンプルに果物のベクトルを「酸味」「甘味」の2次元で表現してみます。

注意としては本来のベクトル化はもっと複雑な意味をベクトルとして表すので、単語単体ではなく文脈におけるベクトルを計算します。
例えば「スーパーに売っているリンゴ」と「ニュートンが落としたリンゴ」のベクトルは(もしかしたら近いかもしれませんが)別物になるはずです。

さて、まずは「レモン」と「バナナ」だけを知っている状態です。
この状態で酸味よりは甘味がちょっと強い果物ってなんだろうという疑問を持ったとすると、「どちらにもあまり近くはないけれど、どちらかというとバナナのほうが近いかな」という認識になります。


学びによる新たな知識の獲得

では、この状態で「リンゴ」について学んだとします。
もともとイメージしていたベクトルにかなり近いベクトルに「リンゴ」がインプットされました。
想像していた果物はどうやら「リンゴ」にかなり近そうです。

同様にもっとたくさんの果物について学んでいくと、様々なベクトルに近しい果物が見つかるようになります。
このように既知のベクトルを増やしていくことは「学び」の一つの代表的な形です。


学びによる既知の知識の更新

ある日、パイナップルにも甘いものとそうでもないものがあることを知りました。
新しい知識をインプットするだけでなく、既知の知識を更新することも「学び」の形です。

これによってパイナップルかもしれないと考えることができる範囲を広げることができました。
ベクトルをずらしたり、ひょっとしたら回転することもできるかもしれません。

概念とアナロジー

「モノ」だけではなく「コト」もベクトル化できると考えると、概念をベクトルで表現することも可能です。
例えば砂糖を入れて煮込むことを「ジャムを作る」という概念としてベクトル化すると、オレンジに加算すればオレンジジャムが作れます。

逆にリンゴジャムからリンゴを減算すると、「ジャムを作る」という「コト」のベクトルが作り出せます。(ここでは「大きさと向き」の概念のほうがわかりやすいかもしれません)

もちろん現実ではジャムにすることは固定量の甘味を足すだけではないですが、この概念を理解することでレモンをジャムにするとどんな味になるかを予測することができそうです。

ところで、一見異なるある概念と別の概念に類似性を見出すことをアナロジー(類推)といいます。
Wikipediaからもう少しちゃんとした定義を引用すると以下の通りです。

類推(るいすい)または類比(るいひ)、アナロジー(analogy)とは、特定の事物に基づく情報を、他の特定の事物へ、それらの間の何らかの類似に基づいて適用する認知過程である。

たとえば料理を完成させるための詳細な手順という意味で料理のレシピと設計図とか、食材の新鮮さを保って後で使えるように保存するという意味での冷蔵庫とタイムカプセルとか、料理の味に深みと複雑さを加える意味での調味料と絵の具とか。
もしかしたらこれらをベクトルとして表現してみたら、意外と似ているのかもしれないですね。(全部ChatGPTさんに考えてもらいましたが、ちょっとわかりづらいですね…)

アナロジーを使いこなせるようになると、新しいアイデアを生み出すことができるかもしれません。これももちろん「学び」の一つです。
例えば料理のレシピを開発手法に見立ててアジャイルカレーとか…あれなんか最近どっかで見た覚えが…。

ネガティブケイパビリティ

学びを進めていくと既知のベクトルが増えていきます。
これによって完全に経験したことのない出来事でも、既知のベクトルに近しいものとして対応することができるようになります。

一方で、本当は別物なのに既存のベクトルに近いからという理由で同じものとして扱ってしまう可能性があります。
特に名前を知らない概念に名前があることを知ったとき、ついつい似たような自分独自の得も言われぬ感情や感性に一般的な名前を当てはめてしそうになります。

子供の頃の休日の夕方に車の後部座席からぼんやりと眺めた空を「チルい黄昏時」と呼んでしまったら、なんだか急にチープな気がしてしまいます。(例えがまったくもって分かりやすくないですね…)

「シラケつつノリ、ノリつつシラケること」で有名な「構造と力」では次のような言葉があります。
なかなかにややこしい文章ではありますが、名を与えられることで必然的にズラされずにはいないというのは実に本質的です。

卵の片割れとしての幼児が喪われた半身を求めて叫びをあげるとき、ドラマが始まる。それ自体としては無意味な、いやむしろ過剰なサンスを孕んだ、無志向的な叫びである。しかし、この叫びはただちにシニフィアンの鎖にすくい取られ、名を与えられる、と言うのは他でもない、おとなたちはこの叫びに「ミルクが欲しい」「暖かくしてほしい」等々といった意味を聞き取るのである。幼児が本当に欲しているのが完全な卵に戻ることであってみれば、それは名を与えられることで必然的にズラされずにはいない。

再度同じ画像を使いますが、このイメージしている果物がリンゴに近いからリンゴとして扱うか、 リンゴに近いけれど別の果物が存在する可能性を考え続けるかというのはとても大きな違いです。

このような答えの出ない課題に対して安易に答えを出さず、わからないものをわからない状態にキープすること、「しないでおく」能力をネガティブケイパビリティと呼びます。

ネガティブケイパビリティを発揮することでより近いベクトルを発見することができるかもしれませんし、そのベクトルに該当する概念が存在しないことがわかったなら、それをビジネスチャンスなどに繋げることもできるかもしれません。
この近しいベクトルに吸い込まれず、本来のベクトルを見失わずにいることは、「学び」において重要なポイントに思えます。

おわりに

仕事や、それ以外の人生においても手持ちの武器でなんとかしなければならないことは多々あると思います。
そんなときに知識の引き出しが多いことは、様々な場面で自身を助けることになります。
一方で知識を更新していかなければ、思い込みや間違った認識で誤った判断をしてしまうこともあるはずです。

構造を理解し、アナロジーを使うことで新しいアイデアに繋げることができますし、未知の領域の理解を大幅にショートカットすることができるかもしれません。
ただし、安易に近しいものを同じものとして扱ってしまうことには注意が必要です。
ネガティブケイパビリティを発揮して答えの見つからない状態をキープしながら、答えを探し続けることが重要な場面も多そうです。

それを勉強と呼ぶかは置いておくとして、学び続けることを大切にしたいものです。
自身の学びをベクトルデータベースにインデックスすることと考えると、またちょっと違った視点が見えてくるかもしれません。

最近なんとなく考えていたことを書いてみました。
ベクトル検索って人間の知識に当てはめてみてもそんなに違和感がないなと。
全部ちゃんと表現できたかというと怪しいのですが、アウトプットすることでまとまっていなかった考えが少し整理できたような気がします。

それでは。