Cloudflare Pages + TanStack Router + TanStack Query + CSS Grid Layout で、りんごの系譜図を作ってみた

以前、食べたりんごをグラフ化するアプリを作りました。
Cloudflare Pages・Workers + Hono + React + Chart.js で食べたリンゴの割合をグラフ化してみた - メモ的な思考的な

これで「今までどれくらい食べてきたのか」を知ることができ、とても便利です。

 
そんな中、いろいろなりんごを食べていくうちに、自分が好きなりんごの傾向がわかりました。どうやら、 千秋 を祖先に持っているりんごが好きなようです。

 
ただ、最近のりんごは世代を重ねているため、りんごの掛け合わせを見ても千秋が出てきません。

また、りんごの掛け合わせをすべて覚えているわけではないため、「千秋が祖先にいるりんご」というのがパッと出てきません。

これでは不便なのでどうすればよいか考えたところ、競馬の系譜図(血統表)みたいなものがあると良さそうと感じました。
例:Golden Apples | 優駿達の蹄跡

 
Webで「りんごの系譜図」を軽く探してみましたが、見当たりませんでした。

そこで、「ないなら作ろう」の精神で作ってみたことから、その時のメモを残します。

 
目次

 

作ったもの

記事が長くなったので、まずは作ったものを置いておきます。

 

デプロイ先

以前作ったアプリに系譜図機能を追加しました。そのため、以下のページから遷移することで系譜図が確認できます。
https://ringosky.thinkami.dev/genealogies

 
なお、このアプリは無料プランの Cloudflare Pages を使っています。

このアプリはそんなに流入が多くないと思うので落ちたりしないと思います。ただ、無料プランの上限を突破してしまった場合は動作しなくなります。

 

スクリーンショット

後述の通り、今回は「デザインをがんばらない」方針としたため、さっぱりとした画面になっています。

 

一覧画面

まずはこの画面から、自分が系譜図を知りたいりんごを探します。

系譜図を見たいりんごの行クリックすると、次の画面へと遷移します。

 

りんごの系譜図画面

以下は、シナノゴールドの系譜図です。URL的にはこちら。
https://ringosky.thinkami.dev/genealogies/shinano_gold

 
ちなみに、系譜図中のりんご名をクリックすると、そのりんごの系譜図へと遷移します。

上記例であれば、 フジ をクリックすると、 フジの系譜図が表示されます。

 

メニュー

左上のハンバーガーボタンをクリックすることで、メニューが表示されます。

 

環境

  • ローカルの開発環境
  • デプロイ先
    • Cloudflare
      • Pages
      • Workers
      • D1
      • KV
  • フロントエンド
    • React 18.3.1
    • Chart.js 4.4.2
    • react-chartjs-2 5.2.0
    • TanStack Router 1.38.1
    • TanStack Query 5.45.1
  • バックエンド
    • Hono 4.4.12
    • Drizzle ORM 0.31.2
    • Drizzle Kit 0.22.7
    • @atproto/api 0.12.23
      • Bluesky API用ライブラリ
  • 開発向けツール
    • Wrangler 3.83.3
    • Biome 1.8.1
      • Linter & Formatter

 

(現時点では) やらないこと

今回、「完成させること」をゴールにします。

そこで、現時点ではやらないことを明確にしておきました。

 

デザインはがんばらない

デザイン方面が得意ではないので、デザインを考え出すといつまでたっても完成しなさそうと考えました。

そこで、まずは動くものができればよいと考え、デザインはあとから適用することにしました。

ただ、見栄えをちょっとは良くしたい & 便利なコンポーネントを使いたいと考えました。そこで、MUIを使って最低限の見栄えとしました。
MUI: The React component library you always wanted

 

スマホには正式対応しない

デザインを適用するのと同時期に行えばよいと考え、今回スマホには正式な対応を行わないことにしました。

 

すべての品種に対して系譜図を作成しない

以下のサイトによると、日本にあるりんごの品種は2,000種類にもなるようです。
りんごの品種 | 青森りんご公式サイト(一社)青森県りんご対策協議会

最初から2,000種類の系譜図を作ろうとしても、データを用意するだけで時間がかかってしまいます。

