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
以外にも様々なバリデーターを個別に実装して組み込めるので柔軟なモデルを作成出来るようになりました。
悩ましいバリデーション実装の参考になれば幸いです。