23
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

記事投稿キャンペーン 「2024年!初アウトプットをしよう」

React書き初め~useTransitionでとりあえずリストの重い再レンダリングをごまかす~

Last updated at Posted at 2024-01-02

useTransitionを使って改善したあとの様子。ボタンが「更新中」という表示になって、クリック不可能になっている

新年明けましておめでとうございます。 2024年もよろしくお願いいたします。

さて、僕の新年の用事は済ませたので、さっそくVSCodeでReactを書いて今年の書き初めとしようと思います。

こちらの記事の重い再レンダリングをReactのトランジション機能で改善できるか試してみました。仕組みは詳しく理解できておらず推測を含みますが、とにかく何とかなったので書き留めておきます。

仮想スクロールを使えば、より確実にパフォーマンス改善できそうな気がしますが、詳しくないし Reactのスケジューリング機構によるパフォーマンス改善を体感してみたかったので今回は触れません。

いつもと違って「新しい使い方を試してみた」主旨の動画なので、useTransition に対する説明が正しいとは限りません。@uhyoさんのこの記事を読んだほうが、解説は詳しいと思います。


「割当て状態を更新」したのをリアルタイムで画面全体に反映するのが重くなっているようなので、状態管理の方法を以下のように見直してみました。

  • useRef は不要
  • useTransition を子コンポーネントに配置する
    • 再レンダリングが完了するまで、対象のコンポーネントだけ古い状態にならず、isPending扱いのほうがレンダリングされる
    • ただし、ステート更新は setCategories(newValue) でなく setCategories(prev => ...) にする必要あり
  • contextは面倒臭いので不使用
    • その代わり、コンポーネントの不要なレイヤーを除去して、状態の取り扱いを単純化しました
  • 各divisionの名称変更は、元記事で十分最適化されているので省略しています
  • 変更された「category->divisionの割当て」状態を送信する機能についても同様に省略しています

https://ja.react.dev/reference/react/useTransition#updating-an-input-in-a-transition-doesnt-work にあるアンチパターン (制御コンポーネントの value, onChange での更新にトランジションを使う) と似てるけど、これは問題ない使い方...だよね?

改善後の動画

結果として、以下のように良い感じになりました

トランジション不使用での動画

ディレクトリ構造およびファイル一覧

ディレクトリ構造およびファイル一覧: コロケーション原則に従って分けています。

  • divisions/
    • DivisionsContainer.tsx
      • 機能のメインになるコンポーネント
      • 複数のdivision(ピッキング分類)について、各category(カテゴリ)の割当て状態を表示する
    • CategoryInDivision.tsx
      • ピッキング分類の中にある、カテゴリの一覧のうち一つの項目を表示するコンポーネント
      • ここでuseTransitionを使う
    • useCategories.ts
      • カテゴリの一覧データを取得
      • カテゴリーの、ピッキング分類への割当て状態を管理
    • useDivisions.ts
      • ピッキング分類の一覧データを取得
  • repositories/
    • categories.ts
      • カテゴリの一覧の、モックデータおよび非同期な取得関数を返す
    • divisions.ts
      • ピッキング分類の一覧の、モックデータおよび非同期な取得関数を返す
  • utils/
    • FetchingResult.type.ts
      • データの取得中・取得成功の状態を表す型
  • App.tsx

各ファイルに沿って見ていく

以下、各ファイルおよび簡単な補足を追加しています。

(利用技術概要)

  • vite 5.0.0
  • react 18.2.0
  • typescript: 5.2.2
  • @chakra-ui/react 2.8.2

src/divisions/DivisionsContainer.tsx

機能全体のエントリーポイント的なコンポーネントです。

categories が divisions にぶら下がっているのではなく、直交する関係(?)なので、コンポーネントの階層を分ける必要がない(むしろ無駄な階層を除去することで見通しが良くなる)と考えて、このコンポーネントにかなり詰め込んでいます。

src/divisions/DivisionsContainer.tsx
import { type FC } from "react";
import {
  Accordion,
  AccordionButton,
  AccordionIcon,
  AccordionItem,
  AccordionPanel,
  Box,
  Container,
  VStack,
} from "@chakra-ui/react";

import { useDivisions } from "./useDivisions";
import { useCategories } from "./useCategories";
import { CategoryInDivision } from "./CategoryInDivision";

/**
 * 複数のピッキング分類について、各カテゴリのアサイン状態を表示する
 */