そこで今回は小さく始めることにしました。具体的には、前回の記事で使った品種のみの系譜図を作ることにしました。
親子情報を持つテーブルとSQLの共通テーブル式(CTE)を使って、曽祖父母までの祖先を取得する - メモ的な思考的な

 

実装上のメモ

作りたいものは決まっているため、あとは実装すればよいだけです。

ただ、実装する上で考えたり調べたりしたことがあったため、メモとして残しておきます。

 

系譜図をCSS Grid Layout で作ることにした経緯

上記の「優駿達の蹄跡」サイトでは、HTMLの table タグを使って血統表を表現していました。

今回のアプリでもtableタグを使えば容易に実現できそうですが、将来のスマホ対応を考えると table タグは採用しづらいです。

 
次に、今回使うライブラリ MUI に含まれる Grid v2 component を使うことを考えました。
React Grid component - Material UI

MUIを使うのだからそのコンポーネントを使っても良さそうでしたが、MUIへの依存が強くなってしまうのが気になりました。

 
続いて、CSSを使って実現する方法を調べてみました。

すると、

あたりが使えそうでした。

引き続きMDNの以下の記事を読んだところ、系譜図は CSS Grid Layoutで作れば実現が容易かもと感じました。
グリッドテンプレート領域 - CSS: カスケーディングスタイルシート | MDN

 
ということで、CSS Grid Layoutの概念がまとまっている本を探したところ、「作って学ぶ HTML + CSS グリッドレイアウト」がありました。
作って学ぶ HTML + CSS グリッドレイアウト | エビスコム - EBISUCOM

この本の著者の記事を読んだところ、本にはCSS Grid Layoutが生まれた経緯から実際の使い方まで記述されているとわかりました。ほしかった本はこれでした。
CSS Grid を中心に据えたレイアウトの制御 ― Webの構築・実装におけるレイアウト手法を再確認&再検討してみた話 | エビスコム - EBISUCOM

実際に本を読んでみたところ、やはりCSS Grid Layoutを使うことで系譜図が実現できそうでした。

 
そこで、今回のアプリでは CSS Grid Layoutをメインに据え、

  • 系譜図は、CSS Grid Layoutの テンプレート で実装
  • メニューなどの配置は、CSS Grid Layoutの トラック で実装

としました。

 

CSS Grid Layoutまわり

ReactでCSS Grid Layoutのgrid-template-areasを指定する方法

Reactで CSS Grid Layoutのテンプレートを定義する方法を調べたところ、バッククォートを使って定義すれば良さそうでした。
css - How to use grid-template-areas in react inline style? - Stack Overflow

今回の場合、 divstyle propsに

<div
  style={{
    display: 'grid',
    gridTemplateAreas: `
    "self p pp ppp"
    "self p pp pps"
    "self p ps psp"
    "self p ps pss"
    "self s sp spp"
    "self s sp sps"
    "self s ss ssp"
    "self s ss sss"
    `
  }}
>

のような感じで指定し、使う側では gridArea キーにそれぞれの値 (selfp など) を指定すれば実現できました。

 

ちなみに、CSS Grid Layoutの トラック をReactで実装する場合には、キーに対する値をそのまま設定すれば実現できました。

<header
  style={{
    display: 'grid',
    gridTemplateColumns: 'auto 1fr',
  }}
>

 

Cloudflare D1 (SQLite) まわり

INSERT OR REPLACE による upsert をする

前回の記事では、曽祖父母までの祖先を取得しました。
親子情報を持つテーブルとSQLの共通テーブル式(CTE)を使って、曽祖父母までの祖先を取得する - メモ的な思考的な

今回も、上記記事と同様の方法でデータを取得してりんごの系譜図を作成することから、

  • りんご情報を持つテーブル (apples)
  • りんごの親子情報を持つテーブル (genealogies)

の2テーブルを用意することにします。

 
ただ、前回の記事では SQLite + Drizzle ORM を使った環境でしたが、今回は Cloudflare D1 + Drizzle ORM な環境となります。

そのため、初期データをCloudflare D1へ投入する場合は、SQLファイルを書いてWranglerから実行する必要があります。
Build a Staff Directory Application | Cloudflare D1 docs

