📝

レンダリング抑制にReact.memoは必要ない

2023/02/21に公開
1

はじめに

Reactで過剰なレンダリングを抑制したいときReact.memoはほぼ不要ですし、公式サイドもそう述べています。しかし知識がないと実行しそうになる罠もあり、過去に私も陥りました。

この投稿では、レンダリングを抑制したい典型的なユースケースとその好ましい対処法を紹介します。

題材

よくありがちな状況が、フォームとリストが一緒になったページで引き起こされる状況です。

  • フォームの操作によりリストをデータフェッチして更新する
  • リストのレンダリングが重いことで、フォームの操作がもたつく

サンプルでは、とても単純なメッセージ投稿アプリを題材にします。メッセージが2000件もあることで、投稿フォームに連続入力するともたつきます(!)

サンプルコード

メインコンポーネントに呼び出すインポートするファイルとして次のものを用意してください。
また前提条件としてこのライブラリが有効になっている必要があります。

  • Tailwind CSS
  • Tanstack Query
UIコンポーネント集
ui.tsx
import React, { ComponentPropsWithRef } from 'react';
import { clsx } from './misc';

// ----------------------------------------

const Container = React.forwardRef<
  HTMLDivElement,
  React.ComponentPropsWithRef<'div'>
>((props, ref) => {
  const { className, ...rest } = props;

  return <div className={clsx('px-4', className)} {...rest} ref={ref} />;
});

Container.displayName = 'Container';

export { Container };

// ----------------------------------------

const Button = React.forwardRef<
  HTMLButtonElement,
  React.ComponentPropsWithRef<'button'>
>((props, ref) => {
  const { className, ...rest } = props;

  return (
    <button
      className={clsx(
        'inline-flex h-10 items-center justify-center rounded-md bg-sky-500 px-4 text-white',
        'disabled:cursor-not-allowed disabled:bg-gray-300',
        'hover:bg-sky-600 active:bg-sky-700',
        'focus:outline-none focus:ring-2 focus:ring-sky-300',
        className
      )}
      {...rest}
      ref={ref}
    />
  );
});

Button.displayName = 'Button';

export { Button };

// ----------------------------------------

const Input = React.forwardRef<
  HTMLInputElement,
  ComponentPropsWithRef<'input'>
>((props, ref) => {
  const { className, ...rest } = props;

  return (
    <input
      className={clsx(
        'h-10 rounded-md bg-gray-100 px-4 py-2',
        'focus:outline-none focus:ring-1 focus:ring-sky-500',
        className
      )}
      {...rest}
      ref={ref}
    />
  );
});

Input.displayName = 'Input';

export { Input };

// ----------------------------------------

const List = React.forwardRef<
  HTMLUListElement,
  React.ComponentPropsWithRef<'ul'>
>((props, ref) => {
  const { className, ...rest } = props;

  return (
    <ul
      className={clsx(
        'h-full flex-1 divide-y overflow-y-auto rounded-lg bg-gray-50 px-4',
        className
      )}
      {...rest}
      ref={ref}
    />
  );
});

List.displayName = 'List';

const ListItem = React.forwardRef<
  HTMLLIElement,
  React.ComponentPropsWithRef<'li'>
>((props, ref) => {
  const { className, ...rest } = props;

  return (
    <li
      className={clsx('flex h-12 items-center', className)}
      {...rest}
      ref={ref}
    />
  );
});

ListItem.displayName = 'ListItem';

const ListModule = Object.assign(List, { Item: ListItem });
export { ListModule as List };

ヘルパーやその他処理の寄せ集め
misc.tsx
import React from 'react';

// utils
// --------------------------------------------------

export const clsx = (...classes: (string | false | undefined | null)[]) => {
  return classes.filter(Boolean).join(' ');
};

/**
 * create array: `[0, 1, ..., <n - 1>]
 */
export const range = (c: number) => Array.from(new Array(c)).map((_, i) => i);

export const queryKeys = {
  messages: 'messages',
};

// types
// --------------------------------------------------

export interface Message {
  id: number;
  content: string;
}

// api
// --------------------------------------------------

const SIZE = 2500;

const fakeMessages: Message[] = range(SIZE).map((num) => ({
  id: num + 1,
  content: `message ${num + 1}`,
}));

