てらブログ

てらブログ

日々の学習の備忘録

Next.js(Using App Router)のドキュメントを読んでServer Componentsの要点整理してみた

目的

Next.js(Using App Router)を読んで、App Routerと、Server Components / Client Componentsの概要理解と使用方法を確認したい

参照:
Docs | Next.js

主な特徴

Feature description
Routing Server Componentsの上に構築されたファイルシステムベースのルーターで、レイアウト、ネスト化されたルーティング、ロードステート、エラー処理などをサポートしています。
Rendering クライアント・コンポーネントとサーバー・コンポーネントによるクライアントサイドおよびサーバーサイド・レンダリング。Next.jsによるサーバー上の静的レンダリングと動的レンダリングでさらに最適化。EdgeおよびNode.jsランタイムでのストリーミング。
Data Fetching React Componentsのasync/awaitサポートと、ReactとWeb Platformに沿ったfetch()s APIにより、データ取得を簡素化。

サーバーコンポーネント

Server Componentsを使えば、開発者はサーバーのインフラをより有効に活用できる。例えば、データ取得をサーバーに移し、データベースに近づけることができる。また、以前はクライアントのJavaScriptバンドルのサイズに影響を与えていた大きな依存関係をサーバーに残すことができ、パフォーマンスの向上につながる。
App Router 内のすべてのコンポーネントはデフォルトで Server Components になっている。

クライアントコンポーネント

クライアントコンポーネントを使うと、アプリケーションにクライアントサイドのインタラクティブ機能を追加できる。Next.jsでは、クライアントコンポーネントはサーバー上でプリレンダリングされ、クライアント上でハイドレーションされる。クライアントコンポーネントは、Pages Router のコンポーネントと同じようなものだと考えてOK。
'use client'をファイルの一番上に挿入することで、クライアントコンポーネントとして扱われる。
また、use client "は、すべてのファイルで定義する必要はない。クライアント・モジュールの境界は、インポートされたすべてのモジュールがクライアント・コンポーネントとみなされるために、"エントリーポイント "で一度だけ定義する必要がある。

サーバーコンポーネントとクライアントコンポーネントの使い分け

クライアントコンポーネントユースケースが決まるまで、サーバーコンポーネント(appディレクトリのデフォルト)を使うことを推奨されている。
サーバーコンポーネントとクライアントコンポーネントのさまざまな使用例のまとめ
Getting Started: React Essentials | Next.js

使用の際の詳細パターン

クライアント・コンポーネントの末端への移動

アプリケーションのパフォーマンスを向上させるには、クライアント・コンポーネントを可能な限りコンポーネント・ツリーの末端に移動させることが推奨される。
たとえば、静的な要素(ロゴ、リンクなど)を持つレイアウトと、状態を使用するインタラクティブな検索バーがあるとする。レイアウト全体をクライアントコンポーネントにする代わりに、インタラクティブロジックをクライアントコンポーネントなど)に移動し、レイアウトをサーバコンポーネントとして維持する。これにより、レイアウトのすべてのコンポーネントJavascriptをクライアントに送信する必要がなくなる。

// SearchBar is a Client Component
import SearchBar from './searchbar'
// Logo is a Server Component
import Logo from './logo'
 
// Layout is a Server Component by default
export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <nav>
        <Logo />
        <SearchBar />
      </nav>
      <main>{children}</main>
    </>
  )
}

クライアント・コンポーネントとサーバー・コンポーネントの構成

サーバーコンポーネントとクライアントコンポーネントは、同じコンポーネントツリーで組み合わせることができる。
裏側では、Reactは以下のようにレンダリングを処理している:

 →これには、クライアントコンポーネントの中にネストされたサーバーコンポーネントも含まれる。
 →この段階で遭遇したクライアント・コンポーネントはスキップされる。

※Next.jsでは、最初のページロード時に、上記のステップでレンダリングされたサーバーコンポーネントとクライアントコンポーネントの両方が、HTMLとしてサーバー上にプリレンダリングされる。

クライアント・コンポーネントの中にサーバー・コンポーネント入れ子にする

サーバーコンポーネントをクライアントコンポーネントにインポートすることはできない。

'use client'
 