今後テーブル構造が変わる可能性があることから、できれば初期データはあとから更新しやすいよう upsert 的なSQLで用意したくなりました。

 
D1(SQLite)で upsert 的なことを行うには

の2つがありました。

今回は

  • 初期データはいわゆるマスタ系であり、投入後の更新は行わない
  • INSERT ... ON CONFLICT の場合、更新対象の列を指定する必要があるため、列が増えたときにSQLのメンテナンスが手間そう

と考え、 INSERT OR REPLACE を採用することにしました。

 
実際のSQLはこんな感じになりました。

INSERT OR REPLACE INTO apples (name, display_name) VALUES ('shinano_gold', 'シナノゴールド');
INSERT OR REPLACE INTO genealogies (child_name, pollen_name, seed_name) VALUES ('shinano_gold', 'senshu', 'golden_delicious');

 

Honoまわり

Honoで Path Parameter を指定する

Hono製のAPI

  • /api/genealogies/shinano_gold
  • /api/genealogies/fuji

のように、 /api/genealogies/<動的な値> を指定したい場合、公式ドキュメントによると :apple_name のような記法を使えば良さそうでした。
Path Parameter | Routing - Hono

 
また、Path parameterの値を取り出すには、 c.req.param('apple_name') とすれば良さそうです。

 

TanStack Routerまわり

File-Based Routing時のディレクトリ構成について

今回の機能では、TanStack Router を使ってルーティングを行います。

TanStack Routerではルーティング方法を選べますが、今回はファイル置けばいい感じにルーティングしてくれる File-Based Routing を採用します。
File-Based Routing | TanStack Router React Docs

また、File-Based Routingでは、ファイルの置き方についてもいくつかパターンがありますが、今回はディレクトリによりファイルが整理される Directory Routes を採用します。

 
Directory Routesの場合、React向けのComponentやHookはどこに置くか悩みました。

そこで今回は以下の記事のように、 - をprefixとして付けた

  • -components
  • -api

のようなディレクトリを作り、その中にComponentやHookを置くことにしました。
TanStack Routerのディレクトリ設計 | TanStack Router(& Query)はSPA開発で求めていたものだった✨【Reactのルーティングとデータ取得】

 

automatic code-splittingを有効にする

TanStack Routerでは、

Code splitting and lazy loading is a powerful technique for improving the bundle size and load performance of an application.

という、 Code Splitting 機能があります。

 
Code Splitting を使えるようにする方法として、公式ドキュメントには

  • Using the .lazy.tsx suffix
  • Using Virtual Routes
  • Using automatic code-splitting

の3つの方法が記載されています。

 
今回のアプリはメンテナンスの手間を減らしたいことから、 automatic code-splitting を採用します。

なお、 automatic code-splitting の制約として

はあるものの、今回のアプリでは問題なさそうです。

 
 
そこで、

を行いました。

 

TanStack RouterとTanStack Queryを併用する方法について

TanStack Query の useSuspenseQuery を使ってデータを取得する

今回の機能ではデータ更新系が存在しません。

そんな状況で TanStack Router と TanStack Query を併用する方法を調べたところ、公式ドキュメントにサンプルがありました。
React TanStack Router Basic React Query Example | TanStack Router Docs

そこでは、 TanStack Query の useSuspenseQuery を使ってデータを取得していました。
useSuspenseQuery | TanStack Query React Docs

また、 useSuspenseQuery の引数 options には、最低限

  • queryKey
  • queryFn

の2つを持つオブジェクトを指定してあげれば良いようでした。

 
ちなみに、 useSuspenseQuery を使う上ではリクエスウォーターフォールに注意したほうが良いようです。

 

では、今回のアプリでの実装を見ていきます。

Hono製APIで Path Parameter を使っている場合、以下のような options を用意します。

const client = hc<GenealogyRouteResponseType>('')

const queryFn = async (appleName: string) => {
  const response = await client.api.genealogies[':apple_name'].$get({
    param: {
      apple_name: appleName,
    },
  })

  if (response.ok) {
    return await response.json()
  }
}