class FakeMessageAPI {
  private readonly _data: Message[];

  constructor() {
    this._data = fakeMessages;
  }

  public async findAll() {
    console.log('Sleep: 800ms (Fake loading)'); // sleep
    await new Promise((r) => setTimeout(r, 800));

    return [...this._data].reverse();
  }

  public async create(message: string) {
    const lastId = [...this._data].pop()?.id;
    const nextId = lastId! + 1;
    const newItem = { id: nextId, content: message };
    this._data.push(newItem);
  }
}

export const MessageAPI = new FakeMessageAPI();
エントリーポイント (App.tsx的なもの)

これから解説するメインのコードです。

App.tsx
import './App.css';

import React, { useState } from 'react';
import {
  QueryClient,
  QueryClientProvider,
  useQuery,
} from '@tanstack/react-query';
import { MessageAPI, Message, queryKeys } from './misc';
import { Button, Container, Input, List } from './ui';

// ----------------------------------------

function MessageList({ data }: { data: Message[] | undefined }) {
  console.log('Message List render!');

  return (
    <List>
      {data?.map((message) => (
        <List.Item key={message.id}>{message.content}</List.Item>
      ))}
    </List>
  );
}

// ----------------------------------------

function MessageView() {
  const [input, setInput] = useState('');
  const { data, isFetching, refetch } = useQuery({
    queryFn: () => MessageAPI.findAll(),
    queryKey: [queryKeys.messages],
  });

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    await MessageAPI.create(input);
    setInput('');
    await refetch();
  };

  return (
    <Container className="flex h-screen flex-col pb-4 pt-2">
      <div className="flex-0 flex h-12 items-center justify-between">
        <h1 className="text-4xl font-bold">Demo</h1>

        {isFetching && <p className="text-gray-400">Loading...</p>}
      </div>

      <form
        className="flex-0 flex h-20 items-center space-x-3"
        onSubmit={handleSubmit}
      >
        <Input
          type="text"
          value={input}
          className="block w-full flex-1"
          placeholder="input here..."
          onChange={(e) => setInput(e.target.value)}
        />

        <Button
          className="w-20"
          disabled={isFetching || input.trim().length === 0}
        >
          Add
        </Button>
      </form>

      <MessageList data={data} />
    </Container>
  );
}

// ----------------------------------------

const queryClient = new QueryClient();

export function AppDefault() {
  return (
    <QueryClientProvider client={queryClient}>
      <MessageView />
    </QueryClientProvider>
  );
}

1. React.memo

表面的に対処するならReact.memoが最も簡単ですが、その簡単さ以上に負債を引き起こします。

React.memoは特別な事情があって適用するAPIという位置づけにも関わらず、少しリッチなくらいのWebサイト的なもので使うことはミスマッチです。またこのコードを見たビギナーが、memoを真似して通常のすべてのコードにコピーするという地獄も産まるかもしれません。

またコールバックをリストUIに渡していたらuseCallbackも全部ラップするために使う羽目になります。保守性の低下は避けられません。

重要な点としては、React.memoやuseCallbackの実行には少なからずコストがかかり、パフォーマンスの向上を保証するものではありません。そのため不安定なコードを生んでしまい、最良の状態を保つことは難しくなります。

コードの差分 (初期から)

App.tsx
function MessageList({ data }: { data: Message[] | undefined }) {
  console.log('MessageList render!');

  return (
    <List>
      {data?.map((message) => (
        <List.Item key={message.id}>{message.content}</List.Item>
      ))}
    </List>
  );
}

+const MemorizedMessageList = React.memo(MessageList);

// ...

-      <MessageList data={data} />
+      <MemorizedMessageList data={data} />
    </Container>
  );
}

Pros

  • 簡単で高速なので、応急処置としては手軽

Cons

  • パフォーマンスが低下しない保証がない
  • メンテナンス性が悪い
  • エッジケース向きの処置を適用しているので、相応しくない

2. stateのリフトアップ + データフェッチライブラリ

React.memoが相応しくないと分かったところでどうすれば良いのでしょうか。最も推奨される典型的な方法は、重いレンダリングを引き起こすstate管理をリフトアップすることです。具体的にはリストUIに関わるコード全てを自身のpropsから経由するように変更します。