// This pattern will **not** work!
// You cannot import a Server Component into a Client Component.
import ExampleServerComponent from './example-server-component'
 
export default function ExampleClientComponent({
  children,
}: {
  children: React.ReactNode
}) {
  const [count, setCount] = useState(0)
 
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
 
      <ExampleServerComponent />
    </>
  )
}

サーバーコンポーネントをpropとしてクライアントコンポーネントに渡すことが推奨されている。
以下のアプローチでは、レンダリングは切り離され、独立してレンダリングすることができる。

// This pattern works:
// You can pass a Server Component as a child or prop of a
// Client Component.
import ExampleClientComponent from './example-client-component'
import ExampleServerComponent from './example-server-component'
 
// Pages in Next.js are Server Components by default
export default function Page() {
  return (
    <ExampleClientComponent>
      <ExampleServerComponent />
    </ExampleClientComponent>
  )
}

サーバからクライアント・コンポーネントへの小道具の受け渡し(シリアライズ

サーバからクライアントコンポーネントに渡されるpropは、シリアライズ可能である必要がある。つまり、関数や日付などの値を直接クライアント・コンポーネントに渡すことはできない。

サーバー専用コードをクライアント・コンポーネントから排除する(ポイズニング)

JavaScriptモジュールはサーバーコンポーネントとクライアントコンポーネントの両方で共有することができるため、サーバー上でのみ実行されることを意図していたコードが、クライアントにこっそり入り込む可能性がある。
例えば、次のようなデータ・フェッチ関数の場合:

export async function getData() {
  const res = await fetch('https://external-service.com/data', {
    headers: {
      authorization: process.env.API_KEY,
    },
  })
 
  return res.json()
}

一見すると、getDataはサーバーとクライアントの両方で動作するように見える。しかし、環境変数API_KEYの前にNEXT_PUBLICが付けられていないため、サーバーでのみアクセスできるプライベート変数になっている。Next.jsでは、セキュアな情報の漏えいを防ぐため、クライアントコードではプライベートな環境変数を空文字列に置き換えている。
結果、getData()をインポートしてクライアント上で実行しても、期待通りに動作しない。また、変数をpublicにすることで、この関数はクライアント上で動作するようになるが、その場合は機密情報が漏れることになってしまう。
つまり、この関数はサーバー上でのみ実行されることを想定して書かれている。

サーバー専用パッケージ

上記のような意図しないクライアントによるサーバーコードの利用を防ぐために、サーバー専用パッケージを使用することで、他の開発者が誤ってこれらのモジュールをクライアント・コンポーネントにインポートした場合に、ビルド時にエラーを表示することができる。
server-onlyパッケージをインストールして使用する。

import 'server-only'
 
export async function getData() {
  const res = await fetch('https://external-service.com/data', {
    headers: {
      authorization: process.env.API_KEY,
    },
  })
 
  return res.json()
}

データのフェッチ

クライアント・コンポーネントでデータをフェッチすることも可能だが、特別な理由がない限り、サーバー・コンポーネントでデータをフェッチすることが推奨される。データ取得をサーバーに移すことで、パフォーマンスとユーザーエクスペリエンスが向上する。

コンテキスト

コンテキストのプロバイダは、グローバルな関心事を共有するために、アプリケーションのルート付近にレンダリングされるのが一般的。しかしServer Components ではコンテキストがサポートされていないため、アプリケーションのルートでコンテキストを作成しようとするとエラーになる。
これを解決するには、コンテキストを作成し、そのプロバイダをクライアント・コンポーネントの中にレンダリングする:

app/theme-provider.tsx
'use client'
 
import { createContext } from 'react'
 
export const ThemeContext = createContext({})
 
export default function ThemeProvider({ children }) {
  return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
}

サーバーコンポーネントは、クライアントコンポーネントとしてマークされているので、プロバイダを直接レンダリングできるようになる:

app/layout.tsx
import ThemeProvider from './theme-provider'
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  )
}

プロバイダがルートにレンダリングされると、アプリ全体の他のすべてのクライアント・コンポーネントがこのコンテキストを利用できるようになる。

How React server components work: an in-depth guideを読んでServer Componentsについて理解する

目的

How React server components work: an in-depth guideを読んで要点整理する。

