Qwik(City)の世界: UI構築とトランジション #3


## はじめに

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

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

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

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

## ディレクトリ構成

```txt:tree実行結果
.
├── README.md
├── package.json
├── public
│   ├── favicon.svg
│   ├── manifest.json
│   └── robots.txt
├── qwik.env.d.ts
├── src
│   ├── components
│   │   └── router-head
│   │       └── router-head.tsx
│   ├── entry.dev.tsx
│   ├── entry.preview.tsx
│   ├── entry.ssr.tsx
│   ├── features
│   │   ├── form
│   │   │   └── types
│   │   │       └── index.ts
│   │   ├── game
│   │   │   ├── constants
│   │   │   │   └── index.ts
│   │   │   └── types
│   │   │       └── index.ts
│   │   ├── pokemon
│   │   │   ├── types
│   │   │   │   └── index.ts
│   │   │   └── ui
│   │   │       └── Card
│   │   │           └── index.tsx
│   │   └── time
│   │       └── types
│   │           └── index.ts
│   ├── global.css
│   ├── root.tsx
│   ├── routes
│   │   ├── index.tsx
│   │   ├── layout.tsx
│   │   └── service-worker.ts
│   ├── styles
│   │   └── index.module.css
│   ├── ui
│   │   ├── CharButton
│   │   │   ├── index.tsx
│   │   │   └── type.ts
│   │   ├── LifeCounter
│   │   │   └── index.tsx
│   │   ├── Loader
│   │   │   └── index.tsx
│   │   ├── PrimaryButton
│   │   │   ├── index.tsx
│   │   │   └── type.ts
│   │   └── ProgressBar
│   │       ├── index.tsx
│   │       └── type.ts
│   └── views
│       ├── GamePlayView
│       │   ├── index.module.css
│       │   ├── index.tsx
│       │   └── type.ts
│       └── GameResultView
│           ├── index.module.css
│           ├── index.tsx
│           └── type.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 を実装します。

### リザルト画面

Qwik の`component$`を使用してリザルト画面を実装します。

イベントハンドラーは`$`サフィックスを付けた命名にし、関数の型は `QRL`にジェネリクスとして渡すことで Qwik 用のイベントハンドラー型を定義できます。

(参考: [component$](https://qwik.dev/docs/components/overview/#component) / [Custom Event Props](https://qwik.dev/docs/components/events/#custom-event-props))

```ts:views/GameResultView/type.ts
// NOTE: QRLはQwik特有の型で、関数型のpropsに使用します
import { QRL } from "@builder.io/qwik";

export type GameResultViewProps = {
  onRetry$: QRL<() => void>;
};
```

スタイリングは CSS Modules を使用します。

(参考: [CSS Modules](https://qwik.dev/docs/components/styles/#css-modules))

```css:views/GameResultView/index.module.css
.section__container {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100%;
}

.score__container {
  display: flex;
  padding: 1rem;
  flex-direction: column;
  row-gap: 1rem;
  border: 1px solid gainsboro;
  border-radius: 10px;
}

.score__label {
  font-weight: 400;
  text-align: center;
  color: rgb(100 116 139);
}

.score__value {
  font-size: 1.5rem;
  line-height: 2rem;
  text-align: center;
}

.score__unit {
  font-size: 0.875rem;
  line-height: 1.25rem;
}

.primary__button {
  padding-top: 1rem;
  padding-bottom: 1rem;
  padding-left: 2rem;
  padding-right: 2rem;
  max-width: 100%;
  font-weight: 500;
  background: greenyellow;
  width: 350px;
  border: none;
  cursor: pointer;
  border-radius: 10px;

  &:hover {
    opacity: 0.75;
  }

  &:active {
    opacity: 0.5;
  }
}
```

```tsx:views/GameResultView/index.tsx
import type { GameResultViewProps } from "./type";
import { component$ } from "@builder.io/qwik";

import styles from "./index.module.css";