これでフォームと巨大なリストを同時に「見る」コンポーネントは無くなり、それぞれの機能に相応の処理を行うコンポーネントの分担になりました。

コードの差分 (初期から)

propsは何でも構いません。ここでは収まりが良いのでchildrenを使っていますが、DOM構造がしっくり来なかったり複数のViewがある等の場合は具体的なprops名の方が好ましいでしょう。

App.tsx
+type MessageViewFrameProps = React.PropsWithChildren;

-function MessageView() {
+function MessageViewFrame(props: MessageViewFrameProps) {
+  const { children } = props;

  const [input, setInput] = useState('');
-  const { data, isFetching, refetch } = useQuery({
+  const { isFetching, refetch } = useQuery({
    queryFn: () => MessageAPI.findAll(),
    queryKey: [queryKeys.messages],
  });

  // ...

-      <MessageList data={data} />
+      {children}
    </Container>
  );
}

+function MessageView() {
+  const { data } = useQuery({
+    queryFn: () => MessageAPI.findAll(),
+    queryKey: [queryKeys.messages],
+  });
+
+  return (
+    <MessageViewFrame>
+      <MessageList data={data} />
+    </MessageViewFrame>
+  );
+}

Pros

  • コンポーネントがそれぞれの働きごとにまとまる

Cons

  • 記述量が少し増える

3. stateのリフトアップ + Context

これまでわかりやすい例としてデータ取得を一元管理してくれるTanstack Queryがあること前提でやっていましたが、現実的にそうでないPJも多いと思います。その場合も考えは同じであり、状態の参照先がuseQueryから自前Contextに変わるだけです。

ただし精細なドキュメントがあるデータフェッチライブラリと比べて、ReactのHooksやContextをしっかり作れるかは経験により差があります。

例えば今の「メッセージを投稿する」という題材が「メッセージをフィルタリングする。ボタンが押されたらGETリクエストのパラメータにセットして更新する」となったとき、Contextの設計をどうするのか大まかにイメージできないなら実装はやや厳しいでしょう。

コードの差分 (2から)

データフェッチの状態を自前で管理することに伴い、事前にの2つのコードを追加します。

API処理用のカスタムフック

Tanstack Queryっぽいやつの雑なコピーです。

use-fetch.ts
import React, { useEffect, useRef, useState } from 'react';

export interface UseFetchOptions<T> {
  fetchFn: () => Promise<T>;
  depsKey: React.DependencyList;
  /**
   * @default true
   */
  enabled?: boolean;
  onSuccess?: (data: T) => void;
  onError?: <E extends Error>(error: E) => void;
}

export interface UseFetchState<T> {
  data: T | undefined;
  isFetching: boolean;
  error: Error | undefined;
}

export interface UseFetchResult<T> extends UseFetchState<T> {
  isLoading: boolean;
  refetch: () => void;
}

/**
 * @example
 * ```tsx
 * const { data: users, isLoading, error } = useFetch({
 *   fetchFn: () => getUsers(),
 *   depsKey: [],
 *   onError: (error) => showToast('Error!')
 * })
 *
 * if (isLoading) return <p>Loading</p>
 * if (error) return <p>errpr</p>
 *
 * return <ul>{users.map(...)}</ul>
 * ```
 */
export function useFetch<T>(options: UseFetchOptions<T>): UseFetchResult<T> {
  const { fetchFn, depsKey, enabled = true, onSuccess, onError } = options;

  const savedFetchFn = useRef<UseFetchOptions<T>['fetchFn']>();

  useEffect(() => {
    savedFetchFn.current = fetchFn;
  }, [fetchFn]);

  const savedOnSuccess = useRef<UseFetchOptions<T>['onSuccess']>();

  useEffect(() => {
    savedOnSuccess.current = onSuccess;
  }, [onSuccess]);

  const savedOnError = useRef<UseFetchOptions<T>['onError']>();

  useEffect(() => {
    savedOnError.current = onError;
  }, [onError]);

  const [state, setState] = useState<UseFetchState<T>>({
    data: undefined,
    isFetching: false,
    error: undefined,
  });

  const refetch = () => {
    setState((prev) => ({ ...prev, isFetching: true }));
    savedFetchFn
      .current?.()
      .then((data) => {
        setState((prev) => ({ ...prev, data }));
        savedOnSuccess.current?.(data);
      })
      .catch((error) => {
        setState((prev) => ({ ...prev, error }));
        savedOnError.current?.(error);
      })
      .finally(() => {
        setState((prev) => ({ ...prev, isFetching: false }));
      });
  };

  useEffect(() => {
    if (!enabled) return;

    let ignore = false;

    setState((prev) => ({ ...prev, isFetching: true }));
    savedFetchFn
      .current?.()
      .then((data) => {
        if (!ignore) {
          setState((prev) => ({ ...prev, data }));
          savedOnSuccess.current?.(data);
        }
      })
      .catch((error) => {
        setState((prev) => ({ ...prev, error }));
        savedOnError.current?.(error);
      })
      .finally(() => {
        if (!ignore) {
          setState((prev) => ({ ...prev, isFetching: false }));
        }
      });

    return () => {
      ignore = true;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [enabled, ...depsKey]);

  return { ...state, isLoading: !state.data && !state.error, refetch };
}
Contextの生成を楽にするヘルパー

こういうの作っとくと楽です。後からunstated-nextというライブラリが同じことをしていると知りました。

misc.tsx
+// react
+// ----------------------------------------
+
+/**
+ * カスタムフックをContextに変換する。カスタムフックの引数は、Providerの`option`で指定可能。
+ *
+ * @param useHook カスタムフック
+ * @returns [Contextを呼び出すフック、ContextのProvider]
+ */
+export const createContextState = <HookOptions, HookResult>(
+  useHook: (option: HookOptions) => HookResult
+) => {
+  const Context = React.createContext<HookResult | undefined>(undefined);
+
+  function useContext(): HookResult {
+    const ctx = React.useContext(Context);
+    if (ctx === undefined) throw new Error('err');
+    return ctx;
+  }
+
+  function Provider(props: React.PropsWithChildren<{ options: HookOptions }>) {
+    const { children, options } = props;
+
+    const hookValue = useHook(options);
+
+    return <Context.Provider value={hookValue}>{children}</Context.Provider>;
+  }
+
+  return [useContext, Provider] as const;
+};
App.tsx
function MessageViewFrame(props: MessageViewFrameProps) {
  const { children } = props;

  const [input, setInput] = useState('');
-  const { isFetching, refetch } = useQuery({
-    queryFn: () => MessageAPI.findAll(),
-    queryKey: [queryKeys.messages],
-  });
+  const { isFetching, refetch } = useMessagesContext();
  
  // ...

}

function MessageView() {
-  const { data } = useQuery({
-    queryFn: () => MessageAPI.findAll(),
-    queryKey: [queryKeys.messages],
-  });
+ const { data } = useMessagesContext();

  // ...
}

-const queryClient = new QueryClient();
+const [useMessagesContext, MessagesProvider] = createContextState(
+  useFetch<Message[]>
+);

export function App() {
  return (
-  <QueryClientProvider client={queryClient}>
+  <MessagesProvider
      options={{
        fetchFn: () => MessageAPI.findAll(),
        depsKey: [],
      }}
    >
      <MessageView />
-    </QueryClientProvider>
+  </MessagesProvider>
  );
}

Pros

  • コンポーネントがそれぞれの働きごとにまとまる

Cons

  • 記述量が少し増える
  • Hooks, ContextのI/Fを忠実に設計する技術が問われる

4. stateのリフトアップ + バケツリレー

(3)の方法をおすすめしたとはいえ、横断的stateの実装は面倒だと感じる方も少なくないでしょう。そうなれば単にpropsのリレーでも全く問題はないです。

ただ個人的にはAPI stateの出自がはっきりと分かる気がするContextの方式が好みです。

コードの差分 (3から)

-type MessageViewFrameProps = React.PropsWithChildren;
+type MessageViewFrameProps = React.PropsWithChildren<{
+  isFetching: boolean;
+  refetch: () => void;
+}>;

function MessageViewFrame(props: MessageViewFrameProps) {
- const { children } = props;
+ const { children, isFetching, refetch } = props;


  const [input, setInput] = useState('');
- const { isFetching, refetch } = useMessagesContext();

  // ...

}

function MessageView() {
- const { data } = useMessagesContext();
+ const { data, isFetching, refetch } = useFetch({
+   fetchFn: () => MessageAPI.findAll(),
+   depsKey: [],
+ });

  return (
-   <MessageViewFrame>
+   <MessageViewFrame isFetching={isFetching} refetch={refetch}>
      <MessageList data={data} />
    </MessageViewFrame>
  );
}

-const [useMessagesContext, MessagesProvider] = createContextState(
-  useFetch<Message[]>
-);

export function App() {
- return (
-   <MessagesProvider
-     options={{
-       fetchFn: () => MessageAPI.findAll(),
-       depsKey: [],
-     }}
-   >
-     <MessageView />
-   </MessagesProvider>
- );
+ return <MessageView />;
}

Pros

  • コンポーネントがそれぞれの働きごとにまとまる

Cons

  • APIを起点とした状態がわかりにくい

そもそも、本当に必要だった?

果たして本当に今までの施策は必要なのでしょうか。そうでないケースが多いかもしれません。
現実的にユーザが直面する問題を考えてみます。つまり本番にビルドすると...あら不思議、連打してもちゃんと動いています。

ただ開発体験が著しく悪化するのも問題なので、トレードオフを考えて総合的に判断しましょう。

総括

これまでを踏まえてまとめると次のようになります。

パターン 難度 おすすめ度
何もしない ★☆
(1) React.memo ☆☆☆
(2) stateのリフトアップ + データフェッチライブラリ 易〜中 ★★★
(3) stateのリフトアップ + Context 中〜難 ★★☆
(4) stateのリフトアップ + バケツリレー 易〜中 ★☆☆

Reactはよくできたライブラリですが、過剰なレンダリングに伴う問題はプログラマーにとって数少ない悩みです。しかしこれを元にうまく対処しましょう。

類似の記事

ここ読むより多分役に立ちます。

https://overreacted.io/before-you-memo/

https://zenn.dev/takepepe/articles/react-context-rerender

App.tsxの全コード

腰を据えて確認したい方へ。

(初期)
import './App.css';

import React, { useState } from 'react';
import {
  QueryClient,
  QueryClientProvider,
  useQuery,
} from '@tanstack/react-query';
import { MessageAPI, Message, queryKeys } from './misc';
import { Button, Container, Input, List } from './ui';

// ----------------------------------------

function MessageList({ data }: { data: Message[] | undefined }) {
  console.log('MessageList render!');

  return (
    <List>
      {data?.map((message) => (
        <List.Item key={message.id}>{message.content}</List.Item>
      ))}
    </List>
  );
}

// ----------------------------------------

function MessageView() {
  const [input, setInput] = useState('');
  const { data, isFetching, refetch } = useQuery({
    queryFn: () => MessageAPI.findAll(),
    queryKey: [queryKeys.messages],
  });

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    await MessageAPI.create(input);
    setInput('');
    await refetch();
  };

  return (
    <Container className="flex h-screen flex-col pb-4 pt-2">
      <div className="flex-0 flex h-12 items-center justify-between">
        <h1 className="text-4xl font-bold">Demo</h1>

        {isFetching && <p className="text-gray-400">Loading...</p>}
      </div>

      <form
        className="flex-0 flex h-20 items-center space-x-3"
        onSubmit={handleSubmit}
      >
        <Input
          type="text"
          value={input}
          className="block w-full flex-1"
          placeholder="input here..."
          onChange={(e) => setInput(e.target.value)}
        />

        <Button
          className="w-20"
          disabled={isFetching || input.trim().length === 0}
        >
          Add
        </Button>
      </form>

      <MessageList data={data} />
    </Container>
  );
}

// ----------------------------------------

const queryClient = new QueryClient();

export function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <MessageView />
    </QueryClientProvider>
  );
}

(1)
import './App.css';

import React, { useState } from 'react';
import {
  QueryClient,
  QueryClientProvider,
  useQuery,
} from '@tanstack/react-query';
import { MessageAPI, Message, queryKeys } from './misc';
import { Button, Container, Input, List } from './ui';

// ----------------------------------------

function MessageList({ data }: { data: Message[] | undefined }) {
  console.log('MessageList render!');

  return (
    <List>
      {data?.map((message) => (
        <List.Item key={message.id}>{message.content}</List.Item>
      ))}
    </List>
  );
}

const MemorizedMessageList = React.memo(MessageList);

// ----------------------------------------

function MessageView() {
  const [input, setInput] = useState('');
  const { data, isFetching, refetch } = useQuery({
    queryFn: () => MessageAPI.findAll(),
    queryKey: [queryKeys.messages],
  });

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    await MessageAPI.create(input);
    setInput('');
    await refetch();
  };

  return (
    <Container className="flex h-screen flex-col pb-4 pt-2">
      <div className="flex-0 flex h-12 items-center justify-between">
        <h1 className="text-4xl font-bold">Demo</h1>

        {isFetching && <p className="text-gray-400">Loading...</p>}
      </div>

      <form
        className="flex-0 flex h-20 items-center space-x-3"
        onSubmit={handleSubmit}
      >
        <Input
          type="text"
          value={input}
          className="block w-full flex-1"
          placeholder="input here..."
          onChange={(e) => setInput(e.target.value)}
        />

        <Button
          className="w-20"
          disabled={isFetching || input.trim().length === 0}
        >
          Add
        </Button>
      </form>

      <MemorizedMessageList data={data} />
    </Container>
  );
}