参照:
How React server components work: an in-depth guide

RSCって何?

React server components (RSC)はエキサイティングな新機能で、近い将来、ページロードのパフォーマンスやバンドルサイズ、そしてReactアプリケーションの書き方に大きな影響を与えるらしい。
サーバーとクライアント(ブラウザ)が協力してReactアプリケーションをレンダリングすることを可能にした。つまり一部のコンポーネントをサーバーがレンダリングし、一部のコンポーネントをブラウザがレンダリングすることを可能にした。

Server Side Rendering(SSR)と一緒?

違う。SSRはReactツリーを生のhtmlにレンダリングする環境をシミュレートするもので、サーバーとクライアントのコンポーネントを区別せず、同じようにレンダリングする。一旦SSRは置いておくのが吉。

RSCはここがすごい

RSC以前は、Reactコンポーネントはすべて「クライアント」コンポーネントで、すべてブラウザで実行された。では、なぜサーバー上で何かをレンダリングしたいのだろうか?それは、サーバー上でのレンダリングには、ブラウザよりも優れている点があるからだ。

  • サーバーはデータソースとより密接に連携しているため、ブラウザよりも迅速にデータを取得することができる
  • 重いコードモジュールを楽に利用することができる

つまり、RSCは、サーバーとブラウザーがそれぞれ得意とすることを可能にする。サーバーコンポーネントはデータのフェッチとコンテンツのレンダリングに集中でき、クライアントコンポーネントはステートフルなインタラクティブ性に集中できるため、ページロードが速くなり、javascriptバンドルサイズが小さくなり、ユーザーエクスペリエンスが向上する。

別の言い方をすれば、RSCは役割分担を可能にするためのもの。サーバーができることを前もってやっておき、残りはブラウザに任せる。

サーバーコンポーネント・クライアントコンポーネントの分割
拡張子によって使用できるコンポーネントが変わる。.server.jsxだとサーバーコンポーネント、.client.jsxだとクライアントコンポーネント、どちらでもなければ両方のコンポーネントを使用できる。

また、大事なことは、クライアント・コンポーネントはサーバー・コンポーネントをインポートできない。以下の例はだめ。

// ClientComponent.client.jsx
// NOT OK:
import ServerComponent from './ServerComponent.server'
export default function ClientComponent() {
  return (
    <div>
      <ServerComponent />
    </div>
  )
}

ただし、ReactNodesのpropsとして取り込むことはできる!

// ClientComponent.client.jsx
export default function ClientComponent({ children }) {
  return (
    <div>
      <h1>Hello from client land</h1>
      {children}
    </div>
  )
}

// ServerComponent.server.jsx
export default function ServerComponent() {
  return <span>Hello from server land</span>
}

// OuterServerComponent.server.jsx
// OuterServerComponent can instantiate both client and server
// components, and we are passing in a <ServerComponent/> as
// the children prop to the ClientComponent.
import ClientComponent from './ClientComponent.client'
import ServerComponent from './ServerComponent.server'
export default function OuterServerComponent() {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  )
}

RSCの詳細

1. サーバーがレンダリング要求を受け取る
"ルート "コンポーネントは常にサーバー・コンポーネントであり、他のサーバーまたはクライアント・コンポーネントレンダリングする可能性がある。

2. サーバーはルート・コンポーネント要素をJSONシリアライズする。
サーバーはルート・サーバー・コンポーネントを、ベースとなるhtmlタグとクライアント・コンポーネントのツリーにレンダリングする。そしてこのツリーをブラウザに送信し、最終的にブラウザでレンダリングを行うことができる。

クライアント・コンポーネントやベースとなるhtmlタグに渡すすべてのpropもシリアライズ可能でなければならない。つまり、サーバコンポーネントから、イベントハンドラをpropとして渡すことはできない!

// NOT OK: server components cannot pass functions as a prop
// to its descendents, because functions are not serializable.
function SomeServerComponent() {
  return <button onClick={() => alert('OHHAI')}>Click me!</button>
}

3. ブラウザがReactツリーを再構築する
ブラウザはサーバーからJSON出力を受信し、ブラウザでレンダリングされるようにReactツリーの再構築を開始する。

残タスク