// NOTE: component$でコンポーネントを定義します
export const GameResultView = component$(
  ({ onRetry$ }: GameResultViewProps) => {
    const { score } = {
      score: 3,
    };

    return (
      <section class={styles.section__container}>
        <div class={styles.score__container}>
          <h2 class={styles.score__label}>スコア</h2>
          <p class={styles.score__value}>
            {score}
            <span class={styles.score__unit}>点</span>
          </p>
          {/* NOTE: onClick$のように$サフィックスを付けてイベントハンドラを定義 */}
          <button onClick$={onRetry$} class={styles.primary__button}>
            もう一度プレイする
          </button>
        </div>
      </section>
    );
  }
);
```

### プレイ画面

同様にプレイ画面も実装します。

```ts:views/GamePlayView/type.ts
import { QRL } from "@builder.io/qwik";
import type { FormType } from "~/features/form/types";
import type { PokemonType } from "~/features/pokemon/types";

export type GamePlayViewProps = {
  displayTexts: FormType[];
  options: FormType[];
  pokemon: PokemonType;
  // NOTE: すべてのイベントハンドラpropsに$サフィックスを付ける
  onAddInput$: QRL<(input: FormType) => void>;
  onRemoveInput$: QRL<(input: FormType) => void>;
  onAnswer$: QRL<() => void>;
};
```

```css:views/GamePlayView/index.module.css
.section__container {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
}

.progressBar__container {
  width: 100%;
  height: 0.75rem;
  background: #cbff7e;
}

.progressBar__track {
  border-top-right-radius: 0.375rem;
  border-bottom-right-radius: 0.375rem;
  height: 100%;
  transition-timing-function: linear;
  background: yellowgreen;
}

.lifeCounter__container {
  display: flex;
  padding-left: 1rem;
  padding-right: 1rem;
  margin: 1rem;
  column-gap: 0.5rem;
  align-items: center;
  width: 100%;
  box-sizing: border-box;
}

.lifeCounter__item {
  font-size: 20px;
}

.title__text {
  display: flex;
  margin-top: 1rem;
  justify-content: center;
  align-items: center;
  font-size: 1.5rem;
  line-height: 2rem;
  font-weight: 500;
}

.displayText__container {
  display: flex;
  column-gap: 1rem;
  align-items: center;
  height: 70px;
}

.options__container {
  display: flex;
  margin: 0.5rem;
  column-gap: 1rem;
  align-items: center;
  height: 70px;
}

.fallback__text {
  display: flex;
  justify-content: center;
  align-items: center;
  color: #9ca3af;
}

.card__container {
  max-width: 100%;
  height: fit-content;
  width: 350px;
}

.primary__button {
  padding-top: 1rem;
  padding-bottom: 1rem;
  padding-left: 2rem;
  padding-right: 2rem;
  max-width: 100%;
  font-weight: 500;
  background: greenyellow;
  width: 350px;
  border: none;
  cursor: pointer;
  border-radius: 10px;

  &:hover {
    opacity: 0.75;
  }
}

.char__button {
  padding-top: 1rem;
  padding-bottom: 1rem;
  padding-left: 1.5rem;
  padding-right: 1.5rem;
  background: ghostwhite;
  border: none;
  cursor: pointer;
  border-radius: 10px;

  &:hover {
    opacity: 0.75;
  }

  &:active {
    opacity: 0.5;
  }
}

.separator {
  width: 100%;
  height: 1px;
  background-color: #e5e7eb;
}
```

```tsx:views/GamePlayView/index.tsx
import { component$ } from "@builder.io/qwik";
import type { GamePlayViewProps } from "./type";

import styles from "./index.module.css";