// ----------------------------------------

const queryClient = new QueryClient();

export function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <MessageView />
    </QueryClientProvider>
  );
}

(2)
import './App.css';

import React, { useState } from 'react';
import {
  QueryClient,
  QueryClientProvider,
  useQuery,
} from '@tanstack/react-query';
import { MessageAPI, Message, queryKeys } from './misc';
import { Button, Container, Input, List } from './ui';

// ----------------------------------------

function MessageList({ data }: { data: Message[] | undefined }) {
  console.log('MessageList render!');

  return (
    <List>
      {data?.map((message) => (
        <List.Item key={message.id}>{message.content}</List.Item>
      ))}
    </List>
  );
}

// ----------------------------------------

type MessageViewFrameProps = React.PropsWithChildren;

function MessageViewFrame(props: MessageViewFrameProps) {
  const { children } = props;

  const [input, setInput] = useState('');
  const { isFetching, refetch } = useQuery({
    queryFn: () => MessageAPI.findAll(),
    queryKey: [queryKeys.messages],
  });

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    await MessageAPI.create(input);
    setInput('');
    await refetch();
  };

  return (
    <Container className="flex h-screen flex-col pb-4 pt-2">
      <div className="flex-0 flex h-12 items-center justify-between">
        <h1 className="text-4xl font-bold">Demo</h1>

        {isFetching && <p className="text-gray-400">Loading...</p>}
      </div>

      <form
        className="flex-0 flex h-20 items-center space-x-3"
        onSubmit={handleSubmit}
      >
        <Input
          type="text"
          value={input}
          className="block w-full flex-1"
          placeholder="input here..."
          onChange={(e) => setInput(e.target.value)}
        />

        <Button
          className="w-20"
          disabled={isFetching || input.trim().length === 0}
        >
          Add
        </Button>
      </form>

      {children}
    </Container>
  );
}

