環境
言語
TypeScript v4.4.2
React
React v17.0.2
僕のアプリ、レンダリング多くね???
例えば下記のようなカウンターアプリがあったとする。
// App.tsx
import React, { useState } from 'react';
import './App.css';
type IncrementProps = {
onIncrement: () => void
}
// 加算ボタン
const Increment: React.FC<IncrementProps> = ({onIncrement}) => {
console.log('Increment');
return (
<button onClick={onIncrement}>Increment</button>
)
}
type DecrementProps = {
onDecrement: () => void
}
// 減算ボタン
const Decrement: React.FC<DecrementProps> = ({onDecrement}) => {
console.log('Decrement');
return (
<button onClick={onDecrement}>Decrement</button>
)
}
type CounterProps = {
count: number
onIncrement: () => void
onDecrement: () => void
}
// カウンター
const Counter: React.FC<CounterProps> = ({count, onIncrement, onDecrement}) => {
console.log('Counter');
return (
<div className='counter-container'>
<h2>{count}</h2>
<div className='counter-actions'>
<Increment onIncrement={onIncrement} />
<Decrement onDecrement={onDecrement} />
</div>
</div>
)
}
// 一番親のコンポーネント
const App: React.FC = () => {
console.log('App');
const [count, setCount] = useState<number>(0)
const onIncrement = () => {
setCount(count + 1)
}
const onDecrement = () => {
setCount(count - 1)
}
return (
<div className="app-container">
<h1>My Counter</h1>
<Counter count={count} onIncrement={onIncrement} onDecrement={onDecrement} />
</div>
);
}
export default App;
この状態でIncrementやDecrementをすると下記のように全てのコンポーネントがレンダリングされる。
AppとCounterがレンダリングされるのはまだ分かるが、IncrementボタンとDecrementボタンが同時にレンダリングされるのは由々しき事態である。
React.memo、useCallback、useReducerを使おう!
結論から言うと、React.memoでPropsの状態を管理、useCallbackで関数の状態を管理、useReducerで加減算を管理すると無駄なレンダリングを回避できます。
何故Incrementボタンを押すとDecrementボタンもレンダリングされるかと言うと、useStateで管理しているcount
の値が更新されるのが原因です。
IncrementボタンやDecrementボタンを押すと、setCount
でcount
の値を更新しているので、新しいonIncrement関数とonDecrement関数が生成されているのです。
// 一番親のコンポーネント
const App: React.FC = () => {
console.log('App');
const [count, setCount] = useState<number>(0)
const onIncrement = () => {
// ここが良くない
setCount(count + 1)
}
const onDecrement = () => {
// ここが良くない
setCount(count - 1)
}
return (
<div className="app-container">
<h1>My Counter</h1>
<Counter count={count} onIncrement={onIncrement} onDecrement={onDecrement} />
</div>
);
}
useReducerを導入
useReducerを使うと、加減算をコンポーネントの外で行うことが出来るので、今回の問題を解決できます。
コードは以下のようになります。
// reducer.ts
// *** actions type *******************************************************
export const INCREMENT = "INCREMENT" as const;
export const DECREMENT = "DECREMENT" as const;
type IncrementActionType = {
type: typeof INCREMENT;
payload: {
addCount: number;
};
};
type DecrementActionType = {
type: typeof DECREMENT;
payload: {
subtractionCount: number;
};
};
export type CounterActionType = IncrementActionType | DecrementActionType;
// *** actions ************************************************************
export const IncrementAction = (addCount: number): CounterActionType => ({
type: INCREMENT,
payload: {
addCount,
},
});
export const DecrementAction = (subtractionCount: number): CounterActionType => ({
type: DECREMENT,
payload: {
subtractionCount,
},
});
// *** reducer ************************************************************
type State = {
count: number;
};
export const initialState: State = {
count: 0,
};
export const counterReducer = (
state = initialState,
action: CounterActionType
): State => {
switch (action.type) {
case INCREMENT: {
const { addCount } = action.payload;
return { ...state, count: state.count + addCount };
}
case DECREMENT: {
const { subtractionCount } = action.payload;
return { ...state, count: state.count - subtractionCount };
}
}
};
// App.tsx 変更分
import React, { useReducer } from 'react';
import './App.css';
import {counterReducer, initialState, INCREMENT, DECREMENT} from './reducer'
/**
* 省略
*/
// 一番親のコンポーネント
const App: React.FC = () => {
console.log('App');
const [state, dispatch] = useReducer(counterReducer, initialState)
const onIncrement = () => {
dispatch({ type: INCREMENT, payload: { addCount: 1 } })
}
const onDecrement = () => {
dispatch({ type: DECREMENT, payload: { subtractionCount: 1 } })
}
return (
<div className="app-container">
<h1>My Counter</h1>
<Counter count={state.count} onIncrement={onIncrement} onDecrement={onDecrement} />
</div>
);
}
これでonIncementとonDecrementの関数は常に同じになったので無駄なレンダリングが発生しないはず!!!
...と思ったら大間違いです。
確かに関数は常に同じになりましたが、ボタンを押す度にuseReducerのstateが更新されるので、Appコンポーネントがレンダリングされ、その都度onIncrement関数とonDecrement関数は新たに生成されてしまい、無駄なレンダリングが発生してしまうのです。
なんてこった...😇
React.memoとuseCallbackを導入
そこで登場するのがReact.memoとuseCallbackです!
React.memoはコンポーネントのpropsの状態を管理してくれていて、状態に変化がなければレンダリングをスキップしてくれます。
useCallbackは関数の状態を管理してくれていて、関数に変化がなければ再生成せず、キャッシュしてる状態を渡してくれます。
要するに、何の変化が無ければレンダリングをスキップしてくれる優れもの!(但し、キャッシュを返すか否かをその都度再計算しているので、無闇やたらに使えば良いという訳では無い。場合によっては再計算がネックになる。)
コードは以下のようになります。
// App.tsx
/**
* 省略
*/
// 加算ボタン
const Increment: React.FC<IncrementProps> = React.memo(({onIncrement}) => {
console.log('Increment');
return (
<button onClick={onIncrement}>Increment</button>
)
})
/**
* 省略
*/
// 減算ボタン
const Decrement: React.FC<DecrementProps> = React.memo(({onDecrement}) => {
console.log('Decrement');
return (
<button onClick={onDecrement}>Decrement</button>
)
})
/**
* 省略
*/
// 一番親のコンポーネント
const App: React.FC = () => {
console.log('App');
const [state, dispatch] = useReducer(counterReducer, initialState)
const onIncrement = useCallback(() => {
dispatch({ type: INCREMENT, payload: { addCount: 1 } })
},[])
const onDecrement = useCallback(() => {
dispatch({ type: DECREMENT, payload: { subtractionCount: 1 } })
},[])
return (
<div className="app-container">
<h1>My Counter</h1>
<Counter count={state.count} onIncrement={onIncrement} onDecrement={onDecrement} />
</div>
);
}
これで無駄なレンダリングを防ぐことが出来ました!🙌
しかし待ってください...よく考えたらAppコンポーネントがレンダリングされるのはおかしいのでは???
何でもかんでも親から渡さない
僕は再利用性を高めようと、何でもかんでも親から渡すことをよくやりがちなのですが、不要なstate upは止めた方が良いです。
今回のカウンター機能はAppコンポーネントではなく、Counterコンポーネントで行うのが適切です。
コードは以下のようになります。(完成形なので、App.tsxを全てのせます)
import React, { useReducer, useCallback } from 'react';
import './App.css';
import {counterReducer, initialState, INCREMENT, DECREMENT} from './reducer'
type IncrementProps = {
onIncrement: () => void
}
// 加算ボタン
const Increment: React.FC<IncrementProps> = React.memo(({onIncrement}) => {
console.log('Increment');
return (
<button onClick={onIncrement}>Increment</button>
)
})
type DecrementProps = {
onDecrement: () => void
}
// 減算ボタン
const Decrement: React.FC<DecrementProps> = React.memo(({onDecrement}) => {
console.log('Decrement');
return (
<button onClick={onDecrement}>Decrement</button>
)
})
// カウンター
const Counter: React.FC = () => {
console.log('Counter');
const [state, dispatch] = useReducer(counterReducer, initialState)
const onIncrement = useCallback(() => {
dispatch({ type: INCREMENT, payload: { addCount: 1 } })
},[])
const onDecrement = useCallback(() => {
dispatch({ type: DECREMENT, payload: { subtractionCount: 1 } })
},[])
return (
<div className='counter-container'>
<h2>{state.count}</h2>
<div className='counter-actions'>
<Increment onIncrement={onIncrement} />
<Decrement onDecrement={onDecrement} />
</div>
</div>
)
}
// 一番親のコンポーネント
const App: React.FC = () => {
console.log('App');
return (
<div className="app-container">
<h1>My Counter</h1>
<Counter />
</div>
);
}
export default App;
これでAppコンポーネントのレンダリングがされなくなり、Counterコンポーネントだけがレンダリングされるようになりました!
パフォーマンスの最適化完了です!😎
あとがき
パフォーマンスを意識してコードが書けるようになると、React使ってる感が出てきて気分上がりますよね😎
やはりReactは楽しい...!!!
今回のアプリをローカルで動かしたい方は僕のGitHubからクローンしてください。
(いつまであるかは分かりませんが...)
他にもパフォーマンスを最適化するのにuseMemo
というReact Hooksがありますが、これもまた今度書こうと思います🙆♂️