シリアライズやモジュール参照など、RSCをより正確に理解するヒントがあった為、改めて深堀りしたい。
今後ドキュメントを参照したり、ハンズオンをしたりしながら身につけていく予定。

enumは使わずにas const等でreadonlyのconstを作成することについて

TypeScriptのEnumについて

enumとは

enumは、関連する定数のグループを一緒に束ねるための機能。たとえば、以下のようにenumを使用することで、特定の集合の値(この場合は方角)を表すことができる。

enum Direction {
  Up,
  Down,
  Left,
  Right
}

enumはいくつかの問題点を抱えている。例えば、生成されるJavaScriptが予想外の挙動を示すことがあったり、enum内の値が実行時に変更可能であるなどの問題がある。

生成されるJavaScriptが想定外の挙動を示す:

enum Colors {
  Red,
  Green,
  Blue
}

上記がJSにコンパイルされると

var Colors;
(function (Colors) {
  Colors[Colors["Red"] = 0] = "Red";
  Colors[Colors["Green"] = 1] = "Green";
  Colors[Colors["Blue"] = 2] = "Blue";
})(Colors || (Colors = {}));

生成されたコードはパフォーマンス上の理由から問題となることもある。

実行時にenumの値が変更可能である:

TypeScriptのenumは実行時にJavaScriptオブジェクトとして存在する。これは、enumの値がランタイムで変更可能であることを意味する。

enum Colors {
  Red,
  Green,
  Blue
}

Colors.Red = 10;  // これが可能。

これはenumがconstであるべきであるという考え方とは対照的。
これらの問題を避けるためには、TypeScriptのas const構文を使用して、readonlyなオブジェクトを作成する方法が推奨される。

as const構文を使用してreadonlyな定数を作成する

これは"const assertions"と呼ばれ、TypeScript 3.4以降で利用可能。

const Directions = {
  Up: "UP",
  Down: "DOWN",
  Left: "LEFT",
  Right: "RIGHT"
} as const;

この場合、Directionsは変更不可能(readonly)であり、そのプロパティはそれぞれ文字列リテラル型("UP"、"DOWN"、"LEFT"、"RIGHT")を持つ。これにより、enumと同じように関連する定数をまとめることができ、さらにenumの持つ問題を避けることが可能になる。


参照:
列挙型(enum)の問題点と代替手段 | TypeScript入門『サバイバルTypeScript』

ReactでTrelloっぽいタスク管理アプリの作成

将来的にドラッグアンドドロップでリストの並び替え機能の実装がある為、簡単にハンズオンしてみた。

使用したライブラリは「react-beautiful-dnd」。
参照:
GitHub - atlassian/react-beautiful-dnd: Beautiful and accessible drag and drop for lists with React

ただメンテナンスも終了しているし、Reactも17系までしか対応していない為、実際にプロジェクトで使用することはない。
今回はこちらのライブラリを使用した動画があったので使用してみることにした。

作成したアプリの動作は下記のような感じです。

ライブラリを使うとこんなに簡単に作れるなんてと感動。。。
イメージが分かりやってみて良かったです。
API・DBとの連携は無かったので、今後の課題としてやってみたいと思います。

React Hook Form × Zod × TypeScriptでフォームを作成する②

背景

前回の残課題だった下記について、作業を進めてみたのでまとめてみる。
・制御コンポーネント・非制御コンポーネントでのReact Hook Formの使い方の違いについて
・Zodで少し複雑なバリデーションを作成する

制御コンポーネント・非制御コンポーネントでのReact Hook Formの使い方の違いについて

そもそも制御・非制御コンポーネントとは

制御・非制御コンポーネントについて、一言でいうと下記のとおり。
制御コンポーネントはライブラリ(React)が「入力要素の状態」を管理
非制御コンポーネントは「入力要素の状態」を DOM 自身が保持
参照:
フォーム – React
非制御コンポーネント – React

制御コンポーネントではControllerを使用する

ControllerだらけになってしまうとReact Hook Formの良さが半減する気が...
参照:
Controller

Chakra UI

単純にChakura UI = 制御コンポーネント、かと思いきやそうでもないみたい。
Chakra UIのドキュメントでもregisterで紹介されてたりする。
参照:
Chakra UI + React Hook Form - Chakra UI