export const DivisionsContainer: FC = () => {
  const { divisions } = useDivisions();
  const { categories, assign } = useCategories();

  if (divisions.type !== "done") return null;
  if (categories.type !== "done") return null;

  const getDivision = (divisionId: string) => {
    return divisions.data.find((d) => d.id === divisionId);
  };

  return (
    <Container>
      <Accordion allowMultiple>
        {divisions.data.map((division) => (
          <AccordionItem key={division.id}>
            <AccordionButton
              sx={{
                display: "grid",
                gridTemplateColumns: "auto max-content",
                textAlign: "start",
              }}
            >
              <Box>{division.name}</Box>
              <AccordionIcon />
            </AccordionButton>

            <AccordionPanel>
              <VStack align="stretch">
                {categories.data.map((category) => {
                  const assignedTo = category.divisionId;
                  return (
                    <CategoryInDivision
                      key={category.id}
                      name={category.name}
                      status={
                        assignedTo == null
                          ? { type: "not-assigned" }
                          : assignedTo === division.id
                            ? { type: "this-division" }
                            : {
                                type: "other-division",
                                divisionName: getDivision(assignedTo)!.name,
                              }
                      }
                      onAssign={() => {
                        assign(category.id, division.id);
                      }}
                      onUnassign={() => {
                        assign(category.id, undefined);
                      }}
                    />
                  );
                })}
              </VStack>
            </AccordionPanel>
          </AccordionItem>
        ))}
      </Accordion>
    </Container>
  );
};

src/divisions/CategoryInDivision.tsx

handleAssignが実行されたときにonAssignを実行すると、(親であるDivisionsContainerを経由して)useCategory内で宣言されたステートを更新しますが、トランジション不使用であれば、その更新による重い再レンダリングの間、更新前の状態のままの表示になってしまいます。

startTransition で囲っているので、その更新が完了するまでの間、 イベントが発生したCategoryInDivisionだけ isPending === true の状態でレンダリングされます。

ほかの箇所のCategoryInDivisionは対象外なので、そのため、他の division の下に同じ category を表示している箇所は、トランジションなしと同じように、「古い状態のまま」で再レンダリングの完了まで待つことになりますが、それは 画面外なのでUXに影響がありません

クリックが発生した箇所のCategoryInDivisionのみ、再レンダリングが完了されるまでの間、ボタンが「更新中」の状態になって、完了すると晴れて新しい状態で表示されます。

その「更新中」の状態であっても、他のカテゴリのボタンを操作することは可能です。ただし、その操作の状態を正確に反映するためには、 setCategories(newValue) でなく setCategories(prev => ...) にする必要があります。

前者だと、カテゴリAの更新ボタンを押して「更新中」の間にカテゴリBのボタンを押したとき、Aに対する更新が、Bボタン押下による更新によって上書きされてしまい、無かったことになります。

「どちらか一方だけが正しい」のではなく、トランジションが絡んだ場合に、そのトランジションの使い方によって、両者を使い分ける必要がありそうです。

src/divisions/CategoryInDivision.tsx
import { type FC, useTransition } from "react";

import { Box, Button, type ButtonProps } from "@chakra-ui/react";

/**
 * - not-assigned: ピッキング分類未指定
 * - this-division: 現在のピッキング分類に設定ずみ
 * - other-division: 他のピッキング分類に設定ずみ
 */
type Status =
  | {
      type: "not-assigned";
    }
  | {
      type: "this-division";
    }
  | {
      type: "other-division";
      divisionName: string;
    };

type Props = {
  name: string;
  status: Status;

  onAssign: () => void;
  onUnassign: () => void;
};

/**
 * ピッキング分類の中で一覧表示されるカテゴリの一つを表示するコンポーネント。
 */
export const CategoryInDivision: FC<Props> = ({
  name,
  status,
  onAssign,
  onUnassign,
}) => {
  const [isPending, startTransition] = useTransition();

  // Chakra UIのButtonに「isPending のときにクリック不能にする」のにちょうどいい
  // 機能があったので、それを使うために共通のPropsとしてスプレッド構文で渡す
  const buttonProps: Partial<ButtonProps> = {
    isLoading: isPending,
    loadingText: "更新中",
  };

  const handleAssign = () => {
    startTransition(() => {
      onAssign();
    });
  };

  const handleUnassign = () => {
    startTransition(() => {
      onUnassign();
    });
  };

  return (
    <Box
      display="grid"
      gridTemplateColumns="auto max-content"
      alignItems="center"
    >
      {/* 商品カテゴリの名前 */}
      <Box>{name}</Box>

      {/* 更新ボタン */}
      {status.type === "not-assigned" ? (
        <Button
          {...buttonProps}
          onClick={handleAssign}
          variant="solid"
          colorScheme="teal"
        >
          設定する
        </Button>
      ) : status.type === "this-division" ? (
        <Button {...buttonProps} onClick={handleUnassign}>
          解除する
        </Button>
      ) : (
        <Button {...buttonProps} disabled>
          設定済み: {status.divisionName}
        </Button>
      )}
    </Box>
  );
};