export const genealogyQueryOptions = (appleName: string) =>
  queryOptions({
    queryKey: ['fetchGenealogy', { appleName }],
    queryFn: () => queryFn(appleName),
  })

 
そして、コンポーネントの冒頭で、TanStack Router の Route.useParams() を使って Path Parameter の appleName を options に渡すことで、Hono製APIを呼ぶことができます。
useParams hook | TanStack Router React Docs

const GenealogyChartComponent = () => {
  const { appleName } = Route.useParams()
  const query = useSuspenseQuery(genealogyQueryOptions(appleName))
  // 略
}

 

TanStack Query を TanStack Router の loader に指定する方法について

TanStack Router では loader を使うことで、ページのロードに合わせてデータ取得を行うことができます。
Data Loading | TanStack Router React Docs

今回の場合、データ取得については TanStack Query を使っていることから、両者を組み合わせて使うことができるか調べてみました。

すると、TanStack Router の公式ドキュメントに記載やサンプルがありました。

 

今回のアプリでの実装を見ていきます。

まずは、 crateRoutercontext に TanStack Query の queryClient を渡します。

const queryClient = new QueryClient()
const router = createRouter({
  routeTree: routeTree,
  context: {
    queryClient,
  },
})

declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}

createRoot(document.getElementById('root')!).render(
  <QueryClientProvider client={queryClient}>
    <React.StrictMode>
      <RouterProvider router={router} />
    </React.StrictMode>
  </QueryClientProvider>,
)

 
続いて、 __root.tsxcreateRootRouteWithContext を使い、 queryClient の型として QueryClient を渡します。

import type { QueryClient } from '@tanstack/react-query'
// 略

export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
  {
    component: () => (
      <>
        <Outlet />
      </>
    ),
  },
)

 
最後に、 createFileRouteloadercontext を取得し、 TanStack Query の機能を使ってデータを取得します。

export const Route = createFileRoute('/genealogies/$appleName')({
  component: Component,
  loader: async ({ context: { queryClient }, params: { appleName } }) =>
    await queryClient.ensureQueryData(genealogyQueryOptions(appleName)),
})

 

今回のアプリを作っているときに、「MUIのコンポーネントに対して、TanStack Router のリンクを付与したい」ことがありました。

調べてみたところ、 TanStack Router の 1.28.1 から createLink 関数を使うことで、カスタムリンクを設定することができるようになったようです。
tanstack router - How to wrap a Link component with type safety? - Stack Overflow

また、公式ドキュメントには createLink + MUIコンポーネントを使ったサンプルコードもありました。
MUI example | Custom Link | TanStack Router React Docs

 

今回のアプリでの実装を見ていきます。

なお、今回は MUI の List コンポーネント関連を使って実装しています。
React List component - Material UI

最初に、createLink を使って、MUIのコンポーネント ListItem + ListItemButton をラップしたコンポーネントを用意します。

type MuiLinkProps = Omit<ButtonProps, 'href'>

const MuiLinkComponent = React.forwardRef<HTMLAnchorElement, MuiLinkProps>(
  (props, ref) => {
    return (
      <ListItem>
        <ListItemButton component={'a'} ref={ref} {...props} />
      </ListItem>
    )
  },
)

const CreatedLinkComponent = createLink(MuiLinkComponent)

export const RingoMenuItem: LinkComponent<typeof MuiLinkComponent> = (
  props,
) => {
  return <CreatedLinkComponent preload={'intent'} {...props} />
}

 
あとは、上記のコンポーネント RingoMenuItem を使い、 to props に遷移先を指定すればOKでした。

<List>
  <RingoMenuItem to={'/'}>合計数量</RingoMenuItem>
  <RingoMenuItem to={'/month'}>月別数量</RingoMenuItem>
  <RingoMenuItem to={'/genealogies'}>系譜図</RingoMenuItem>
</List>

 

MUIまわり

ハンバーガーアイコンをクリックしたらメニューを表示する

MUIの IconButtonDrawer を使って実現できました。

 
今回のアプリでの実装を見ていきます。

まずは Drawer でメニューを作ります。

type Props = {
  open: boolean
  handleClose: () => void
}

