SolidJSの世界: UI構築とトランジション #3


## はじめに

はじめまして、WEB フロントエンドエンジニアの nuintee です。

この度かねてより関心があった SolidJS に入門する覚悟が出来たので、
その学習過程をアドベントカレンダーにしてみました。

SolidJS に少しでも興味のある方は、ぜひご覧ください。

## フロントエンドの世界 2024 について

「[フロントエンドの世界 2024](https://qiita.com/advent-calendar/2024/frontend-assort-2024)」は普段 [`Next.js`](https://nextjs.org/) を書いている筆者が、同じフロントエンドライブラリである [`Svelte(Kit)`](https://svelte.jp/docs/introduction), [`Remix`](https://remix.run/docs/en/main) ,[`SolidJS`](https://docs.solidjs.com/), [`Qwik(City)`](https://qwik.dev/)の 4 つにアソート形式で触れ、理解を深めていく様子を収めたアドベントカレンダーです。

![frontend-assort-2024-banner.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3294810/7666d209-1aef-9ce5-c298-d8e60847e830.png)

## もくじ

- [はじめに](#はじめに)
- [フロントエンドの世界 2024 について](#フロントエンドの世界-2024-について)
- [もくじ](#もくじ)
- [今回作るモノ](#今回作るモノ)
- [ディレクトリ構成](#ディレクトリ構成)
- [機能の型定義](#機能の型定義)
- [モックページの実装](#モックページの実装)
- [共通コンポーネントの追加](#共通コンポーネントの追加)
- [その他のコンポーネント](#その他のコンポーネント)
- [共通コンポーネント呼び出し](#共通コンポーネント呼び出し)
- [最終的なコード](#最終的なコード)
- [おわりに](#おわりに)

## 今回作るモノ

https://c78115e9.frontend-assort-2024-solidjs.pages.dev

![スクリーンショット 2024-12-16 0.16.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3294810/f147553c-f2ef-6324-e970-4927bb2d17d1.png)

今回は [PokeAPI](https://pokeapi.co/) を用いたポケモン当てクイズアプリを`Svelte` で開発します。

機能的には以下の通りです。

- ランダム出題機能
- 回答判定機能
- ライフ管理
- 制限時間管理
- リザルト機能

## ディレクトリ構成

```txt:tree実行結果
.
├── README.md
├── index.html
├── package.json
├── pnpm-lock.yaml
├── postcss.config.js
├── src
│   ├── App.tsx
│   ├── features
│   │   ├── form
│   │   │   ├── hooks
│   │   │   │   └── index.ts
│   │   │   └── types
│   │   │       └── index.ts
│   │   ├── pokemon
│   │   │   ├── data
│   │   │   │   └── index.ts
│   │   │   ├── types
│   │   │   │   └── index.ts
│   │   │   └── ui
│   │   │       └── Card
│   │   │           └── index.tsx
│   │   ├── time
│   │   │   └── hooks
│   │   │       └── index.ts
│   │   ├── ui
│   │   │   ├── CharButton
│   │   │   │   ├── index.tsx
│   │   │   │   └── type.ts
│   │   │   ├── Debugger
│   │   │   │   └── index.tsx
│   │   │   ├── LifeCounter
│   │   │   │   └── index.tsx
│   │   │   ├── Loader
│   │   │   │   └── index.tsx
│   │   │   ├── PrimaryButton
│   │   │   │   ├── index.tsx
│   │   │   │   └── type.ts
│   │   │   └── ProgressBar
│   │   │       ├── index.tsx
│   │   │       └── type.ts
│   │   └── utils
│   │       └── kana
│   │           └── index.ts
│   ├── index.css
│   ├── index.tsx
│   └── views
│       ├── GamePlayView
│       │   ├── index.tsx
│       │   └── type.ts
│       └── GameResultView
│           ├── index.tsx
│           └── type.ts
├── tailwind.config.ts
├── tsconfig.json
└── vite.config.ts
```

## 機能の型定義

### PokeAPI のレスポンス

features 配下にポケモン API の取得レスポンス型を定義します。

(参考: [PokeAPI](https://pokeapi.co/))

```ts:features/pokemon/types/index.ts
export type SpecieResponse = {
  names: {
    language: {
      name: string;
    };
    name: string;
  }[];
};

export type PokemonResponse = {
  id: number;
  name: string;
  sprites: {
    other: {
      home: {
        front_default: string;
      };
    };
  };
};

export type PokemonType = SpecieResponse & PokemonResponse;
```

### 回答入力フォーム

回答欄と選択肢で共通して使う型を定義します。

```ts:features/form/types/index.ts
export type FormType = {
  char: string;
  index: number;
};

export type FormValues = {
  options: FormType[];
  displayTexts: FormType[];
};
```

## モックページの実装

まずは固定のデータで UI を実装します。

### リザルト画面

React で慣れ親しんだ JSX 構文でリザルト画面を実装します。

```ts:views/GameResultView/type.tsx
// propsの型定義
export type GameResultViewProps = {
  score: number;
  onRetry: () => void;
};
```

```tsx:views/GameResultView/index.tsx
import { GameResultViewProps } from "./type";

export const GameResultView = ({ score, onRetry }: GameResultViewProps) => {
  return (
    <section class="h-full w-full flex items-center justify-center">
      <div class="flex flex-col gap-y-4 border p-4 rounded-[10px] border-solid border-[gainsboro]">
        <h2 class="text-center text-slate-500">スコア</h2>
        <p class="text-center text-2xl">
          {score}
          <span class="text-sm">点</span>
        </p>
        {/* PrimaryButton */}
        <button
          onClick={onRetry}
          class="bg-[greenyellow] font-medium max-w-full w-[350px] px-8 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
        >
          もう一度プレイする
        </button>
      </div>
    </section>
  );
};
```

### プレイ画面

同じ要領でプレイ画面も実装します。

```tsx:views/GamePlayView/type.ts
// propsの型定義
import { FormType } from "../../features/form/types";
import { PokemonType } from "../../features/pokemon/types";

export type GamePlayViewProps = {
  displayTexts: FormType[];
  options: FormType[];
  lifeCounts: number;
  pokemon: PokemonType;
  onAddInput: (input: FormType) => void;
  onRemoveInput: (input: FormType) => void;
};
```

```tsx:views/GamePlayView/index.tsx
import { For, Show } from "solid-js";
import { GamePlayViewProps } from "./type";

export const GamePlayView = (props: GamePlayViewProps) => {
  const displayTexts = () => props.displayTexts;
  const options = () => props.options;
  const lifeCounts = () => props.lifeCounts;
  const pokemon = () => props.pokemon;

  const life = Array.from({ length: lifeCounts() }).map((_, i) => i);

  return (
    <section class="flex flex-col items-center justify-center">
      {/* ProgressBar */}
      <div class="w-full h-3 bg-[#cbff7e]">
        <div
          class={`h-full rounded-r-md bg-[yellowgreen] ease-linear`}
          style={{ width: `${70}%` }}
        ></div>
      </div>

      {/* LifeCounter */}
      <div class="flex items-center gap-x-2 px-4 m-4 w-full">
        <For each={life}>{(_) => <div class="text-xl">❤️</div>}</For>
      </div>

      <h2 class="flex items-center justify-center text-2xl mt-8">
        このポケモンは誰？
      </h2>
      {/* Card */}
      <img
        src={pokemon()?.sprites.other.home.front_default ?? ""}
        alt="ポケモン"
        draggable={false}
        class="w-[350px] max-w-full h-fit mx-auto"
      />
      <div class="flex items-center gap-x-4 h-[70px]">
        <Show
          when={!!displayTexts().length}
          fallback={
            <span class="flex items-center justify-center text-gray-400">
              👇 クリックで名前を入力して下さい
            </span>
          }
        >
          <span>名前は</span>
          <For each={displayTexts()}>
            {(displayText) => (
              <button
                class="bg-[ghostwhite] px-6 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
                onClick={() => props.onRemoveInput(displayText)}
              >
                {displayText.char}
              </button>
            )}
          </For>
          <span>です</span>
        </Show>
      </div>
      <hr class="w-full" />
      <div class="flex items-center gap-x-4 h-[70px] m-2">
        <For each={options()}>
          {(option) => (
            <button
              class="bg-[ghostwhite] px-6 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
              onClick={() => props.onAddInput(option)}
            >
              {option.char}
            </button>
          )}
        </For>
      </div>
      {/* PrimaryButton */}
      <button
        onClick={() => {}}
        class="bg-[greenyellow] font-medium max-w-full w-[350px] px-8 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
      >
        回答する
      </button>
    </section>
  );
};
```

### ルート画面

条件によって画面を変える制御処理を追加します。

**Match コンポーネント**
when 条件が `true` の場合にのみ要素を描画してくれるコンポーネントです。

**Switch コンポーネント**
複数の `Match` コンポーネントを `Switch` コンポーネントで囲む事で、最初に 条件 が`true`になったものだけ描画されるようになります。

(参考: [\<Switch> / \<Match>](https://docs.solidjs.com/reference/components/switch-and-match))

**ErrorBoundary**
クライアントでエラーが throw された際にフォールバック画面を表示してくれるコンポーネントです。

(参考: [ErrorBoundary](https://docs.solidjs.com/reference/components/error-boundary))

```ts:App.tsx
import { ErrorBoundary, Match, Switch, type Component } from "solid-js";
import { GameResultView } from "./views/GameResultView";
import { GamePlayView } from "./views/GamePlayView";
import { Loader } from "./features/ui/Loader";
import { PokemonType } from "./features/pokemon/types";

const App: Component = () => {
  const displayTexts = [
    { char: "フ", index: 1 },
    { char: "シ", index: 2 },
    { char: "ギ", index: 3 },
    { char: "バ", index: 4 },
    { char: "ナ", index: 4 },
  ];

  // NOTE: ダミー入りの選択肢
  const options = [
    { char: "マ", index: 2 },
    { char: "バ", index: 3 },
    { char: "ジ", index: 5 },
    { char: "バ", index: 3 },
    { char: "フ", index: 1 },
    { char: "リ", index: 6 },
    { char: "ナ", index: 4 },
    { char: "ン", index: 7 },
  ];

  const pokemon: PokemonType = {
    sprites: {
      other: {
        home: {
          front_default:
            "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/home/3.png",
        },
      },
    },
    id: 1,
    name: "xxx",
    names: [{ language: { name: "xxx" }, name: "yyy" }],
  };

  // NOTE: API呼び出し時に置き換え
  const isLoading = false;

  const gameState = "START" as "START" | "END";
  const lifeCounts = 3;

  return (
    // エラー発生時はフォールバックを表示フォールバックを表示
    <ErrorBoundary fallback={<p>Error</p>}>
      <Switch>
        // NOTE: ロード中はLoaderを表示
        <Match when={isLoading}>
          <div class="h-[10rem] w-[10rem] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
            <Loader />
          </div>
        </Match>
        // NOTE: ゲーム終了状態の場合はリザルト画面を表示
        <Match when={gameState === "END"}>
          <GameResultView score={3} onRetry={() => {}} />
        </Match>
        // NOTE: ゲームが開始状態の場合はプレイ画面を表示
        <Match when={gameState === "START"}>
          <GamePlayView
            pokemon={pokemon}
            lifeCounts={lifeCounts}
            displayTexts={displayTexts}
            options={options}
            onAddInput={() => {}}
            onRemoveInput={() => {}}
          />
        </Match>
      </Switch>
    </ErrorBoundary>
  );
};

export default App;
```

## 共通コンポーネントの追加

`ui/`配下にアプリケーション全体で使用する共通 UI パーツを追加していきます。
(参考: [React - コンポーネントの定義](https://ja.react.dev/learn/your-first-component#defining-a-component))

### CharButton

回答の入力・表示を行うボタンをコンポーネント化します。

```ts:ui/CharButton/type.ts
// 型定義
import { ReactNode } from "react";

export type CharButtonProps = {
  onClick: () => void;
  children: ReactNode;
};
```

```tsx:ui/CharButton/index.tsx
// コンポーネント実態
import { CharButtonProps } from "./type";

export const CharButton = ({ onClick, children }: CharButtonProps) => {
  return (
    <button
      class="bg-[ghostwhite] px-6 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
      onClick={onClick}
    >
      {children}
    </button>
  );
};
```

### PrimaryButton

回答やリトライを担うプライマリーボタンをコンポーネント化します。

```tsx:ui/PrimaryButton/type.tsx
import { ReactNode } from "react";

export type PrimaryButtonProps = {
  onClick: () => void;
  children: ReactNode;
};
```

```tsx:ui/PrimaryButton/index.tsx
import { PrimaryButtonProps } from "./type";

export const PrimaryButton = ({ onClick, children }: PrimaryButtonProps) => {
  return (
    <button
      onClick={onClick}
      class="bg-[greenyellow] font-medium max-w-full w-[350px] px-8 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
    >
      {children}
    </button>
  );
};
```

### LifeCounter

残りのライフ表示をコンポーネント化します。

```tsx:ui/LifeCounter/index.tsx
export const LifeCounter = (props: { lifeCount: number }) => {
  const lifeCount = () => props.lifeCount;

  return (
    <div class="flex items-center gap-x-2 px-4 m-4 w-full">
      {Array.from({ length: lifeCount() }).map((_) => (
        <div class="text-xl">❤️</div>
      ))}
    </div>
  );
};
```

### Loader

ローディング時に表示するアニメーション SVG のコンポーネント化します。

今回は[SVG Backgrounds](https://www.svgbackgrounds.com/elements/animated-svg-preloaders/)というサイトで生成した`Ripples`ローダーの色だけ調整してそのまま使用します。

```tsx:ui/Loader/index.tsx
export const Loader = () => {
  return (
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
      <circle
        fill="none"
        stroke-opacity="1"
        stroke="#9E9E9E"
        stroke-width=".5"
        cx="100"
        cy="100"
        r="0"
      >
        <animate
          attributeName="r"
          calcMode="spline"
          dur="2"
          values="1;80"
          keyTimes="0;1"
          keySplines="0 .2 .5 1"
          repeatCount="indefinite"
        ></animate>
        <animate
          attributeName="stroke-width"
          calcMode="spline"
          dur="2"
          values="0;25"
          keyTimes="0;1"
          keySplines="0 .2 .5 1"
          repeatCount="indefinite"
        ></animate>
        <animate
          attributeName="stroke-opacity"
          calcMode="spline"
          dur="2"
          values="1;0"
          keyTimes="0;1"
          keySplines="0 .2 .5 1"
          repeatCount="indefinite"
        ></animate>
      </circle>
    </svg>
  );
};
```

### ProgressBar

ゲームの制限時間表示をコンポーネント化します。

`classList` を用いると条件に一致したクラス定義を動的に行えます。

(参考: [classList](https://docs.solidjs.com/reference/jsx-attributes/classlist))

```ts:ui/ProgressBar/type.ts
export type ProgressBarProps = {
  value: number;
  maxValue: number;
};
```

```tsx:ui/ProgressBar/index.ts
import { ProgressBarProps } from "./type";

export const ProgressBar = ({ value, maxValue }: ProgressBarProps) => {
  const progress = (value / maxValue) * 100;

  const isBellowMax = value < maxValue;
  // NOTE: 1000ms分のdelayがあるので-1を終了時間にする
  const isAboveMin = value > -1;
  const isBetweenRange = isAboveMin && isBellowMax;

  return (
    <div class="w-full h-3 bg-[#cbff7e]">
      <div
        classList={{
          ["h-full rounded-r-md bg-[yellowgreen] ease-linear"]: true,
          ["duration-1000"]: isBetweenRange,
        }}
        style={{ width: `${progress}%` }}
      ></div>
    </div>
  );
};
```

### Debugger

開発時の値確認用コンポーネントを追加します。

```tsx:ui/Debugger/index.tsx
export const Debugger = ({
  data,
}: {
  data: Record<string, string | number | undefined>;
}) => {
  if (process.env.NODE_ENV !== "development") return null;

  return (
    <div class="absolute top-0 right-0 bg-black bg-opacity-50 text-white">
      {Object.keys(data).map((key) => (
        <dl class="flex items-center gap-x-2">
          <dt>{key} :</dt>
          <dd>{data[key]}</dd>
        </dl>
      ))}
    </div>
  );
};
```

## その他のコンポーネント

### Card

取得したポケモンの画像表示用 `img` タグをコンポーネント化します。
`alt`が主にポケモンに依存しそうなので`pokemon`という`feature`配下に追加します。

:::note warn
今回のテーマでは無いので雑な設計はご容赦いただけると幸いです 🙇‍♂️
:::

```tsx:features/pokemon/ui/Card/index.tsx
export const Card = ({ src }: { src: string }) => {
  return (
    <img
      src={src}
      alt="ポケモン"
      draggable={false}
      class="w-[350px] max-w-full h-fit mx-auto"
    />
  );
};
```

## 共通コンポーネント呼び出し

### リザルト画面

PrimaryButton をインポートします。

```diff_tsx:views/GameResultView/index.tsx
+ import { PrimaryButton } from "../../features/ui/PrimaryButton";
import { GameResultViewProps } from "./type";

export const GameResultView = ({ score, onRetry }: GameResultViewProps) => {
  return (
    <section class="h-full w-full flex items-center justify-center">
      <div class="flex flex-col gap-y-4 border p-4 rounded-[10px] border-solid border-[gainsboro]">
        <h2 class="text-center text-slate-500">スコア</h2>
        <p class="text-center text-2xl">
          {score}
          <span class="text-sm">点</span>
        </p>
-       {/* PrimaryButton */}
-       <button
-         onClick={onRetry}
-         class="bg-[greenyellow] font-medium max-w-full w-[350px] px-8 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
-       >
-         もう一度プレイする
-       </button>
+       <PrimaryButton onClick={onRetry}>もう一度プレイする</PrimaryButton>
      </div>
    </section>
  );
};
```

### プレイ画面

上で作成した共通コンポーネントを `GamePlayView` でインポートします。

```diff_tsx:views/GamePlayView/index.tsx
import { For, Show } from "solid-js";
import { GamePlayViewProps } from "./type";
+ import { ProgressBar } from "../../features/ui/ProgressBar";
+ import { LifeCounter } from "../../features/ui/LifeCounter";
+ import { Card } from "../../features/pokemon/ui/Card";
+ import { CharButton } from "../../features/ui/CharButton";
+ import { PrimaryButton } from "../../features/ui/PrimaryButton";

export const GamePlayView = (props: GamePlayViewProps) => {
  const displayTexts = () => props.displayTexts;
  const options = () => props.options;
  const lifeCounts = () => props.lifeCounts;
  const pokemon = () => props.pokemon;

  const life = Array.from({ length: lifeCounts() }).map((_, i) => i);

  return (
    <section class="flex flex-col items-center justify-center">
-     {/* ProgressBar */}
-     <div class="w-full h-3 bg-[#cbff7e]">
-       <div
-         class={`h-full rounded-r-md bg-[yellowgreen] ease-linear`}
-         style={{ width: `${70}%` }}
-       ></div>
-     </div>
+     <ProgressBar value={70} maxValue={100} />

-     {/* LifeCounter */}
-     <div class="flex items-center gap-x-2 px-4 m-4 w-full">
-       <For each={life}>{(_) => <div class="text-xl">❤️</div>}</For>
-     </div>
+     <LifeCounter lifeCount={lifeCounts()}/>

      <h2 class="flex items-center justify-center text-2xl mt-8">
        このポケモンは誰？
      </h2>
-     {/* Card */}
-     <img
-       src={pokemon()?.sprites.other.home.front_default ?? ""}
-       alt="ポケモン"
-       draggable={false}
-       class="w-[350px] max-w-full h-fit mx-auto"
-     />
+     <Card src={pokemon()?.sprites.other.home.front_default ?? ""} />
      <div class="flex items-center gap-x-4 h-[70px]">
        <Show
          when={!!displayTexts().length}
          fallback={
            <span class="flex items-center justify-center text-gray-400">
              👇 クリックで名前を入力して下さい
            </span>
          }
        >
          <span>名前は</span>
          <For each={displayTexts()}>
            {(displayText) => (
-             <button
-               class="bg-[ghostwhite] px-6 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
-               onClick={() => props.onRemoveInput(displayText)}
-             >
-               {displayText.char}
-             </button>
+             <CharButton onClick={() => props.onRemoveInput(displayText)}>
+               {displayText.char}
+             </CharButton>
            )}
          </For>
          <span>です</span>
        </Show>
      </div>
      <hr class="w-full" />
      <div class="flex items-center gap-x-4 h-[70px] m-2">
        <For each={options()}>
          {(option) => (
-           <button
-             class="bg-[ghostwhite] px-6 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
-             onClick={() => props.onAddInput(option)}
-           >
-             {option.char}
-           </button>
+           <CharButton onClick={() => props.onRemoveInput(displayText)}>
+             {displayText.char}
+           </CharButton>
          )}
        </For>
      </div>
-      {/* PrimaryButton */}
-     <button
-       onClick={() => {}}
-       class="bg-[greenyellow] font-medium max-w-full w-[350px] px-8 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
-     >
-       回答する
-     </button>
+     <PrimaryButton onClick={() => {}}>
+       回答する
+     </PrimaryButton>
    </section>
  );
};
```

## 最終的なコード

```tsx:App.tsx
import { ErrorBoundary, Match, Switch, type Component } from "solid-js";
import { GameResultView } from "./views/GameResultView";
import { GamePlayView } from "./views/GamePlayView";
import { Loader } from "./features/ui/Loader";
import { PokemonType } from "./features/pokemon/types";

const App: Component = () => {
  const displayTexts = [
    { char: "フ", index: 1 },
    { char: "シ", index: 2 },
    { char: "ギ", index: 3 },
    { char: "バ", index: 4 },
    { char: "ナ", index: 4 },
  ];

  // NOTE: ダミー入りの選択肢
  const options = [
    { char: "マ", index: 2 },
    { char: "バ", index: 3 },
    { char: "ジ", index: 5 },
    { char: "バ", index: 3 },
    { char: "フ", index: 1 },
    { char: "リ", index: 6 },
    { char: "ナ", index: 4 },
    { char: "ン", index: 7 },
  ];

  const pokemon: PokemonType = {
    sprites: {
      other: {
        home: {
          front_default:
            "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/home/3.png",
        },
      },
    },
    id: 1,
    name: "xxx",
    names: [{ language: { name: "xxx" }, name: "yyy" }],
  };
  const isLoading = false;

  const gameState = "START" as "START" | "END";
  const lifeCounts = 3;

  return (
    <ErrorBoundary fallback={<p>Error</p>}>
      <Switch>
        <Match when={isLoading}>
          <div class="h-[10rem] w-[10rem] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
            <Loader />
          </div>
        </Match>
        <Match when={gameState === "END"}>
          <GameResultView score={3} onRetry={() => {}} />
        </Match>
        <Match when={gameState === "START"}>
          <GamePlayView
            pokemon={pokemon}
            lifeCounts={lifeCounts}
            displayTexts={displayTexts}
            options={options}
            onAddInput={() => {}}
            onRemoveInput={() => {}}
          />
        </Match>
      </Switch>
    </ErrorBoundary>
  );
};

export default App;
```

![スクリーンショット 2024-12-16 0.21.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3294810/e9d0f1c6-30c9-936b-fdf1-2a28f5372f6f.png)

## おわりに

For や Match コンポーネントを使う事で、制御処理を非常に見やすく書く事が出来て感動しました。

また標準で ErrorBoundary が使えるのも便利だと感じました。

また本シリーズを通してお気軽にコメントお待ちしております。
また完走賞も目指しているので是非応援お願いします！

---

この記事は [フロントエンドの世界 Advent Calendar 2024](https://qiita.com/advent-calendar/2024/frontend-assort-2024)の 15 記事目です。
次の記事はこちら [SolidJS の世界: データ取得と状態管理 #4](https://qiita.com/nuinteedev/private/afda5d21108228b0bd26)