// ----------------------------------------

function MessageView() {
  const { data } = useQuery({
    queryFn: () => MessageAPI.findAll(),
    queryKey: [queryKeys.messages],
  });

  return (
    <MessageViewFrame>
      <MessageList data={data} />
    </MessageViewFrame>
  );
}

// ----------------------------------------

const queryClient = new QueryClient();

export function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <MessageView />
    </QueryClientProvider>
  );
}

(3)
import './App.css';

import React, { useState } from 'react';
import { MessageAPI, Message, createContextState } from './misc';
import { Button, Container, Input, List } from './ui';
import { useFetch } from './useFetch';

// ----------------------------------------

function MessageList({ data }: { data: Message[] | undefined }) {
  console.log('MessageList render!');

  return (
    <List>
      {data?.map((message) => (
        <List.Item key={message.id}>{message.content}</List.Item>
      ))}
    </List>
  );
}

// ----------------------------------------

type MessageViewFrameProps = React.PropsWithChildren;

function MessageViewFrame(props: MessageViewFrameProps) {
  const { children } = props;

  const [input, setInput] = useState('');
  const { isFetching, refetch } = useMessagesContext();

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    await MessageAPI.create(input);
    setInput('');
    await refetch();
  };

  return (
    <Container className="flex h-screen flex-col pb-4 pt-2">
      <div className="flex-0 flex h-12 items-center justify-between">
        <h1 className="text-4xl font-bold">Demo</h1>

        {isFetching && <p className="text-gray-400">Loading...</p>}
      </div>

      <form
        className="flex-0 flex h-20 items-center space-x-3"
        onSubmit={handleSubmit}
      >
        <Input
          type="text"
          value={input}
          className="block w-full flex-1"
          placeholder="input here..."
          onChange={(e) => setInput(e.target.value)}
        />

        <Button
          className="w-20"
          disabled={isFetching || input.trim().length === 0}
        >
          Add
        </Button>
      </form>

      {children}
    </Container>
  );
}