export const RingoMenu = ({ open, handleClose }: Props) => {
  return (
    <Drawer open={open} onClose={handleClose}>
      <Box>
        <List>
          <RingoMenuItem to={'/'}>合計数量</RingoMenuItem>
          <RingoMenuItem to={'/month'}>月別数量</RingoMenuItem>
          <RingoMenuItem to={'/genealogies'}>系譜図</RingoMenuItem>
        </List>
      </Box>
    </Drawer>
  )
}

 
あとは IconButton で state 管理して表示/非表示を切り替えます。

export const TitleWithMenu = ({ title }: Props) => {
  const [open, setOpen] = useState(false)
  const handleClose = () => setOpen(false)

  return (
    <>
      <IconButton onClick={() => setOpen(true)}>
        <MenuIcon />
      </IconButton>
      <RingoMenu open={open} handleClose={handleClose} />
    </>
  )
}

 

実際のデプロイ作業

ここではデプロイ作業の記録を残しておきます。

 

ringo-db ディレクトリでの作業

Drizzle-Kit によるマイグレーションファイル生成

Drizzle-Kit にてマイグレーションファイルを生成しておくことで、 Wrangler で適用できます。

$ bun drizzle-kit generate

 

ローカルのD1にマイグレーションを適用

Wrangler--local フラグを利用して、ローカルのD1にマイグレーションを適用します。

$ wrangler d1 migrations apply ringodb --local

 

ローカルのD1に初期データを投入

こちらも --local フラグを利用してデータを投入します。

$ wrangler d1 execute ringodb --local --file=seed/apples_and_genealogies.sql --batch-size=1

 

本番のD1にマイグレーションを適用

続いて、 --remote フラグを使って、本番のD1にマイグレーションを適用します。

途中で適用の確認がされるので、 y を入力します。

$ wrangler d1 migrations apply ringodb --remote

Migrations to be applied:
┌──────────────────────────────┐
│ name                         │
├──────────────────────────────┤
│ 0001_youthful_beyonder.sql   │
├──────────────────────────────┤
│ 0002_fearless_gunslinger.sql │
└──────────────────────────────┘
✔ About to apply 2 migration(s)
Your database may not be available to serve requests during the migration, continue? … yes
🌀 Executing on remote database ringodb (***):
🌀 To execute on your local development database, remove the --remote flag from your wrangler command.
🚣 Executed 3 commands in 0.9372999999999999ms
┌──────────────────────────────┬────────┐
│ name                         │ status │
├──────────────────────────────┼────────┤
│ 0001_youthful_beyonder.sql   │ ✅       │
├──────────────────────────────┼────────┤
│ 0002_fearless_gunslinger.sql │ 🕒️     │
└──────────────────────────────┴────────┘
🌀 Executing on remote database ringodb (***):
🌀 To execute on your local development database, remove the --remote flag from your wrangler command.
🚣 Executed 2 commands in 0.5869ms
┌──────────────────────────────┬────────┐
│ name                         │ status │
├──────────────────────────────┼────────┤
│ 0001_youthful_beyonder.sql   │ ✅       │
├──────────────────────────────┼────────┤
│ 0002_fearless_gunslinger.sql │ ✅       │
└──────────────────────────────┴────────┘

 

本番のD1に初期データを投入

引き続き、本番のD1に初期データを投入しようとしたところ、エラーになりました。

$ wrangler d1 execute ringodb --remote --file=seed/apples_and_genealogies.sql --batch-size=1

✔ ⚠️ This process may take some time, during which your D1 database will be unavailable to serve queries.
  Ok to proceed? … yes
🌀 Executing on remote database ringodb (***):
🌀 To execute on your local development database, remove the --remote flag from your wrangler command.
Note: if the execution fails to complete, your DB will return to its original state and you can safely retry.
🌀 File already uploaded. Processing.

✘ [ERROR] Cannot read properties of undefined (reading 'forEach')

If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose
Note that there is a newer version of Wrangler available (3.83.0). Consider checking whether upgrading resolves this error.
✔ Would you like to report this error to Cloudflare? … no
🪵  Logs were written to "***.log"

 
原因が分からないものの、エラーメッセージにある通り wrangler のバージョンがやや古いのは事実でした。

