何番煎じかわからないが実装してみたから残しておく。
やりたいこと
- Reactのカスタムhookとして実装する
- フォームのバリデーションをする
- フォームをなるべく共通化する
- onSubmitとかonChangeについては共通化する
- JSXやスタイルについては共通化せず個別に実装できるようにする
流れとしてはReact hooksを自分で定義してフォームをそれっぽく扱えるようにし、そのhookを使ってログインフォームを実装する。
バージョンとか
package.jsonからTypeScript, Reactあたりを抜粋するとこんな感じ
{
"dependencies": {
"@types/react": "^16.9.1",
"@types/react-dom": "^16.8.5",
"@types/react-router-dom": "^5.1.2",
"react": "^16.9.0",
"react-dom": "^16.9.0",
"react-router-dom": "^5.1.2",
"typescript": "^3.7.2",
...
}
カスタムhookの実装
まずカスタムhookとしてuseForm
を実装する。
フォームとしてスタイル以外のところ、Stateの管理やバリデーションなどを引き受けるコンポーネントとなる。
面倒なのでコード全体にコメントで説明。
import * as React from 'react';
export interface Validator<State extends {}> {
runAll(state: State): Errors;
}
export type Errors = Map<string, string>;
export const useForm = <State extends {}>(onSubmit, initial: State, validator: Validator<State>) => {
const [state, setState] = React.useState<State>(initial);
const [errors, setErrors] = React.useState<Errors>(new Map());
const [submitEnabled, submitEnable] = React.useState<boolean>(false);
const isValid: () => boolean = React.useCallback(() => {
const _errors = Array.from(validator.runAll(state)).reduce<Map<string, string>>(
(map, [key, error]) => {
if (error) {
return map.set(key, error);
} else {
return map;
}
},
new Map(),
);
setErrors(_errors);
return _errors.size == 0;
}, [state]);
const handleSubmit = React.useCallback(
event => {
if (event) event.preventDefault();
if (submitEnabled && isValid()) {
onSubmit(state);
} else {
console.log(`submit is disabled. error = ${errors}`);
}
},
[state],
);
const handleChange = React.useCallback(
event => {
const name: string = event.target.name;
const value: string = event.target.value;
if (!submitEnabled && value.length > 0) {
submitEnable(true);
}
setState(prevState => {
const newState = { ...prevState, [name]: value };
return newState;
});
},
[state],
);
return {
state,
errors,
disabled: !submitEnabled,
handleChange,
handleSubmit,
};
};
一部のhooksでは引数として依存する値を配列で渡すことができ、大体はstateが変わらなければ結果は同じなので[state]
を渡していたりする。
どうやって使うか
たとえばLoginフォーム。
こちらもコードで説明。
import * as React from 'react';
import {Redirect} from 'react-router-dom';
import {Errors, useForm, Validator} from './useForm';
type LoginState = {
readonly email: string;
readonly password: string;
};
const validator: Validator<LoginState> = new (class implements Validator<LoginState> {
emailValidator(email: string): string | null {
if (email.length == 0) {
return 'Email is empty.';
} else if (!email.match(/.+@.+\..+/)) {
return 'Email is not valid format';
}
}
passwordValidator(password: string): string | null {
if (password.length == 0) {
return 'Password is empty.';
} else if (password.length < 8) {
return 'Password min length is 8';
} else if (password.length > 100) {
return 'Password max length is 100';
}
}
runAll(state: LoginState): Map<string, string> {
return new Map([
['email', this.emailValidator(state.email)],
['password', this.passwordValidator(state.password)],
]);
}
})();
const Login = () => {
const [loggedIn, setLoggedIn] = React.useState(false);
if (loggedIn) {
return <Redirect to={"/"} />;
}
const onSubmit = values => {
if (values.email && values.password) {
ApiClient
.login(values.email, values.password)
.then(response => {
setLoggedIn(true);
})
.catch(err => {
console.log(`Failed to login. message = ${err}`);
});
} else {
console.log('Invalid inputs');
}
};
const { state, errors, disabled, handleChange, handleSubmit } = useForm<LoginState>(
onSubmit,
{
email: '',
password: '',
},
validator,
);
return (
<form onSubmit={handleSubmit}>
<Label>Email:
<Input
key={'email'}
name={'email'}
type={'email'}
value={state.email}
onChange={handleChange} />
<div>{errors.get('email')}</div>
</Label>
<Label>Password:
<Input
key={'password'}
name={'password'}
type={'password'}
value={state.password}
onChange={handleChange} />
<div>{errors.get('password')}</div>
</Label>
<button type="submit" disabled={disabled}>送信</button>
</form>
);
};
export default Login;
これでログインフォームは動く、はず。
とはいえ認証した結果をLoginコンポーネント内だけで持っているのは流石に実用出来ないが。
さっくりまとめると、
useForm
の責務
- formの状態管理
- submit時のバリデーションとエラーの保持
useForm
を使う側の責務
- JSX(スタイル含む)
- onChange/onSubmitを適切に設定する
- バリデーション関数の実装
- submit時の処理(APIリクエストなど)
という感じ。
formに入力されている値の保持とかバリデーション実行とかはよくあることなので共通化してあると考える頻度が減って良い。