// ----------------------------------------

function MessageView() {
  const { data } = useMessagesContext();

  return (
    <MessageViewFrame>
      <MessageList data={data} />
    </MessageViewFrame>
  );
}

// ----------------------------------------

const [useMessagesContext, MessagesProvider] = createContextState(
  useFetch<Message[]>
);

export function App() {
  return (
    <MessagesProvider
      options={{
        fetchFn: () => MessageAPI.findAll(),
        depsKey: [],
      }}
    >
      <MessageView />
    </MessagesProvider>
  );
}

(4)
import './App.css';

import React, { useState } from 'react';
import { MessageAPI, Message } from './misc';
import { Button, Container, Input, List } from './ui';
import { useFetch } from './useFetch';

// ----------------------------------------

function MessageList({ data }: { data: Message[] | undefined }) {
  console.log('MessageList render!');

  return (
    <List>
      {data?.map((message) => (
        <List.Item key={message.id}>{message.content}</List.Item>
      ))}
    </List>
  );
}

// ----------------------------------------

type MessageViewFrameProps = React.PropsWithChildren<{
  isFetching: boolean;
  refetch: () => void;
}>;

function MessageViewFrame(props: MessageViewFrameProps) {
  const { children, isFetching, refetch } = props;

  const [input, setInput] = useState('');

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    await MessageAPI.create(input);
    setInput('');
    await refetch();
  };

  return (
    <Container className="flex h-screen flex-col pb-4 pt-2">
      <div className="flex-0 flex h-12 items-center justify-between">
        <h1 className="text-4xl font-bold">Demo</h1>

        {isFetching && <p className="text-gray-400">Loading...</p>}
      </div>

      <form
        className="flex-0 flex h-20 items-center space-x-3"
        onSubmit={handleSubmit}
      >
        <Input
          type="text"
          value={input}
          className="block w-full flex-1"
          placeholder="input here..."
          onChange={(e) => setInput(e.target.value)}
        />

        <Button
          className="w-20"
          disabled={isFetching || input.trim().length === 0}
        >
          Add
        </Button>
      </form>

      {children}
    </Container>
  );
}

// ----------------------------------------

function MessageView() {
  const { data, isFetching, refetch } = useFetch({
    fetchFn: () => MessageAPI.findAll(),
    depsKey: [],
  });

  return (
    <MessageViewFrame isFetching={isFetching} refetch={refetch}>
      <MessageList data={data} />
    </MessageViewFrame>
  );
}

// ----------------------------------------

export function App() {
  return <MessageView />;
}

Discussion

nuko_suke_devnuko_suke_dev

Reactで過剰なレンダリングを抑制したいときReact.memoはほぼ不要ですし、公式サイドもそう述べています

この話は下記のページのことですかね?一応ソースとして貼っておきますね。

https://beta.reactjs.org/reference/react/memo#should-you-add-memo-everywhere

また、「レンダリングを抑制したい典型的なユースケース」ということで今回のケースをご紹介されているので少し話が外れてしまうかもですが、今回のようなフォームとリストのケースでは useTransitionuseDeferredValue を UI の更新を中断や遅延させるのも良いかなーと思いました!