Zodで少し複雑なバリデーションを作成する

シンプルなバリデーションだとschemaは下記のような感じ。

const schema = z.object({
  fruit: z.string().nonempty({ message: "未入力です" }),
});

例えば下記のように、既に登録したデータの重複チェックをしたいとする。

.refine()メソッドで、カスタムバリデーションロジックを実現する。

const fruits = ["りんご", "ばなな", "どらごんふるーつ"]

const schema = z.object({
  fruit: z
      .string()
      .nonempty({ message: "未入力です" })
      .refine(value => !fruits.includes(value), {
        message: "既に存在しています",
      }),
});

参照:
Zod | Documentation

React Hook Form × Zod × TypeScriptでフォームを作成する①

背景

フォームを作成することになりました。プロジェクト全体でフォームが沢山登場しそうな雰囲気だったので、よりフォームを簡単に扱うためのライブラリを入れることにしました。
この機会にキャッチアップ・整理したいと思います。
React Hook Formは、フォームのデータのバリデーションやエラー処理を簡単に行うことができ、より高速でパフォーマンスが高いことが特徴らしいです。
またZodを組み合わせることで、より複雑なバリデーションにも対応できそうだったので選択しました。

そもそもライブラリを使用しない場合

useState・useRef・そもそもHookを使用しない、など色々な手段がありました。
useStateを使用すると、下記のようになります。

import { useState } from "react";

export const App = () => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    console.log({
      email,
      password,
    });
  };

  return (
    <div className="App">
      <h1>ログイン</h1>
      <form onSubmit={handleSubmit}>
        <div>
          <label htmlFor="email">Email</label>
          <input
            id="email"
            name="email"
            value={email}
            onChange={e => {
              setEmail(e.target.value);
            }}
          />
        </div>
        <div>
          <label htmlFor="password">Password</label>
          <input
            id="password"
            name="password"
            value={password}
            onChange={e => {
              setPassword(e.target.value);
            }}
            type="password"
          />
        </div>
        <div>
          <button type="submit">ログイン</button>
        </div>
      </form>
    </div>
  );
}

シンプルなフォームを作成するのであれば、これで良い気もします。。

React Hook Formの使用

次にReact Hook Formのみを使用してフォームを作成していきます。
先ほどのuseStateを使用したフォームを作り替えてみます。

import { useForm, type SubmitHandler } from "react-hook-form";

type FormValues = {
  email: string;
  password: string;
};

export const App = () => {
  const { register, handleSubmit } = useForm<FormValues>();

  const onSubmit: SubmitHandler<FormValues> = data => {
    console.log(data);
  };

  return (
    <div className="App">
      <h1>ログイン</h1>
      <form onSubmit={onSubmit}>
        <div>
          <label htmlFor="email">Email</label>
          <input
            id="email"
            {...register("email")}
          />
        </div>
        <div>
          <label htmlFor="password">Password</label>
          <input
            id="password"
            {...register("password")}
            type="password"
          />
        </div>
        <button type="submit">ログイン</button>
      </form>
    </div>
  );
}

register内部でrefが使われ、非常にシンプルな記述になりました。
参照:
useForm - register

React Hook Formのバリデーション

シンプルなバリデーション

次にReact Hook Formでバリデーションを作成します。
registerの箇所を{...register("email", { required: "入力が必須の項目です" })}とし、バリデーションを追加。
また、useFormからformState: { errors },を取得し、エラー情報を参照できるようにします。

return (
    <div className="App">
      <h1>ログイン</h1>
      <form onSubmit={onSubmit}>
        <div>
          <label htmlFor="email">Email</label>
          <input
            id="email"
            // 入力必須のバリデーションを追加
            {...register("email", { required: "入力が必須の項目です" })}
          />
          {/* エラーメッセージの表示 */}
          {errors.email?.message && <div>{errors.email.message}</div>}
        </div>
        <div>
          <label htmlFor="password">Password</label>
          <input
            id="password"
            {...register("password")}
            type="password"
          />
        </div>
        <button type="submit">ログイン</button>
      </form>
    </div>
  );

複数のバリデーション

また、1つの入力項目に複数のバリデーションを作成することも可能。
デフォルトだと1つのエラー内容しか取れないので、useFormに{ criteriaMode: "all" }を渡し、
registerにもオブジェクト記法で複数のバリデーションを渡す。

