この記事は、AEON Advent Calendar 2023の24日目です🎉
さらに改善いただいた記事
@honey32 さんに更に改善していただきました
useTransition
のHookを利用してレンダリングと実際の更新タイミングをズラすことで、UIに触れない時間を限りなくゼロにしています
以下の記事の内容にありますuseRef
のアンチパターンにも抵触しないので、より素晴らしい改善を実施いただきました!
ネットスーパーの管理画面を改善したい
はじめまして!
イオンリテールという、AEONグループの総合小売業の企業で働く渡邊と申します。
総合小売業とは衣・食・住・H&BCに各テナントを合わせた広い領域を扱う小売業となります。そこで現在私はネットスーパーの新規事業検証に関する部署におり、本記事ではそこの現場作業に必要なデータを登録するための管理画面の不満と改善点を記事にしてみました!!
実際に改善活動を実行できたわけではなく、まあおそらくこうだよねというところの机上論的ではありますが、現場も苦労しているんだよということでお読みいただけると嬉しいです。
データを登録する管理画面が異常に重い
まずはこちらの映像をご覧ください
お分かりいただけただろうか
こちらはReactで作られたネットスーパーのピッキング分類の登録管理画面となります。具体的には温度帯や商品が存在する階層単位で分類を登録することで、店舗の商品をピッキングする際のルート最適化に活用するためのデータ登録画面となります。
現在部署として進めているデジタル端末によるネットスーパーピッキングシステムを店舗に導入する際には、必ずこの画面から登録を行わなければなりません。店舗数は約270店舗あり、月に数店舗ずつ導入を進めていく都合上、それなりの頻度でこのページを触るのです。
なぜこんなことが起きているのか
しかしながら、項目名を一文字変えるだけで画面全体が再レンダリング。チェックボックスを一つクリックするだけでも再レンダリング。普通であればこのようなコンポーネントのレンダリングで重くなることはありませんが、会社の商品カテゴリは800程度、その下にあるサブカテゴリが各カテゴリに5~8個程度あるため、一つのアコーディオン形式のコンポーネント内に4~7,000程度のチェックボックスが存在します。
さらに、それがピッキング分類の数だけ存在するので、数万程度のチェックボックスと文字列の再レンダリングが一度の動作で発生しているのです。
補足|Reactと再レンダリングのタイミング
Reactにおける再レンダリングのタイミングは、状態変数(以下、state)と呼ばれる変数が変更されたタイミングとなります。Reactはこのstateを管理することで画面の状態を管理し、ユーザーに適切な画面を表示することを実行しています。
Inputタグやチェックボックスの値をstateとして管理し、この入力が変わったタイミングで画面を再レンダリングすること自体は正しいのですが、このページの場合はおそらく全て単一のglobal stateで管理されているので一つが変わると全部が変わるという現象が発生しているのです
どうすれば良いか
残念ながら私にこのページのコードを編集する権限はありません。ですが、こうすれば軽くなるよという案がありますので、それを記事にしてみました。
方針としては、データベースからフェッチしたたものをそのまま使っていると思われるstate(今回はuseContextでcontext valueとして保有する形にします)を直接更新するのではなく、更新タイミングをずらすことでレンダリング範囲を限定することで動作を軽くします。
補足|memo化ではだめなのか?
基本的に今回の事例はmemo化では対応が難しいと考えています。
一度計算した値や関数をキャッシュすることで、差分が発生していないことを明確に伝える機能が下の3つ
React.memo()
useMemo()
useCallback()
となりますが、global stateを直接更新するという手段をとる時点で、stateの更新が発生してしまうため、memo化したところで更新差分が発生してしまい、再レンダリングにつながってしまいます。本質的にはコンポーネントごとに渡すpropsをコントロールすることが必要というわけです。
実際の改善例
擬似的に再現したアプリケーションの最終的なGitHubは以下のページです。データベースに対するフェッチはsetTimeOutやレンダリングの負荷を向上させることで擬似的に再現しています。
main
ブランチが一通り全てを解決したパターン。v0.0.2
という謎のバージョンのタグが、Inputタグの部分だけを軽量化したパターンとなります。
実行環境
srcフォルダの構成
/project-root
│
├── /Hooks
│ └── ... (今回はトーストを表示するカスタムHookのロジック)
│
├── /assets
│ └── ... (使ってない。めんどくさいからそのまま残ってる)
│
├── /components
│ └── ... (ベースとなるUIのコンポーネント類)
│
├── /contexts
│ └── ... (データベースから取得した想定のデータと、その更新ロジックをuseReducerで記述)
│
├── /utils
│ └── ... (データベースを想定したデータを生成。addIndexはめんどくなって使わなかった)
│
├── App.tsx
├── main.tsx
└── vite-env.d.ts
package.json
{
"name": "app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@chakra-ui/react": "^2.8.2",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"framer-motion": "^10.16.16",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"typescript": "^5.2.2",
"vite": "^5.0.8"
}
}
- UIはChakraUI
楽だから
- ランタイムとしてBun
もうNode.jsには戻れない
- ホスティングはNetlify
Inputの軽量化
ある程度挙動を再現するためにそれなりに重いですのでご注意ください
冷蔵温度帯、・・・などピッキング領域の名前を記入するInputの部分を触っても、ページ全体が再レンダリングされないようにしています。
やり方としてはsrc/atoms/IndexField.tsx
コンポーネントにあるように、global contextの値をコンポーネントのstate初期値として渡し、直接global stateが更新されないようにすることで、レンダリング範囲をInput領域に限定しています。
InputField.tsx
import { Input, Text, Box } from "@chakra-ui/react";
import { useState } from "react";
interface InputFieldProps {
id: string;
defaultValue: string;
}
export const InputField: React.FC<InputFieldProps> = ({ id, defaultValue }) => {
const [inputValue, setInputValue] = useState(defaultValue);
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
return (
<Box as="span" flex='1' textAlign='left' w='50%'>
<Text>{id}</Text>
<Input value={inputValue} onChange={onChange} />
</Box>
);
};
global stateの更新はいつするのか?それは後のお楽しみです
チェックボックスの軽量化
ある程度挙動を再現するために同じく重いですのでご注意ください
先ほどのwebアプリケーションと見た目は似ていますが異なる挙動をとります。
やり方自体は先ほど同じです。見た目としてわかりやすくすることと、レンダリング自体は発生していることを示すためにチェックボックスではなくボタンのコンポーネントに切り替えています。
global contextにあるカテゴリの属するピッキング分類データをボタンにpropsとして渡し、それを各ボタンのstate初期値として設定することで、同じくglobal contextを直接更新することを避けています。
ただし、この方法を取るとglobal context自体が更新されないため、ピッキングルート間の相互作用が発生しなくなります。具体的にはある温度帯で選択されたボタンは他の温度帯では選択できなくなるという機能が実装できなくなります。
重すぎて動かないよりはマシかなというところでトレードオフですね。もし実装方法わかる方いらしたら教えてください!!
データベースの更新
さて、ここまでそれぞれのコンポーネント自体にstateを持たせて、再レンダリング範囲を抑えるという機能を実装してきましたが、この更新差分をデータベースに反映できなければ意味がありません。ですので、最終的にはglobal contextの差分を更新し、データベースにPUTするという機能を実装していきます。
Reactには値が変わっても再レンダリングを引き起こさない機能がありますね。
そう、useRef
を使っていきます。
この方法は明らかにReact useRefのアンチパターンですので、本来であればページの構成を変える(例えば、表示する商品カテゴリを絞り込む機能をつける)ことが望ましいのですが、今回はページ自体変更せずに軽量化するという前提で実装を進めています
下の図のようにuseRefで定義したcategoryUpdateRef
というReact.MutableRefObject
を子コンポーネントまで渡して、変更履歴を記録。データ更新の際に変更履歴をglobal stateに反映するという手順を踏むことで、global stateの更新タイミングを限定し、再レンダリングのタイミングをコントロールしています。
下図のコンソールにあるように、useRef.currentの中にオブジェクトの形で更新差分を記録していきます
ボタンを押すとカテゴリ名をkey、属するピッキング属性をvalueとするオブジェクトとして記録される
そして、更新差分は<RegisterButton />
コンポーネントで表現される登録するボタンを押したタイミングでglobal stateに反映させています。
以下のロジックを記載している実際のコンポーネントは以下の通りとなります。面倒だったので、CategoryContextの更新ロジックしか書いていませんが、DivisionContextも同じように記述すれば問題ありません。
DataContext.tsx
global contextとその更新ロジックをReducerに記述
import { ReactNode, createContext, useReducer, } from "react";
import { CategoryAction, CategoryDataType, DataContextType, DivisionAction, DivisionDataType } from "./types";
import { categoryData, divisionData } from "../utils/data";
interface DataProviderProps {
children: ReactNode;
}
export const DataContext = createContext<DataContextType | null>(null);
export const DataProvider: React.FC<DataProviderProps> = ({ children }) => {
const [categories, CategoryDispatch] = useReducer(categoryReducer, {'読み込み中': 0});
const [divisions, DivisionDispatch] = useReducer(divisionReducer, {0: '読み込み中'});
const ContextValue = { categories, CategoryDispatch, divisions, DivisionDispatch };
return (
<DataContext.Provider value={ContextValue}>
{children}
</DataContext.Provider>
)
}
const categoryReducer = (state: CategoryDataType | null, action: CategoryAction): CategoryDataType => {
switch (action.type) {
case 'checked':
return {
...state,
[action.category]: action.selectedDivision
};
case 'fetch':
return categoryData;
case 'unmount':
return {'読み込み中': 0};
default:
throw new Error('undefined action')
}
}
const divisionReducer = (state: DivisionDataType | null, action: DivisionAction): DivisionDataType => {
switch (action.type) {
case 'rename':
return {...state};
case 'fetch':
return divisionData;
case 'unmount':
return {0: '読み込み中'};
default:
throw new Error('undefined action')
}
}
AccordionCell.tsx
useRef.currrentの更新ロジックを記述
import { AccordionPanel, Box, Button, HStack, Spacer, Text } from "@chakra-ui/react";
import { useState } from "react";
interface AccordionCellProps {
category: string;
divId: string;
selectedDivision: number;
categoryUpdateRef: React.MutableRefObject<{[key: string]: number} | null>;
}
export const AccordionCell: React.FC<AccordionCellProps> = ({
category,
divId,
selectedDivision,
categoryUpdateRef,
}) => {
return (
<Box
outline='0.5px solid'
outlineColor='gray.200'
>
<AccordionPanel p={4}>
<HStack>
<Text>{category}</Text>
<Spacer />
<DivSetButton
category={category}
divId={divId}
selectedDivision={selectedDivision}
categoryUpdateRef={categoryUpdateRef}
/>
<Spacer />
</HStack>
</AccordionPanel>
</Box>
)
}
interface DivSetButtonProps {
category: string;
divId: string;
selectedDivision: number;
categoryUpdateRef: React.MutableRefObject<{[key: string]: number} | null>;
}
const DivSetButton: React.FC<DivSetButtonProps> = ({
category,
divId,
selectedDivision,
categoryUpdateRef
}) => {
const [divState, setDivState] = useState(selectedDivision);
const result = (divId: string,divisionState: string): 'notSetteled' | 'thisCategory' | 'otherCategory' => {
switch (divisionState) {
case '0':
return 'notSetteled'
case divId:
return 'thisCategory'
default:
return 'otherCategory'
}
};
const res = result(divId, divState.toString())
const isDisabled = () => {
switch(res) {
case 'otherCategory':
return true
default:
return false
}
};
const buttonString = () => {
switch(res) {
case 'notSetteled':
return '設定する'
case 'thisCategory':
return '解除する'
case 'otherCategory':
return `設定済み: ${divState.toString()}`
default:
throw Error('予期せぬ文字列です')
}
}
const setColorScheme = () => {
switch (res) {
case 'notSetteled':
return 'blue'
default:
return 'gray'
}
}
const onClick = () => {
switch (res) {
case 'thisCategory':
setDivState(0);
categoryUpdateRef.current = {
...categoryUpdateRef.current,
[category]: 0,
}
console.log(categoryUpdateRef.current);
break
case 'notSetteled':
setDivState(Number(divId));
categoryUpdateRef.current = {
...categoryUpdateRef.current,
[category]: Number(divId),
}
console.log(categoryUpdateRef.current);
break
default:
return
}
}
return (
<Button
w={200}
isDisabled={isDisabled()}
colorScheme={setColorScheme()}
onClick={onClick}
>
{buttonString()}
</Button>
)
}
ResisterButton.tsx
登録のロジックと画面更新を記述
import { Box, Button, Spinner } from "@chakra-ui/react";
import { useCommonToast } from "../../Hooks/useCommonToast"
import { startTransition, useEffect, useState } from "react";
import { useDataContext } from "../../contexts/useDataContext";
interface RegisterButtonProps {
categoryUpdateRef: React.MutableRefObject<{[key: string]: number} | null>
}
export const RegisterButton: React.FC<RegisterButtonProps> = ({categoryUpdateRef}) => {
const { categories, CategoryDispatch } = useDataContext();
const showToast = useCommonToast();
const [isFetching, setIsFetching] = useState(false);
const onClickButton = () => {
if (categoryUpdateRef.current){
setIsFetching(true)
startTransition(() => {
if (!categoryUpdateRef.current) {
return
}
Object.entries(categoryUpdateRef.current).forEach(([category, selectedDivision]) => {
CategoryDispatch({
type: 'checked',
category: category,
selectedDivision: selectedDivision,
})
})
console.log(categories);
setIsFetching(false);
})
} else{
showToast({
title: 'info',
description: '変更がありませんでした',
status: 'info'
})
setIsFetching(false);
}
}
useEffect(() => {
if(!isFetching && categoryUpdateRef.current) {
showToast({
title: '実行完了',
description: '正常に登録されました'
});
window.location.reload();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isFetching])
return (
<Box>
<Button
colorScheme='blue'
onClick={onClickButton}
isDisabled={isFetching}
w={200}
>
{isFetching ? <Spinner /> : '登録'}
</Button>
</Box>
)
}
<ResisterButton />
コンポーネントの中で、startTranstionのHookを利用して、データフェッチと画面更新のタイミングをコントロールして、「現在はフェッチしていますよ!」ということが直感的に伝わるようにしているところもポイントでしょうか。
この事例は氷山の一角
今回は視覚的にわかりやすく私もそれなりに特異な分野だったのでこのような記事を書きましたが、このような技術的負債は会社のいたるところに存在しています。
可能であれば私自身で解決したい。でも私には触る権限がない。けど触りたい。
どうしてもだめなのであれば・・・
記事を読んだ方、幣グループは絶賛採用中とのことです
現場からもどんどん改善案を上げていくので、一緒に組織を変えていきましょう!
小売業がよくなれば日本が良くなると信じている
- よくやめないね
- 転職したら?
- そんなに技術あるんだったらもっと稼げるでしょ
死ぬほど言われます。それでも私は小売業にこだわります。
それは、小売業がよくなれば日本が良くなると信じており、私自身が小売業に救われたからです。
日本最大規模だからこそ難しいところもあるし、うんざりすることの方が多いです。
それでもきっと日本のためになると信じて、これからも頑張っていきます!