105
37

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

お題は不問!Qiita Engineer Festa 2023で記事投稿!

【React Server Component】Server を Client の内側に注入できる Composition の力

Last updated at Posted at 2023-06-21

App Router では Server Component 中心になるらしいけど、動きがある要素には Client Component が必要になるから、どうせ全部 Client Component で書くことになるんでしょ?

Next.js App Router で Server component (以下 Server Comp.) に触れようとすると、このような疑問が出てくるかもしれません。しかし、Composition パターンによって 「Client Comp. の中に Server Comp. が入っているように見える」画面を実現できるようになります。

前置きの前置き: 公式ドキュメント

Next.js 公式ドキュメントには、 Next.js における Server Comp. と Client Comp. の使い方についての記述があります。当記事はこのドキュメントを希釈したものなので、当記事だけで満足せず公式も読みましょう。(今はまだ日本語訳されていませんが DeepL なりで翻訳してください)

前置き: Server Comp. と Client Comp. は組み合わせ可能

本題に入る前に。

まず、「Server Comp. を使うと動きがある画面が作れない」ということはありません。

Server Comp. と Client Comp. の段階的なレンダリング (Stage 0 , State 1) について書かれたこの記事を読んでみてください。

Server Comp. から Client Comp. を使った場合、 Stage 0 において Server Comp. から吐き出された Client Comp. のツリーがクライアント (Stage 1) に渡されて、実際に動きのある UI になります。(SSRは一旦忘れて)

そのため、 Server Comp. の内側に、 動きのある Client Comp. を配置できる、と理解できると思います。

「Client Comp. の中に Server Comp.」を実現する Composition

しかし、Next.js のドキュメントにあるように、 コンポーネントの依存関係ツリーにおいて Client Comp. の子はすべて Client Comp. になってしまいます。

それじゃあ、コンポーネントの依存のツリーの中どこかで Client Comp. を使ってしまったら、その 内側 は全部 Client Comp. になってしまって、 Server Comp. の恩恵を受けることが不可能になってしまうのでしょうか?

答えは NO です。

Composition を使えば 「コンポーネントの依存関係 / コンポーネントの『内↔外』関係」 を逆転できる、と先日アップロードした下記の記事から分かると思います。

Composition のパワーによって、 Server Comp. を 依存関係上の兄弟 である Client Comp. の 内側 に配置することができます。

以下のアニメーションのような、「ライト/ダークが切り替えられるラッパー (Client Comp.)」と、「その領域の中で Markdown をパースして表示する機能 (Server Comp.)」からなる画面を題材に考えてみましょう。

完成品アニメーション

コード

主な依存ライブラリ

  • Next.js: v13.4.0
    • App Router を使用
  • React: v18.2.0
  • TypeScript v5.1.3
    • (async component を使ってるので 5.1 以上必須)
  • unified: v10.1.2
  • rehype-prism-plus: v1.5.1
  • rehype-react: v7.2.0
  • remark-parse: v10.0.2
  • remark-rehype: v10.1.0

page.tsx ファイル

page.tsx ファイルは、Next.js App Router のルーティングのファイル(ページ本体)です。

このコンポーネント自体は、 "use client" を記述していないので Server Component であり、以下のコンポーネントを使用していまあす。

  • ThemedArea (Client Comp.)
    • 上部にテーマ切り替えのラジオグループが配置される
    • なので、Client Component
  • Markdown (Server Comp.)
    • サイズの大きなライブラリを使いつつバンドルサイズは節約したい
    • なので、Server Component

コンポーネントの依存関係は、

  • Page (Server) -> ThemedArea (Client)
  • Page (Server) -> Markdown (Server)

のようになっているので、 「Client Comp. から Server Comp. は使えない」に反していません。それでいて、 ThemedArea の children Prop を通じて、Themed Area の内側に Markdown コンポーネントを描画することができます。 これが Composition のパワーです。

依存関係の図

page.tsx
import { FC } from "react";

import { ThemedArea } from "./ThemedArea";
import { Markdown } from "./Markdown";

const Page: FC = () => {
  const rawString = `
/*        長いので中略         */
`;

  return (
    <ThemedArea>
      <Markdown rawString={rawString} />
    </ThemedArea>
  );
};

export default Page;
page.tsx 全文はこちら
page.tsx
import { FC } from "react";

import { ThemedArea } from "./ThemedArea";
import { Markdown } from "./Markdown";

const Page: FC = () => {
  const rawString = `
## Hello, World!

First Paragraph, Normal *Italic*  **Bold**.

Second Paragraph.

- Unordered List
- Alice
- Bob
- Zelda

Lorem ipsum dolor sit amet, consectetur 
adipiscing elit, sed do eiusmod tempor incididunt 
ut labore et dolore magna aliqua.

Ut enim ad minim veniam, quis nostrud exercitation 
ullamco laboris nisi ut aliquip ex ea commodo 
consequat.
`;

  return (
    <ThemedArea>
      <Markdown rawString={rawString} />
    </ThemedArea>
  );
};

