redux-saga で編集フォームのフローを実装する
ふとしです。
最近 flux と redux と vuex を行ったり来たりして脳みそが大変です。
redux-saga
redux-saga は redux で非同期処理を取りあつかう際の選択肢としてあげられる middleware です。
しかし以下のような処理のみを redux-saga で行う場合、redux-thunk に対しても同様ですが、導入するには大げさすぎるという意見もあるようです。
function* save (payload) {
try {
yield call(Registry.api.saveProfile, payload);
yield put({ type: SAVE_PROFILE_SUCCEEDED });
} catch (e) {
yield put({ type: SAVE_PROFILE_FAILED, payload: e.errors });
}
}
function* fetch () {
try {
const payload = yield call(Registry.api.fetchProfile);
yield put({ type: FETCH_PROFILE_SUCCEEDED, payload });
} catch (e) {
yield put({ type: FETCH_PROFILE_FAILED });
}
}
わたしも多少そのように思っていました。
フロー全体を一つの Saga として取りあつかう
機能を知ろうとドキュメントを読んでいたところ redux-saga は pull 型のアプローチであるという記述に目がとまりました。
redux-saga では redux の Action フローからターゲットとして定めた Action を取りだすことにより一連の処理を行えます。以下の例では take
が LOGIN LOGOUT の Action が来るまで処理をブロックします。
function* loginFlow() {
while (true) {
yield take('LOGIN')
// ... perform the login logic
yield take('LOGOUT')
// ... perform the logout logic
}
}
このアプローチによりフロー全体を把握しやすい形でコードにできます。ただ LOGIN などは比較的大きな粒度なので、実際の実装にどの程度の影響があるかわかりづらいと思いました。
そこで SPA で典型的に登場する編集フォームを考えてみました。今回はかんたんなプロフィール編集フォームを想定しています。
プロフィール編集フロー
プロフィール編集フォームで必要な処理をかんたんに整理しましょう。
プロフィール編集は明確な開始と終了があるフローです。フローは初期化処理・入力受付・終了処理で構成されています。
初期化処理
フロー開始時に一度だけ実行されます。
- 既存のプロフィールデータを取得します
- 取得したデータをフォームの値として設定します
入力受付
終了条件のあるループです。
- ユーザーからの値入力を受けつけます
- ユーザーからの保存要求を受けつけます
終了条件は以下のとおりです。
- 保存が成功した
- フォームから離脱した
- フローを進行できない問題がある
終了処理
フロー終了時に一度だけ実行されます。処理内容は上記ループの入力受付が終了に至った理由によりかわります。
- 保存成功していればプロフィール画面へ
- フォームから離脱した場合は何もしない
- いずれの場合でもプロフィール編集に関する state を撤去する
プロフィール編集フロー Saga
整理したフローを Saga として実装します。
- 入力受付 を
while
とtake
を用いたループとして扱います。終了条件に至った場合はwhile
を抜けます - 初期化 はそのループ以前に記述します
- 終了処理 はそのループ以後に記述します
SET_PROFILE_EDIT_STATE
のみ Reducer が処理します。
export default function* profileEditFlow () {
const chan = yield actionChannel([
FETCH_PROFILE,
FETCH_PROFILE_SUCCEEDED,
FETCH_PROFILE_FAILED,
INPUT_PROFILE_VALUE,
RESET_PROFILE_VALUE,
SAVE_PROFILE,
SAVE_PROFILE_SUCCEEDED,
SAVE_PROFILE_FAILED,
LEAVE_PROFILE_EDIT_FLOW,
END_PROFILE_EDIT_FLOW,
]);
// # 初期化処理
let reason = '';
let isLeft = false;
yield put({ type: FETCH_PROFILE });
// フォームのロック処理
// 同期的に API リクエストを行っている場合、同じループに含めると take がその call 後になる
yield fork(function* () {
const chan = yield actionChannel([
LOCK_PROFILE_EDIT,
UNLOCK_PROFILE_EDIT,
CLEAN_PROFILE_EDIT_FLOW,
]);
flow: while (true) {
const action = yield take(chan);
const state = (yield select()).profileEditState;
switch (action.type) {
case LOCK_PROFILE_EDIT:
yield put({ type: SET_PROFILE_EDIT_STATE, payload: { ...state, isLocked: true } });
continue;
case UNLOCK_PROFILE_EDIT:
yield put({ type: SET_PROFILE_EDIT_STATE, payload: { ...state, isLocked: false } });
continue;
case ENDED_PROFILE_EDIT_FLOW:
break flow;
default:
continue;
}
}
});
// # 入力受付
flow: while (true) {
const action = yield take(chan);
const state = (yield select()).profileEditState;
switch (action.type) {
case FETCH_PROFILE:
yield put({ type: LOCK_PROFILE_EDIT });
yield call(fetch);
continue flow;
case FETCH_PROFILE_SUCCEEDED:
yield put({ type: SET_PROFILE_EDIT_STATE, payload: { ...state, values: { ...action.payload }, defaultValues: { ...action.payload } } });
yield put({ type: UNLOCK_PROFILE_EDIT });
continue flow;
case FETCH_PROFILE_FAILED:
// 終了条件
reason = 'server error';
break flow;
case INPUT_PROFILE_VALUE:
yield put({ type: SET_PROFILE_EDIT_STATE, payload: { ...state, values: { ...state.values, ...action.payload } } });
continue flow;
case RESET_PROFILE_VALUE:
yield put({ type: SET_PROFILE_EDIT_STATE, payload: { ...state, values: { ...state.defaultValues } } });
continue flow;
case SAVE_PROFILE:
yield put({ type: LOCK_PROFILE_EDIT });
yield call(save, state.values);
continue flow;
case SAVE_PROFILE_SUCCEEDED:
// 終了条件
reason = 'succeeded';
break flow;
case SAVE_PROFILE_FAILED:
yield put({ type: SET_PROFILE_EDIT_STATE, payload: { ...state, errors: action.payload } });
yield put({ type: UNLOCK_PROFILE_EDIT });
continue flow;
case LEAVE_PROFILE_EDIT_FLOW:
// 終了条件
reason = 'left';
isLeft = true;
yield put({ type: END_PROFILE_EDIT_FLOW });
continue flow;
case END_PROFILE_EDIT_FLOW:
yield call(chan.close);
break flow;
default:
continue flow;
}
}
// # 終了処理
switch (reason) {
case 'server error':
yield put({ type: NOTIFY, payload: 'server error' });
yield call(Registry.history.push, '/@');
break;
case 'succeeded':
yield put({ type: NOTIFY, payload:'saved' });
isLeft || (yield call(Registry.history.push, '/@/profiles'));
break;
case 'left':
// do nothing
break;
default:
yield call(Registry.logger.log, '?');
yield call(Registry.history.push, '/@');
break;
}
yield put({ type: CLEAN_PROFILE_EDIT_FLOW });
}
take
を用いることで入力中に起こりうる出来事を一元管理できました。また初期化処理と終了処理で何が起こるかも一目瞭然です。
本人以外が処理内容を把握するために断片化されたコードを手繰る困難さが軽減されたと思います。
フォームのロック処理用のループについて
非同期処理を同期的に行っている call(fetch)
call(save, state.values)
を fork
で行うことによりロック処理も同ループに含められます。
今回は保存中にページ離脱され、その後に発生した SAVE_PROFILE_SUCCEEDED
を捉えて通知するというケースを考えたため call
を使用しています。
call
について
call
は非同期関数を同期的に扱う (返り値が必要であればそれを得る) ために使用されますが、同期関数を呼び出すことも可能です。
同期非同期に関わらず call
を用いることでフロー内で起こる出来事として観測可能になります。そうすることによりテストを有利に進められるため、フロー内での関数呼び出しは call
で行うことをおすすめします。
Saga のテスト方法について詳細はドキュメントを参照してください。 https://redux-saga.js.org/docs/advanced/Testing.html
プロフィール編集フローの起動と終了
プロフィール編集フローはプロフィール編集フォームに至った時点で起動されます。また、フォームから離脱した場合は終了されます。そこで今回は didMount
willUnmount
に相当する時点でそれぞれの Action を発行します。
また入力や保存に際しては、プロフィール編集フロー Saga が take
する Action 以外は発行しないようにすることで、コードを手繰る負荷を低減できます。
function ProfileEditForm () {
const dispatch = useDispatch();
const {
isLocked,
errors,
values: {
name,
email,
message,
},
} = useSelector(s => s.profileEditState);
useEffect(() => {
dispatch({ type: START_PROFILE_EDIT_FLOW });
return () => { dispatch({ type: LEAVE_PROFILE_EDIT_FLOW }); };
}, []);
const submit = e => e.preventDefault() || dispatch({ type: SAVE_PROFILE });
const reset = () => dispatch({ type: RESET_PROFILE_VALUE });
const setter = k => e => dispatch({ type: INPUT_PROFILE_VALUE, payload: { [k]: e.target.value } });
const errorMessages = errors.length ? <>{errors.map(m => <p key={m}>{m}</p>)}</> : null;
return (
<form onSubmit={submit}>
<h1>Profile</h1>
{errorMessages}
<div>
<label htmlFor="name">Name:</label>
<input type="text" disabled={isLocked} id="name" value={name} onChange={setter('name')} />
</div>
<div>
<label htmlFor="email">Email:</label>
<input type="text" disabled={isLocked} id="email" value={email} onChange={setter('email')} />
</div>
<div>
<label htmlFor="message">Message:</label>
<input type="text" disabled={isLocked} id="message" value={message} onChange={setter('message')} />
</div>
<button type="submit" disabled={isLocked}>Save</button>
<button type="button" disabled={isLocked} onClick={reset}>Reset</button>
</form>
);
}
余談: 上記のコードと実際に使っているコードのちがい
- ActionCreator を使っています
- TypeScript 使っています。Action に適切な型を与えることにより
switch
がタイプガードとして働きます
まとめ
redux-saga を最初に導入した時にはこのエントリーの最初に書いてるような単純な使い方しかしていませんでした。
ドキュメントを読んだところ pull 型を実現する take
こそが redux-saga のパワーではないのかと思い、そのように実装してみたところこのようになりました。そして好ましいと感じました。
より複雑なフォーム、例えばショッピングカートではいくつかのフォームを進んでいく高レベルなフローが存在する場合があります。(ドキュメントに記載されていた LOGIN LOGOUT のフローも高レベルですね) その場合も同じように一つのフローとして考え記述することにより、全体で何が起こるのかがわかりやすくなると思います。
redux-saga の使い方のヒントになれば幸いです。