export const GamePlayView = component$(
  ({
    pokemon,
    options,
    displayTexts,
    onAddInput$,
    onRemoveInput$,
    onAnswer$,
  }: GamePlayViewProps) => {
    const lifeCounts = Array.from({ length: 3 });

    return (
      <section class={styles.section__container}>
        {/* ProgressBar */}
        <div class={styles.progressBar__container}>
          <div
            class={styles.progressBar__track}
            style={{ width: `${70}%` }}
          ></div>
        </div>

        {/* LifeCounter */}
        <div class={styles.lifeCounter__container}>
          {lifeCounts.map((_, index) => (
            <div class={styles.lifeCounter__item} key={index}>
              ❤️
            </div>
          ))}
        </div>

        <h2 class={styles.title__text}>このポケモンは誰？</h2>
        <img
          src={pokemon.sprites.other.home.front_default ?? ""}
          alt="ポケモン"
          draggable={false}
          class={styles.card__container}
          width={350}
          height={350}
        />
        <div class={styles.displayText__container}>
          {displayTexts.length ? (
            <>
              <span>名前は</span>
              {displayTexts.map((displayText) => (
                <button
                  class={styles.char__button}
                  onClick$={() => onRemoveInput$(displayText)}
                  key={`${displayText.char}-${displayText.index}`}
                >
                  {displayText.char}
                </button>
              ))}
              <span>です</span>
            </>
          ) : (
            <span class={styles.fallback__text}>
              👇 クリックで名前を入力して下さい
            </span>
          )}
        </div>
        <div class={styles.separator} />
        <div class={styles.options__container}>
          {options.map((option) => (
            <button
              class={styles.char__button}
              onClick$={() => onAddInput$(option)}
              key={`${option.char}-${option.index}`}
            >
              {option.char}
            </button>
          ))}
        </div>
        <button onClick$={onAnswer$} class={styles.primary__button}>
          回答する
        </button>
      </section>
    );
  }
);
```

### ルート画面

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

**ページローダーのスタイル**
※ ページローダー用のスタイルを src 直下の styles 配下に追加します。

```css:styles/index.module.css
.loader__container {
  position: absolute;
  top: 50%;
  left: 50%;
  height: 10rem;
  width: 10rem;
}
```

:::note warn
関数を定義するときは `$()` で囲みます。(参考: [The dollar \$ sign](https://qwik.dev/docs/advanced/dollar/))
:::

```tsx:routes/index.tsx
import { $, component$ } from "@builder.io/qwik";
import type { DocumentHead } from "@builder.io/qwik-city";
import { GameStoreType } from "~/features/game/types";
import { Loader } from "~/ui/Loader";
import { GamePlayView } from "~/views/GamePlayView";
import { GameResultView } from "~/views/GameResultView";

import styles from "../styles/index.module.css";

export default component$(() => {
  // NOTE: モック用の固定データ
  const displayTexts = [
    { char: "メ", index: 1 },
    { char: "タ", index: 2 },
    { char: "モ", index: 3 },
    { char: "ン", index: 4 },
  ];

  const options = [
    { char: "ン", index: 4 },
    { char: "メ", index: 1 },
    { char: "タ", index: 2 },
    { char: "メ", index: 5 },
    { char: "モ", index: 3 },
    { char: "ル", index: 6 },
  ];

  const gameState: GameStoreType = {
    state: "START",
    life: 3,
    score: 0,
    round: 1,
  };

  const isLoading = false;

  // NOTE: $関数でイベントハンドラを定義
  const onAddInput = $(() => {});
  const onAnswer = $(() => {});
  const onRemoveInput = $(() => {});
  const onRetry = $(() => {});

  // NOTE: 早期リターンによる条件分岐
  if (isLoading)
    return (
      <div class={styles.loader__container}>
        <Loader />
      </div>
    );

  if (gameState.state === "END") {
    return <GameResultView onRetry$={onRetry} />;
  }

  return (
    <GamePlayView
      pokemon={{
        id: 1,
        name: "yyy",
        names: [{ language: { name: "ja" }, name: "xxx" }],
        sprites: {
          other: {
            home: {
              front_default: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/home/132.png",
            },
          },
        },
      }}
      displayTexts={displayTexts}
      options={options}
      onAddInput$={onAddInput}
      onAnswer$={onAnswer}
      onRemoveInput$={onRemoveInput}
    />
  );
});
```

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

`ui/`配下にアプリケーション全体で使用する共通 UI パーツを追加していきます。

### CharButton

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

```tsx:ui/CharButton/index.tsx
import { component$ } from "@builder.io/qwik";
import type { QRL } from "@builder.io/qwik";

type CharButtonProps = {
  onClick$: QRL<() => void>;
  children: any;
};
```

```css:ui/CharButton/index.module.css
.button {
  padding: 1rem 1.5rem;
  background-color: ghostwhite;
  border: none;
  cursor: pointer;
  border-radius: 10px;
}

