useStateの問題点
-
本来別々にするべき以下の3つの機能が統合されてしまっている
-
state作成機能
stateの空間を確保し、必要なら初期値を設定する -
state変更機能
stateの値を変更し通知を行う -
state取得機能
stateの変更を検知し、値を受け取る
-
-
3つ機能が統合されていることによる弊害
- stateを作成したコンポーネントは、必ずコンポーネントの再評価に巻き込まれる
親コンポーネントで作成したstateを、自分自身では使用せず子に配るだけのような場合も、親がstate変更の通知を受け取ってしまう - 再評価の影響範囲を最小化する場合、上位のコンポーネントでstateが作成できない
上位コンポーネントで作成したstateを変更すると、stateと無関係の子コンポーネントまで影響を受ける。親に影響を与えず子コンポーネント間でstateを共有したい場合、その処理をすっきり書くことが出来ない
もしやろうとすると、通知を受け取るコンポーネントでuseStateをした後に、そこで生成したdispatch(setState的なもの)を親コンポーネントが吸い上げて配り直さなければならない
- stateを作成したコンポーネントは、必ずコンポーネントの再評価に巻き込まれる
理想的な動作
-
3つの機能が分離している状態
- stateを作成し、初期値を設定するだけのhook
あくまで領域を確保することだけを目的とし、このhookを持っているコンポーネントはstateの変更通知をうけとらない
これなら親でstateを作成しても、親が再評価に巻き込まれない - stateの変更通知を受け取り、コンポーネントの再評価を行うhook
受け取りたいstateを指定しておけば、コンポーネントで対象の通知のみ受け取ることが出来る
ReduxのuseSelectorのような動作が理想 - stateを変更して通知を送り、自分自身は通知を受け取らない関数
stateの変更のみに特化した関数を別枠で用意する
- stateを作成し、初期値を設定するだけのhook
ぼくのかんがえたさいきょうのstate管理
作成したコード
構想30分、実装60分ぐらいで動くものを作り、その後ある程度修正したコードです
作ったものはnpmに放り込んだので、すぐ使えるようになっています
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useCallback, useEffect, useRef, useState } from 'react';
/**
* Type for State control
*
* @export
* @interface LocalState
* @template T The type of value to use for state
*/
export interface LocalState<T> {
dispatches: React.Dispatch<T>[];
value: T;
}
/**
* Create a state
*
* @export
* @template T The type of value to use for state
* @param {(T | (() => T))} value Initial value
* @return Instances of state
*/
export const useLocalStateCreate: {
<T>(value: T | (() => T)): LocalState<T>;
<T = undefined>(value?: T): LocalState<T>;
} = <T>(value: T | (() => T)) => {
return useRef<LocalState<T>>({
dispatches: [],
value: typeof value === 'function' ? (<() => T>value)() : value,
}).current;
};
/**
* Perform the same action as useState
*
* @export
* @template T The type of value to use for state
* @param {LocalState<T>} state The type of value to use for state
* @return [value,dispatch]
*/
export const useLocalState = <T = undefined>(
state: LocalState<T>
): [T, (value: T | ((value: T) => T)) => void] => {
const [, dispatch] = useState<T>();
useEffect(() => {
state.dispatches = [...state.dispatches, dispatch];
return () => {
state.dispatches = state.dispatches.filter((d) => d !== dispatch);
};
}, [state]);
const setState = useCallback(
(value: T | ((value: T) => T)) => mutateLocalState(state, value),
[state]
);
return [state.value, setState];
};
/**
* Select and retrieve the value of state
*
* @export
* @template T The type of value to use for state
* @template K Type of the selected value
* @param {LocalState<T>} state The type of value to use for state
* @param {(value: T) => K} callback callbak to select the target state.
* @return {*} {K} Selected state
*/
export const useLocalSelector = <T, K>(state: LocalState<T>, callback: (value: T) => K): K => {
const [value, setValue] = useState(() => callback(state.value));
const dispatch = useCallback((value: T) => setValue(callback(value)), []);
useEffect(() => {
state.dispatches = [...state.dispatches, dispatch];
return () => {
state.dispatches = state.dispatches.filter((d) => d !== dispatch);
};
}, [state]);
return value;
};
/**
* Write a value to state
*
* @export
* @template T The type of value to use for state
* @param {LocalState<T>} state The type of value to use for state
* @param {(T | ((value: T) => T))} value A value to set for state or a callback to set
*/
export const mutateLocalState = <T>(state: LocalState<T>, value: T | ((value: T) => T)) => {
state.value = typeof value === 'function' ? (<(value: T) => T>value)(state.value) : value;
state.dispatches.forEach((dispatch) => dispatch(state.value));
};
/**
* Reducer to manipulate the state.
*
* @export
* @template T The type of value to use for state
* @template R Reducer
* @template K Action
* @param {LocalState<T>} state
* @param {R} reducer
* @return {*} dispatch
*/
export const useLocallReducer = <
T,
R extends (state: T, action: any) => T,
K extends Parameters<R>[1]
>(
state: LocalState<T>,
reducer: R
) => {
return useCallback(
(action: K) => mutateLocalState(state, reducer(state.value, action)),
[state, reducer]
);
};
Sample
実際の使用方法はこちらです
import React, { VFC } from 'react';
import {
LocalState,
mutateLocalState,
useLocalSelector,
useLocalStateCreate,
} from '@react-libraries/use-local-state';
interface LocalStateType {
tall: number;
weight: number;
}
interface ChildProps {
state: LocalState<LocalStateType>;
}
export const Tall: VFC<ChildProps> = ({ state }) => {
console.log('Tall');
const tall = useLocalSelector(state, (v) => v.tall);
return (
<div>
Tall:
<input
value={tall}
onChange={(e) => {
mutateLocalState(state, (v) => ({ ...v, tall: Number(e.target.value) }));
}}
/>
</div>
);
};
export const Weight: VFC<ChildProps> = ({ state }) => {
console.log('Weight');
const weight = useLocalSelector(state, (v) => v.weight);
return (
<div style={{ display: 'flex' }}>
Weight:
<input
value={weight}
onChange={(e) => {
mutateLocalState(state, (v) => ({ ...v, weight: Number(e.target.value) }));
}}
/>
</div>
);
};
export const Bmi: VFC<ChildProps> = ({ state }) => {
console.log('Bmi');
const { tall, weight } = useLocalSelector(state, (v) => v);
return (
<div>
{isNaN(Number(tall)) || isNaN(Number(weight))
? 'Error'
: `BMI:${Math.floor((Number(weight) / Math.pow(Number(tall) / 100, 2)) * 100) / 100}`}
</div>
);
};
const App = () => {
const state = useLocalStateCreate<LocalStateType>(() => ({ tall: 170, weight: 60 }));
console.log('Parent');
return (
<>
<Bmi state={state} />
<Tall state={state} />
<Weight state={state} />
</>
);
};
export default App;
- Appコンポーネントで
useLocalStateCreate
を使いstateを作成 - Tallコンポーネントはtallの値のみ利用
- WeightコンポーネントはWeightの値だけ利用
- BmiコンポーネントはTallとWeightを利用
React Developer Toolsで再レンダリングの確認
Appコンポーネントで作成したstateが、その値を必要としている場所にだけ配られているのが確認出来ます
Reducerを使用した場合
import React, { VFC } from 'react';
import {
LocalState,
useLocalSelector,
useLocalStateCreate,
useLocallReducer,
} from '@react-libraries/use-local-state';
interface LocalStateType {
tall: number;
weight: number;
}
interface ChildProps {
state: LocalState<LocalStateType>;
}
const reducer = (
state: LocalStateType,
{ type, payload: { value } }: { type: 'SET_TALL' | 'SET_WEIGHT'; payload: { value: number } }
) => {
switch (type) {
case 'SET_TALL':
return { ...state, tall: value };
case 'SET_WEIGHT':
return { ...state, weight: value };
}
};
export const Tall: VFC<ChildProps> = ({ state }) => {
console.log('Tall');
const tall = useLocalSelector(state, (v) => v.tall);
const dispatch = useLocallReducer(state, reducer);
return (
<div>
Tall:
<input
value={tall}
onChange={(e) => {
dispatch({ type: 'SET_TALL', payload: { value: Number(e.target.value) } });
}}
/>
</div>
);
};
export const Weight: VFC<ChildProps> = ({ state }) => {
console.log('Weight');
const weight = useLocalSelector(state, (v) => v.weight);
const dispatch = useLocallReducer(state, reducer);
return (
<div style={{ display: 'flex' }}>
Weight:
<input
value={weight}
onChange={(e) => {
dispatch({ type: 'SET_WEIGHT', payload: { value: Number(e.target.value) } });
}}
/>
</div>
);
};
export const Bmi: VFC<ChildProps> = ({ state }) => {
console.log('Bmi');
const { tall, weight } = useLocalSelector(state, (v) => v);
return (
<div>
{isNaN(Number(tall)) || isNaN(Number(weight))
? 'Error'
: `BMI:${Math.floor((Number(weight) / Math.pow(Number(tall) / 100, 2)) * 100) / 100}`}
</div>
);
};
const App = () => {
const state = useLocalStateCreate<LocalStateType>(() => ({ tall: 170, weight: 60 }));
console.log('Parent');
return (
<>
<Bmi state={state} />
<Tall state={state} />
<Weight state={state} />
</>
);
};
export default App;
まとめ
Reactの不便なところは、state変更の通知を受け取るタイミングが柔軟に制御出来ないことです
この不便さを取り除くため3つの機能を分離して使えるようにしたところ、立ち所にこの問題が解消されました
アプリケーション全体で必要とされるようなstateはRedux等のライブラリを使うにしても、小規模なコンポーネント間の通信はこれで十分だという気がします