tkit/model-Reactグローバルおよびローカル状態管理ソリューション
備考
tkit/*は内部バージョンで、外部バージョンではtkit-*に置き換えてください。
+ Redux
18年9月、チームはTypeScript + React + Reduxのソリューションを本格的に推進し始め、その中からReduxが選ばれました:
- redux-actionsを使ったアクションとリデューサーの作成
- redux-sagaによる副作用の処理
型付きreduxアクションコードの例を以下に示します:
// store
interface State { ... }
const initialState: State = { ... };
// action
const DOSOMETHING = 'DOSOMETHING';
const doSomething = createAtion(DOSOMETHING, (id: number) =>({ id }));
function* doSomethingEffect(action: Action<{ id: number }>) { ... };
function* doSomethingSaga() {
 yield takeEvery(DOSOMETHING, doSomethingEffect);
}
// reducer
const applySomething = handleActions({
 [DOSOMETHING]: (state: IInitialState, action: Action<{ id: number }>) => { ... }
});
redux-actionsを導入してアクションとリデューサーの作成を簡素化した後でも、このソリューションには多くの問題がありました:
コード構造が複雑すぎる上に、型アノテーションを追加すると、開発経験が飛躍的に低下します。
dvajs
interface Model<S extends any> {
 state: S;
 reducers: {
 [doSomething: string]: (state: S, action: Action<any>) => S;
 };
 effects: {
 [doSomethingAsync: string]: (action: Action<any>, utils: SagasUtils) => Iterator<{}, any, any>;
 };
}
同時に、この構造の欠点もまとめています:
不足1:エフェクト・パラメーターの順番を調整するだけです:
interface Model<S extends any> {
 ...
 effects: {
 [doSomethingAsync: string]: (utils: SagasUtils, action: Action<any>) => Iterator<{}, any, any>;
 };
 ...
}
一方、その他の欠陥については、別のアプローチが必要です。
Model
1つのエフェクト内の型付けと、型計算を行うように設計されたアクションパラメータのスループットは、インターフェイス・ジェネリックスだけではできません。
このファクトリー機能の基本構造:
 interface CreateModel {
 <S, R, E>
 (model: {
 state: S;
 reducers: R;
 effects?: E;
 }
 ): {
 state: S;
 reducers: ApplySomething<S, R>;
 actions: DoSomethings<R, E>
 }
 }
リデューサーとステートのタイプクロスを実装。
export interface Reducers<S> {
 [doSomething: string]: <P extends AbstractAction>(state: Readonly<S>>, action: P) => S;
}
interface CreateModel {
 <S, R extends Reducers<S>, ...>(model: {
 state: S;
 reducers: R;
 ...
 }): any
}
エフェクト内でリデューサーをトリガーするための型付けの実装
エフェクトロジックの中でmodel.actions.doSomethingを直接呼び出します。エフェクトの戻り値の型を明示的に指定する必要があります。
const model = createModel({
 ...
 effects: {
 *doSometingAsync(...): Iterator<{}, any, any> {
 model.actions.doSomething(action)
 }
 }
 ...
})
実装とbindActionCreators型のパススルー。
このアイデアは、リデューサーとエフェクト内のメソッドのアクションパラメータの型を抽出し、ファクトリー関数によって返されるモデルのアクション属性の型を推測するために使用することです:
interface EffectWithPayload<Utils extends BaseEffectsUtils> {
 <P extends AbstractAction>(
 saga: Utils,
 action: P
 ) => Iterator<{}>
}
interface Effects<Utils extends BaseEffectsUtils> {
 [doSomethingAsync: string]: EffectWithPayload<Utils>
}
interface CreateModel {
 <S, R extends Reducers<S>, E extends Effects<SagaUtils>>(model: {
 state: S;
 reducers: R;
 effects: E;
 ...
 }): {
 ...
 actions: {
 [doSomething in keyof R]: <A extends Parameters<R[doSomething]>>[1]>(payload: A['payload']) = > A;
 [doSomethingAsync in keyof E]: <A extends Parameters<E[doSomethingAsync]>>[1]>(payload: A['payload']) = > XXX;
 }
 }
}
function Demo(props: { actions: typeof model.actions }) {
 // ok
 props.actions.doSomething(paramsMathed);
 // TS check error
 props.actions.doSomething(paramsMismatched);
 ...
}
非同期関数をサポートするエフェクトの実装
まず、型定義はエフェクト・ジェネリックを拡張します:
interface HooksModelEffectWithPayload<Utils extends BaseEffectsUtils> {
 <P extends AbstractAction>(saga: Utils, action: P): Promise<any>;
}
interface ReduxModelEffects {
 [doSomethingAsync: string]:
 | EffectWithPayload<ReduxModelEffectsUtils>
 | HooksModelEffectWithPayload<ReduxModelEffectsUtils>;
}
interface CreateModel {
 <... E extends ReduxModelEffects>(model: {
 ...
 effects?: E;
 }): { ... }
}
そして、論理的な実装において、非同期とジェネレーターを区別する必要があります:
{
 ...
 const mayBePromise = yield effect(effects, action);
 mayBePromise['then']
}
Demo
グローバルなReduxモデルの実例です。
import createModel from '@tkit/model';
export interface State {
 groups: Group[];
 scopes: Scope[];
}
const model = createModel({
 state: groupModelState,
 namespace: 'groupModel',
 reducers: {
 setGroups: (state, action: Tction<Group[]>): typeof state => {
 return {
 ...state,
 groups: action.payload
 }
 }
 },
 effects: {
 async clearGroupByPromise({ asyncPut }, action: Tction<Group>) {
 await asyncPut(model.actions.setGroups, []);
 },
 *clearGroupByGenerator({ tPut }, action: Tction<Group>): Iterator<{}, any, any> {
 yield tPut(model.actions.setGroups, []);
 },
 }
})
immerをサポートするためにreducerを強化
タイプ拡大:
interface CMReducers<S> {
 [doSomethingCM: string]: <P extends AbstractAction>(state: S, action: P) => void | S;
}
論理的な処理は、コードがイマーベースのレデューサーと通常のレデューサーを論理的に区別することができないので、これを行うために新しいファクトリー関数CMを作成する必要があります - レデューサーの型の互換性がないため、型変換アーティファクトが必要です:
interface CM {
 <S, R extends CMReducers<S>, E extends ReduxModelEffects>(model: {
 state: S;
 reducers: R;
 effects: E;
 }): CreateModel<S, {
 [doSomething in keyof R]: (
 state: S,
 action: Parameters<R[doSomething]>[1]
 ) => M;
 }, E>
}
そうすれば、明るくなれるんです
import { CM } from '@tkit/model';
const model = CM({
 reducers: {
 setGroups: (state, action: Tction<Group[]>) => {
 state.groups = action.payload;
 }
 },
 ...
})
Reduxの痛みからのソース
確かに、十分な構造化と型付けを行ったとしても、Redux自体から苦痛を感じることは多々あります。例えば、グローバルなReduxには収まりきらないが、その複雑さはローカルのsetStateでは管理できない状態がたくさんあります。-
19年2月に安定版がリリースされたReact Hooks useReducerは、この問題を解決してくれるようです - 部分的なReduxのためのテンプレートです:
const [state, dispatch] = useReducer((state, action) => {
 switch(action.type) { ... }
})
reducerとdispatchを使えば、Reduxモデルをフックに再利用するのは簡単です。
Hooks Model
interface M {
 <M, R extends Reducers<M>, E extends HooksModelEffects>model: {
 state: M;
 reducers: R;
 effects: E;
 }): CreateModel(M, R, E)
}
イマーをサポートするバージョンMM - タイプアーティファクトも必要です:
interface MM {
 <M, R extends CMReducers<M>, E extends HooksModelEffects>(model: {
 state: M;
 reducers: R;
 effects: E;
 }): MM<
 M,
 {
 [doSomething in keyof R]: (
 state: M,
 action: Parameters<R[doSomething]>[1]
 ) => M;
 },
 E
 >
}
useModel
useModel インターフェースは、フックモデルと初期状態を引数にとります:
interface useModel {
 <M extends { reducers: any; ... }>(model: M, initialState: M['state'] = model['state']): [
 M,
 M['actions']
 ]
}
bindDispatchToActionディスパッチをモデルのアクションにバインドするための実装です:
interface bindDispatchToAction {
 <A, E, M extends { actions: A; effects: E; TYPES: any }>(
 actions: A,
 dispatch: ReturnType<typeof useReducer>[1],
 model: M
 ): A
}
Demo
import { MM, useModel } from '@tkit/model';
const UserMMModel = MM({
 namespace: 'test',
 state: UserMMModelState,
 reducers: {
 doRename: (state, action: Tction<{ username: string }>) => {
 state.username = action.payload.username;
 }
 },
 effects: {
 doFetchName: async ({ tPut }, action: Tction<{ time: number }>): Promise<{}> => {
 return tPut(UserMMModel.actions.doRename, { username: `${action.payload.time}` });
 }
 }
});
function Demo() {
 const [state, actions] = useModel(UserMMModel);
 
 return (
 <>
 <h5>{state.username}</h5>
 <button onClick={() => actions.doRename('ok')}>1</button>
 <button onClick={() => actions.doFetchName(1)}>1</button>
 </>
 );
}