.button:hover {
  opacity: 0.75;
}

.button:active {
  opacity: 0.5;
}
```

```tsx:ui/CharButton/index.tsx
import { component$ } from "@builder.io/qwik";
import type { CharButtonProps } from "./type";
import styles from "./index.module.css";

export const CharButton = component$((props: CharButtonProps) => {
  return (
    <button onClick$={props.onClick$} class={styles.button}>
      {props.children}
    </button>
  );
});
```

### PrimaryButton

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

```css:ui/PrimaryButton/index.module.css
.button {
  padding: 1rem 2rem;
  max-width: 100%;
  width: 350px;
  font-weight: 500;
  background-color: greenyellow;
  border: none;
  cursor: pointer;
  border-radius: 10px;
}

.button:hover {
  opacity: 0.75;
}

.button:active {
  opacity: 0.5;
}
```

```tsx:ui/PrimaryButton/index.tsx
import { component$ } from "@builder.io/qwik";
import type { QRL } from "@builder.io/qwik";

import styles from "./index.module.css"

type PrimaryButtonProps = {
  onClick$: QRL<() => void>;
  children: any;
};

export const PrimaryButton = component$(
  ({ onClick$, children }: PrimaryButtonProps) => {
    return (
      <button
        onClick$={onClick$}
        class={styles.button}
      >
        {children}
      </button>
    );
  }
);
```

### LifeCounter

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

```css:ui/LifeCounter/index.module.css
.container {
  display: flex;
  padding: 0 1rem;
  margin: 1rem;
  column-gap: 0.5rem;
  align-items: center;
  width: 100%;
  box-sizing: border-box;
}

.item {
  font-size: 20px;
}
```

```tsx:ui/LifeCounter/index.tsx
import { component$ } from "@builder.io/qwik";

import styles from "./index.module.css"

export const LifeCounter = component$(({ lifeCount }: { lifeCount: number }) => {
  return (
    <div class={styles.container}>
      {Array.from({ length: lifeCount }).map((_, index) => (
        <div class={styles.item} key={index}>
          ❤️
        </div>
      ))}
    </div>
  );
});
```

### Loader

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

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

```tsx:ui/Loader/index.tsx
import { component$ } from "@builder.io/qwik";