そこで、Bunにより wrangler のバージョンを上げてみます。
https://bun.sh/docs/cli/update

$ bun update wrangler
bun update v1.1.13 (bd6a6051)

installed wrangler@3.83.0 with binaries:
 - wrangler
 - wrangler2

11 packages installed [5.71s]

 
再度、本番のD1にデータを投入したところ、処理が成功しました。

$ wrangler d1 execute ringodb --remote --file=seed/apples_and_genealogies.sql --batch-size=1

 ⛅️ wrangler 3.83.0
-------------------

✔ ⚠️ This process may take some time, during which your D1 database will be unavailable to serve queries.
  Ok to proceed? … yes
🌀 Executing on remote database ringodb (***):
🌀 To execute on your local development database, remove the --remote flag from your wrangler command.
Note: if the execution fails to complete, your DB will return to its original state and you can safely retry.
├ 🌀 Uploading ***.sql 
│ 🌀 Uploading complete.
│ 
🌀 Starting import...
🌀 Processed 34 queries.
🚣 Executed 34 queries in 0.00 seconds (0 rows read, 85 rows written)
   Database is currently at bookmark ***.
┌────────────────────────┬───────────┬──────────────┬────────────────────┐
│ Total queries executed │ Rows read │ Rows written │ Database size (MB) │
├────────────────────────┼───────────┼──────────────┼────────────────────┤
│ 34                     │ 0         │ 85           │ 0.22               │
└────────────────────────┴───────────┴──────────────┴────────────────────┘

 

ringo-db Worker のデプロイ

この記事ではふれていませんが、D1にテーブルを追加したのに合わせて、データを取得する処理を ringo-db Workerへ追加していました。

そこで、Workerアプリをデプロイします。

$ bun run deploy
$ wrangler deploy --minify

 ⛅️ wrangler 3.83.0
-------------------

Total Upload: 75.08 KiB / gzip: 21.05 KiB
Worker Startup Time: 4 ms
Your worker has access to the following bindings:
- D1 Databases:
  - DB: ringodb (***)
Uploaded ringo-db (2.87 sec)
Deployed ringo-db triggers (0.27 sec)
  https://ringo-db.dev-thinkami.workers.dev
Current Version ID: ***

 

ringo-web ディレクトリでの作業

ringo-web ディレクトリは Cloudflare Pages としてデプロイされています。

Cloudflare Workersと異なり、Cloudflare Pagesでは、mainブランチ以外での wrangler deploy は Preview としてデプロイされます。

 
そこで、まずは Preview で動作確認するためのデプロイを行います。

 

最初にアプリを作ったときと同様ビルド時に画面がハングするため、そのことを織り込んで作業します。

まずはフロントエンドからビルドします。 built in と表示されたらキャンセルします。

$ bun run build:fe

...
✓ built in 4.74s

 

続いて、バックエンドもビルドします。 built in と表示されたらキャンセルします。

$ bun run build:be
$ vite build
vite v5.3.1 building SSR bundle for production...
✓ 21 modules transformed.
dist/_worker.js  23.21 kB
✓ built in 205ms

 

最後にデプロイします。

$ bun run deploy
$ wrangler pages deploy dist
▲ [WARNING] Warning: Your working directory is a git repo and has uncommitted changes

  To silence this warning, pass in --commit-dirty=true


✨ Success! Uploaded 1 files (1 already uploaded) (2.56 sec)

✨ Compiled Worker successfully
✨ Uploading Worker bundle
✨ Uploading _routes.json
🌎 Deploying...
✨ Deployment complete! Take a peek over at ***
✨ Deployment alias URL: ***

 
動作確認したところ良さそうだったので、GitHub上でプルリク作成 & mainブランチへとマージします。

その後、 mainブランチに切り替え、再度、デプロイ作業として

  • bun run build:fe
  • bun run build:be
  • bun run deploy

を行います。

あらためて動作確認したところ、問題なく動いていました。

 

ソースコード

GitHubに上げました。
https://github.com/thinkAmi/cf_ringo_sky

今回のプルリクはこちら