export default Page;

ThemedArea コンポーネント (Client)

ダーク/ライトモードの切り替えに useState を使うので、Client Component である必要があります。

composition を使って、children を囲う div にスタイルを設定しているので、その中身にまでスタイルが効いています。

ThemedArea.tsx 全文はこちら
ThemedArea.tsx
"use client";

import { FC, ReactNode, useState } from "react";

type Theme = "light" | "dark";

type Props = {
  children?: ReactNode;
};

export const ThemedArea: FC<Props> = ({ children }) => {
  const [theme, setTheme] = useState<Theme>("light");

  return (
    <div style={{ padding: 16 }}>
      {/* ラジオボタンでライト/ダークモード切り替え */}
      <div style={{ display: "flex", gap: 8, padding: 8 }}>
        {(["light", "dark"] as const).map((t) => (
          <label key={t}>
            <input
              type="radio"
              checked={theme === t}
              onChange={() => setTheme(t)}
            />
            {t}
          </label>
        ))}
      </div>

      {/* 中身を表示する領域 */}
      <div
        style={
          theme === "dark"
            ? { color: "#ccc", backgroundColor: "#222" }
            : { color: "#444", backgroundColor: "white" }
        }
      >
        {children}
      </div>
    </div>
  );
};

Markdown コンポーネント (Server)

Markdown コンポーネントでは、 markdown パースライブラリ remark (unified) と、 コードブロックのパースライブラリ prism を使っています。

Server Component 側に記述した JS は、クライアント側には送信されないので、JS ファイルのサイズを削減できます。今回はパースライブラリをモリモリに盛っているので 1.3 MB ほど節約できました。

(個人的には、「描画が一度きりしか起きない」と明示できるところにも魅力を感じます。)

Markdown.tsx 全文はこちら
Markdown.tsx
import "server-only";
// 必須ではないけど、 client にこのコンポーネントのコードが混入するのを阻止する。
// (これを書くには、もちろんライブラリの install が必要)

import { FC, Fragment, createElement } from "react";

import rehypeReact from "rehype-react";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypePrismAll from "rehype-prism-plus";
import { unified } from "unified";


const parser = unified()
  .use(remarkParse)
  .use(remarkRehype)
  .use(rehypePrismAll, { ignoreMissing: true, showLineNumbers: true })
  .use(rehypeReact, { createElement, Fragment });

type Props = {
  rawString: string;
};

export const Markdown: FC<Props> = async ({
  rawString,
}) => {
  const output = await parser.process(rawString);

  return <div style={{ padding: 16 }}>{output.result}</div>;
};

ThemedArea から Markdown (children) には Props を渡せないけど、どうするの?

Composition の弱点といえば、Props を直に渡すことが出来なくなることです。これを克服するのに大きく2つの方法があります。

  • DOM の機能を使う
    • 例: form 要素 - input 要素の連携
    • 例: スタイルの継承
  • React の Context を使う

そのうち、今回は「スタイルの継承」を使っています。 div 要素に { color: "#ccc", backgroundColor: "#222" } とスタイルを当ててしまえば、その div 要素のなか全体にその黒背景白文字のスタイルが当てられます。

また、 form の内側に置かれた input 要素や、 button (type="submit") は勝手に連携が取られるので、それもうまく活用できるかも知れません。

Context を使った実装例

しかし、それだけでは実装できない場合には、 Context を使うことになります。

useContext (およびそのラッパー)での Context 読み取りが Client Comp. 内限定であることに注意が必要です。 例: HogeConsuming コンポーネント

Context をつかう実装の依存グラフ

page.tsx
const Page: FC = () => {
  return (
    <Providers>
      <Contents />
    </Providers>
  )
};
Providers.tsx
"use client";

export const Providers: FC<Props> = ({ children }) => {
  // 中略
  <HogeProvider value={/* */}>
    {children}
  </HogeProvider>
Contents.tsx
export const Contents: FC = () => {
  // 中略
  <HogeConsuming />
HogeConsumer.tsx
"use client";

export const HogeConsuming: FC = () => {
  const hoge = useHogeContext(); // Context から取得

まとめ

いかがでしたか?

とっつきにくく見える React Server Component ですが、 composition を活用できれば、どんな画面でも難なく記述できることが分かったと思います。(強引)

各ライブラリ側では、Next.js App Router 対応に手間取っていたりしますが、主要なライブラリが出揃って、実務で App Router を Server Component で書くのを楽しみに、個人でガチャガチャ触りながら期待しましょう!

関連記事

宣伝

Next.js App Router の機能の一部を逆引きで調べられる記事も書いています。 App Router を試したい人は参照してみてください。

105
37
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
105
37

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?