export const Loader = component$(() => {
  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

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

```css:ui/ProgressBar/index.module.css
.container {
  width: 100%;
  height: 0.75rem;
  background-color: #cbff7e;
}

.track {
  height: 100%;
  border-radius: 0 0.375rem 0.375rem 0;
  background-color: yellowgreen;
  transition: width 1s linear;
}
```

```tsx:ui/ProgressBar/index.tsx
import { component$ } from "@builder.io/qwik";

import styles from "./index.module.css"

type ProgressBarProps = {
  value: number;
  maxValue: number;
};

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

  return (
    <div class={styles.container}>
      <div
        class={styles.track}
        style={{ width: `${progress}%` }}
      ></div>
    </div>
  );
});
```

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

### Card

取得したポケモンの画像表示用コンポーネントを実装します。

```css:features/pokemon/ui/Card/index.module.css
.card {
  width: 350px;
  max-width: 100%;
  height: fit-content;
  margin: 0 auto;
}
```

```tsx:features/pokemon/ui/Card/index.tsx
import { component$ } from "@builder.io/qwik";

import styles from "./index.modules.css"

export const Card = component$(({ src }: { src: string }) => {
  return (
    <img
      src={src}
      alt="ポケモン"
      draggable={false}
      class={styles.card}
    />
  );
});
```

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

### リザルト画面

PrimaryButton をインポートして使用します。

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

  export const GameResultView = component$(
    ({ onRetry$ }: GameResultViewProps) => {
      const { score } = {
        score: 3,
      };
      return (
        <section class={styles.section__container}>
          <div class={styles.score__container}>
            <h2 class={styles.score__label}>スコア</h2>
            <p class={styles.score__value}>
              {score}
              <span class={styles.score__unit}>点</span>
            </p>
-           <button onClick$={onRetry$} class={styles.primary__button}>
-             もう一度プレイする
-           </button>
+           <PrimaryButton onClick$={onRetry$}>
+             もう一度プレイする
+           </PrimaryButton>
          </div>
        </section>
      );
    }
  );
```

### プレイ画面

共通コンポーネントをインポートして使用します。

```diff_tsx:views/GamePlayView/index.tsx
+ import { ProgressBar } from "~/ui/ProgressBar";
+ import { LifeCounter } from "~/ui/LifeCounter";
+ import { CharButton } from "~/ui/CharButton";
+ import { PrimaryButton } from "~/ui/PrimaryButton";
+ import { Card } from "~/features/pokemon/ui/Card";

  export const GamePlayView = component$(
    ({
      pokemon,
      options,
      displayTexts,
      onAddInput$,
      onRemoveInput$,
      onAnswer$,
    }: GamePlayViewProps) => {
      return (
        <section class={styles.section__container}>
-         <div class={styles.progressBar__container}>
-           <div class={styles.progressBar__track} style={{ width: `${70}%` }}></div>
-         </div>
+         <ProgressBar value={70} maxValue={100} />

-         <div class={styles.lifeCounter__container}>
-           {lifeCounts.map((_, index) => (
-             <div class={styles.lifeCounter__item} key={index}>❤️</div>
-           ))}
-         </div>
+         <LifeCounter lifeCount={3} />

          <h2 class={styles.title__text}>このポケモンは誰？</h2>
-         <img
-           src={pokemon.sprites.other.home.front_default ?? ""}
-           alt="ポケモン"
-           draggable={false}
-           class={styles.card__container}
-         />
+         <Card src={pokemon.sprites.other.home.front_default ?? ""} />

          {/* ... 他のコード ... */}

-         <button onClick$={onAnswer$} class={styles.primary__button}>
-           回答する
-         </button>
+         <PrimaryButton onClick$={onAnswer$}>
+           回答する
+         </PrimaryButton>
        </section>
      );
    }
  );
```

## 最終的なコード

```tsx:routes/index.tsx
import { $, component$ } from "@builder.io/qwik";
import type { DocumentHead } from "@builder.io/qwik-city";
import { GameStoreType } from "~/features/game/types";
import { Loader } from "~/ui/Loader";
import { GamePlayView } from "~/views/GamePlayView";
import { GameResultView } from "~/views/GameResultView";

export default component$(() => {
  const displayTexts = [
    { char: "メ", index: 1 },
    { char: "タ", index: 2 },
    { char: "モ", index: 3 },
    { char: "ン", index: 4 },
  ];

  const options = [
    { char: "ン", index: 4 },
    { char: "メ", index: 1 },
    { char: "タ", index: 2 },
    { char: "メ", index: 5 },
    { char: "モ", index: 3 },
    { char: "ル", index: 6 },
  ];

  const gameState: GameStoreType = {
    state: "START",
    life: 3,
    score: 0,
    round: 1,
  };

  const isLoading = false;

  const onAddInput = $(() => {});
  const onAnswer = $(() => {});
  const onRemoveInput = $(() => {});
  const onRetry = $(() => {});

  if (isLoading)
    return (
      <div class={styles.loader__container}>
        <Loader />
      </div>
    );

  if (gameState.state === "END") {
    return <GameResultView onRetry$={onRetry} />;
  }

  return (
    <GamePlayView
      pokemon={{
        id: 1,
        name: "yyy",
        names: [{ language: { name: "ja" }, name: "xxx" }],
        sprites: {
          other: {
            home: {
              front_default: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/home/132.png",
            },
          },
        },
      }}
      displayTexts={displayTexts}
      options={options}
      onAddInput$={onAddInput}
      onAnswer$={onAnswer}
      onRemoveInput$={onRemoveInput}
    />
  );
});
```

<details>
  <summary>📸 実装イメージ</summary>

![qwik.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3294810/0bde78f0-6b7d-3c69-c496-3d3f32e22285.png)

</details>

## おわりに

今回共通コンポーネントやページコンポーネント作成を通して、`$()`や`QRL`をはじめとした`Qwik`の基本概念が少し分かってきました。

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

---

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