白猫のメモ帳

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 />;
        },
    ],
};

終わりに

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

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