startTransition(() => ..) の中で onAssign を呼び出しているのは、密結合かもしれません。

startTransition の中では「同期的にset関数を呼び出す」必要があるので、親側の、onAssign 等のイベントハンドラで不適切な記述(非同期に呼び出す等)をした場合、トランジションが効かない可能性があります。

https://ja.react.dev/reference/react/useTransition#react-doesnt-treat-my-state-update-as-a-transition

src/divisions/useCategories.ts

src/divisions/useCategories.ts
import { useCallback, useEffect, useState } from "react";

import { type FetchingResult } from "../utils/FetchingResult.type";
import { fetchCategories, type Category } from "../repositories/categories";

type UseCategoriesReturns = {
  categories: FetchingResult<Category[]>;
  assign: (categoryId: string, divisionId: string | undefined) => void;
};

/**
 * 商品カテゴリたちと、ピッキング分類への割当状態を保持する。
 *
 * - マウント時に一覧データを取得する。
 * - `assign()` 実行時に、categories ステートを更新する。
 */
export const useCategories = (): UseCategoriesReturns => {
  const [categories, setCategories] = useState<
    UseCategoriesReturns["categories"]
  >({
    type: "pending",
  });

  useEffect(() => {
    let ignore = false;
    fetchCategories().then((data): void => {
      if (ignore) return;
      setCategories({ type: "done", data });
    });
    return () => {
      ignore = true;
    };
  }, []);

  const assign: UseCategoriesReturns["assign"] = useCallback(
    (categoryId, divisionId) => {
      setCategories((prev) => {
        if (prev.type !== "done") return prev;

        const newCategories = prev.data.map((category) => {
          if (category.id === categoryId) return { ...category, divisionId };
          return category;
        });
        return { type: "done", data: newCategories };
      });
    },
    [],
  );

  return { categories, assign };
};

src/divisions/useDivisions.ts

src/divisions/useDivisions.ts
import { useEffect, useState } from "react";

import { type FetchingResult } from "../utils/FetchingResult.type";
import { type Division, fetchDivisions } from "../repositories/divisions";

type UseDivisionsReturns = {
  divisions: FetchingResult<Division[]>;
};

/**
 * ピッキング分類を取得
 */
export const useDivisions = (): UseDivisionsReturns => {
  const [divisions, setDivisions] = useState<UseDivisionsReturns["divisions"]>({
    type: "pending",
  });

  useEffect(() => {
    let ignore = false;
    fetchDivisions().then((data): void => {
      if (ignore) return;
      setDivisions({ type: "done", data });
    });
    return () => {
      ignore = true;
    };
  }, []);

  return { divisions };
};

src/repositories/ 以下のファイル

src/repositories/categories.ts
/**
 * 商品カテゴリのデータ型
 */
export type Category = {
  id: string;
  name: string;
  divisionId: string | undefined;
};

function* generateRandomCategories(size: number): Generator<Category> {
  for (let i = 0; i < size; i++) {
    const padded = i.toFixed().padStart(4, "0");
    yield { id: padded, name: `カテゴリ${padded}`, divisionId: undefined };
  }
}

const seedCategories = Array.from(generateRandomCategories(1000));

/**
 * 商品カテゴリ一覧を取得する非同期関数
 */
export const fetchCategories = (): Promise<Category[]> => {
  return new Promise((resolve) => {
    resolve(seedCategories);
  });
};

src/repositories/divisions.tsx
/**
 * ピッキング分類のデータ型
 */
export type Division = {
  id: string;
  name: string;
};

/**
 * ピッキング分類の一覧データを取得
 */
export const fetchDivisions = (): Promise<Division[]> => {
  return new Promise((resolve) => {
    resolve([
      { id: "01", name: "冷蔵温度帯" },
      { id: "02", name: "冷凍温度帯" },
      { id: "03", name: "常温温度帯" },
    ]);
  });
};

utils/FetchingResult.type.ts

src/utils/FetchingResult.type.ts
/**
 * フェッチ中・完了・エラーの状態
 */
export type FetchingResult<T> =
  | {
      type: "done";
      data: T;
    }
  | {
      type: "pending";
    }
  | { type: "error"; error: unknown };

まとめ

Reactの備えている「重いレンダリング」対策機能であるuseTransitionを使うと、UXの悪化をマシにすることが叶いました!

しかも、素直なステート更新に少しのコードを書くだけで、大きなコンポーネント構造の変更が不要でした!

useTransitionには、やはりインタラクションが絡む色んな使い方が見つかりそうなので、腕に覚えのある人は個人のコード等で試してQiitaやZennやXで共有してみましょう!


実は既にApp RouterでのLinkコンポーネントでは画面遷移を実装するのに使われてるんですけどね

23
22
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
23
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?