Reduxを使った実装で悩むのがフォームのバリデーションをどの層で行うかというところだと思います。
Redux Formを使えばバリデーションもついてきますが今回はこちらは使わない前提で考えていきます。
考えられるパターン
以下の4つの層のどこかで行うことが考えられます。
- View
- Action
- Middleware
- Model
それぞれ利点欠点あると思いますが今回は4. Model + 3. Middlewareでの実装をやってみました。ミドルウェアには redux-sagaを利用しました。
そもそもReduxにモデルなんて出てこないわけですが、ここでいうモデルとは Immutable.Record を使った実装パターンでのモデルで、詳細はこちらやこちらなどを参照してください。
モデル側の実装
まずモデルの実装を見ていきます。
通常のImmutable.Recordを使ったモデルの実装はこのようになります。
import { Record, Map } from 'immutable';
const Post = new Record({
id: undefined,
title: '',
content: '',
author: undefined
comments: new Map()
});
const post = new Post({ id: 1, title: 'title' });
この実装を少し拡張してバリデーション機能付きのクラスを返すEntity関数を実装します。
import { Record } from 'immutable';
export default (defaultValue = {}, validators = {}) => {
return class extends Record(defaultValue) {
get validators() {
return validators;
}
validate(key) {
const validator = validators[key];
if (!validator) {
return null;
}
if (validator.isInvalid(this[key])) {
return validator.message;
}
return null;
}
}
}
import { Map } from 'immutable';
import Entity from './Entity.js';
import StrLengthValidator from './StrLengthValidator.js';
const validators = {
title: new StrLengthValidator(100),
content: new StrLengthValidator(400)
};
const Post = Entity({
id: undefined,
title: '',
content: '',
author: undefined
comments: new Map(),
errorMessages: new Map()
}, validators);
Entityの第一引数はImmutable.Recordに渡す引数と同様で、モデルの初期化パラメータです。
第二引数のvalidatorsはバリデーション対象のパラメータをKeyに実際にバリデーションを行うクラスのインスタンスをオブジェクト化したものです。
新たに追加したパラメータerrorMessagesはエラーメッセージを保存しておくためのフィールドです。
バリデーションクラスはisInvalid(value)とmessage関数に応答する必要があります。
今回実装したのは文字数をチェックするStrLengthValidator.jsですが、前述の関数に応答さえすればどんなものでも利用できます。
export default class {
constructor(limit) {
this.limit = limit
}
isInvalid(value) {
return value.length > this.limit
}
get message() {
return '文字数オーバー'
}
}
post.titleは100文字、post.contentは400文字を超えた状態でpost.validate(key)関数を呼び出すと文字数オーバーというエラーメッセージが返ります。
Reduxアプリケーションに組み込む
このモデルをReduxアプリケーションで利用します。
実際にバリデーションを行うのは、ユーザーがフォームへの入力を終えたというactionが発行されたタイミングとなります。
このアクションをMiddlewareで拾いバリデーションを行い、更に新たなアクションを発行することになります。
詳しく見ていきましょう。
まずはReducerです。
import Post from '../models/Post.js';
export default (state = new Post(), action) => {
switch (action.type) {
case 'CHANGE_TITLE': {
return state.set('title', action.value);
}
case 'CHANGE_CONTENT': {
return state.set('content', action.value);
}
case 'ADD_ERROR': {
return state.setIn(['errorMessages', action.key], action.error);
}
case 'DELETE_ERROR': {
return state.deleteIn(['errorMessages', action.key]);
}
default:
return state
}
}
CHANGE_TITLE、CHANGE_CONTENTはごく一般的なもので、受け取った値でそのまま更新しています。
ADD_ERRORではerrorMessageに受け取ったキーに値を追加し、DELETE_ERRORはその逆でerrorMessageから受け取ったキーを削除しています。
ViewではこのerrorMessageにアクセスし、キーが存在する場合にメッセージを表示します。
ADD_ERRORとDELETE_ERRORはミドルウェアであるsagasから発行されます。
import { put, takeEvery, select } from 'redux-saga/effects'
export function* validateField(action) {
const { key } = action;
const post = yield select(state => state.post);
const error = post.validate(key);
if (error) {
yield put({ type: 'ADD_ERROR', key, error });
} else {
yield put({ type: 'DELETE_ERROR', key });
}
}
export default function* rootSaga() {
yield takeEvery('VALIDATE_FIELD', validateField);
}
VALIDATE_FIELDアクションを全てキャッチしてバリデーションを実行し、エラーがあった場合はADD_ERRORを、なかった場合はDELETE_ERRORを発行します。
※ putはアクションの発行、 takeEveryは指定したアクションが発行されると関数を別のsagaを実行します。
また、selectではstateを取得できます。
redux-sagaについて詳しくはこちら。
export const changeTitle = value => ({ type: 'CHANGE_TITLE', value });
export const changeContent = value ({ type: 'CHANGE_CONTENT', value });
export const validateField = key => ({ type: 'VALIDATE_FIELD', key });
アクションクリエーターはシンプルな実装です。
フォームのonChangeイベントでchangeTitleやchangeContent、onBlurでvalidateFieldを発火します。
所感
Immutable.Recordを使いモデル層を実装することでビジネスロジックをモデルに集約することができます。
その利点を更に活用し、モデル層にバリデーションロジックも集約してしまおうというのが今回のアイディアです。
また、StrLengthValidator以外にも様々なバリデーターを個別に実装して組み込めるので柔軟なモデルを作成出来るようになりました。
悩ましいバリデーション実装の参考になれば幸いです。