const {
  register,
  handleSubmit,
  formState: { errors },
} = useForm<FormValues>({
  criteriaMode: "all",
});

デフォルト値

フォームにデフォルト値を設定することも可能。
useFormにdefaultValuesを渡す。

const { register, handleSubmit } = useForm<FormValues>({
  defaultValues: { email: "test@test.com", password: "pass" },
});

バリデーションの発火タイミング

また、バリデーションのタイミングを変更することもできる。
デフォルトでは1回目はSubmit時で、それ以降はonChangeのタイミングでバリデーションを行う。
例えばuseFormに{ mode: "onChange" }を渡すと、最初からonChageのタイミングでバリデーションが行われるようになる。

const {
  register,
  handleSubmit,
  formState: { errors },
} = useForm<FormValues>({
  mode: "onChange",
});

入力状態のチェック

さらに、isDirtyを使用することで、フォームが未入力状態かどうか判定できる。
formState オブジェクトのプロパティにisDirtyを渡す。
submitボタンのdisabledの値として渡すことで、未入力時はボタンを無効にする挙動を実装することができる。

const {
register,
handleSubmit,
formState: { isDirty },
} = useForm<FormValues>();
<button
    type="submit"
    disabled={!isDirty}
>
    ログイン
</button>

詳細な入力チェック

dirtyFieldsを使用すると、それぞれの入力項目に対して未入力状態かを判定できる。

const {
register,
handleSubmit,
formState: { dirtyFields },
} = useForm<FormValues>();
<button
    type="submit"
    disabled={!(dirtyFields.email && dirtyFields.password)}
>
    ログイン
</button>

Zodの利用

ライブラリとリゾルバーをインストール。

% npm install @hookform/resolvers zod

スキーマを利用して、バリデーションの設定を行う。

import { zodResolver } from "@hookform/resolvers/zod";
import * as z from 'zod';

const schema = z.object({
  email: z.string().email().min(1),
  password: z.string().min(1),
});

また、定義した schema を利用するために useForm Hook の引数で zodResolver を利用。

const {
  register,
  handleSubmit,
  formState: { errors },
} = useForm({
  resolver: zodResolver(schema),
});

全体のコードとしては下記のようになりました。

/* eslint-disable @typescript-eslint/no-misused-promises */
import { Input } from "@chakra-ui/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm, type SubmitHandler } from "react-hook-form";
import * as z from "zod";

const schema = z.object({
  email: z.string().email({ message: "メールアドレスの形式ではありません。" }),
  password: z.string().min(1, { message: "1文字以上入力する必要があります。" }),
});

type FormValues = {
  email: string;
  password: string;
};

const App = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormValues>({ resolver: zodResolver(schema) });

  const onSubmit: SubmitHandler<FormValues> = data => {
    console.log(data);
  };

  return (
    <div className="App">
      <h1>ログイン</h1>
      <form onSubmit={handleSubmit(onSubmit)}>
        <div>
          <label htmlFor="email">Email</label>
          <Input
            id="email"
            {...register("email")}
          />
          <p>{errors.email?.message}</p>
        </div>
        <div>
          <label htmlFor="password">Password</label>
          <Input
            id="password"
            {...register("password")}
            type="password"
          />
          <p>{errors.password?.message}</p>
        </div>
        <button type="submit">ログイン</button>
      </form>
    </div>
  );
};

export default App;

課題・残タスク

今後の課題として、制御系と非制御系でのReact Hook Formの使用方法の違いと、いまいちZodの利点を感じられなかったので、Zodを導入する利点について深堀してみたいと思いました。

参照:
【2022年】 React Hook FormでValidationライブラリはどれにするか?

NestJS・React環境でGraphQLを学習してみた②

NestJS・ReactでToDoリストを作成した。

基本的なCRUD機能を実装し、GraphQLを用いた開発を一通りやることで理解が深まった。

もっと確認したいことがたくさんあるので、引き続き手を動かしていきたい。

残タスク:
・BFFについて確認したい
Apollo Clientではなく、たとえばTanstackQueryを使うとどうなる
・mutationの数を増やしたい