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 で典型的に登場する編集フォームを考えてみました。今回はかんたんなプロフィール編集フォームを想定しています。

プロフィール編集フロー

プロフィール編集フォームで必要な処理をかんたんに整理しましょう。

プロフィール編集は明確な開始と終了があるフローです。フローは初期化処理・入力受付・終了処理で構成されています。

初期化処理

フロー開始時に一度だけ実行されます。

入力受付

終了条件のあるループです。

終了条件は以下のとおりです。

終了処理

フロー終了時に一度だけ実行されます。処理内容は上記ループの入力受付が終了に至った理由によりかわります。

プロフィール編集フロー Saga

整理したフローを Saga として実装します。

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>
  );
}

余談: 上記のコードと実際に使っているコードのちがい

まとめ

redux-saga を最初に導入した時にはこのエントリーの最初に書いてるような単純な使い方しかしていませんでした。

ドキュメントを読んだところ pull 型を実現する take こそが redux-saga のパワーではないのかと思い、そのように実装してみたところこのようになりました。そして好ましいと感じました。

より複雑なフォーム、例えばショッピングカートではいくつかのフォームを進んでいく高レベルなフローが存在する場合があります。(ドキュメントに記載されていた LOGIN LOGOUT のフローも高レベルですね) その場合も同じように一つのフローとして考え記述することにより、全体で何が起こるのかがわかりやすくなると思います。

redux-saga の使い方のヒントになれば幸いです。