Open11
Full Stack open 2020 を読む【Part 9-d】
概要
これを読んだメモ。再翻訳の手間をなくすことを主な目的としてメモする。
リポジトリ
読メモ
ReactにTypeScriptを用いるメリットは?
→ 以下のようなエラーを検知できる
- 不必要なpropを渡している
- 必要なpropが渡っていない
- 違う型のpropを渡している
JavaScriptだとテスト時に検知するエラー、コンパイル時点で検知できるのがTypeScriptのよいところ
ReactAppをTypeScriptでつくる
-
npx create-react-app my-app --template typescript
でいける -
npm start
ですぐに動かせる - JavaScriptのみのプロジェクトと基本的に同じで、次の違いがある
- js, jsxファイルがts, tsxファイルになっていて、型注釈がある
- ルートディレクトリに
tsconfig.json
がある
-
tsconfig.json
のallowJs
をtrueからfalseに変えましょう- 今回は純粋なTypeScriptプロジェクトにしたいので、falseにする
- JavaScriptとTypeScriptを混在させる場合はtrueにする
- JavaScriptのプロジェクトをTypeScriptに変換中の場合とかね
- eslintの設定をする
- eslint自体のインストールや依存関係の設定は
create-react-app
で作成されているので実施不要 -
.eslintrc
の設定はこんな感じで
- eslint自体のインストールや依存関係の設定は
.eslintrc
{
"env": {
"browser": true,
"es6": true,
"jest": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended"
],
"plugins": ["react", "@typescript-eslint"],
"settings": {
"react": {
"pragma": "React",
"version": "detect"
}
},
"rules": {
"@typescript-eslint/explicit-function-return-type": 0,
"@typescript-eslint/explicit-module-boundary-types": 0
}
}
-
explicit-function-return-type
およびexplicit-module-boundary-types
を無効にして、デフォルトより少しゆるくしてある- 関数の戻り値の型を必ずしも明示する必要がなくなる
- Reactコンポーネントの戻り値が基本的にJSX.Elementまたはnullであるため
-
package.json
のスクリプトlint
で*.tsxファイルを対象に加える- tsxファイル:ReactでいうJSXのTypeScript版
"lint": "eslint './src/**/*.{ts,tsx}'"
- Windowsの場合は
"lint": "eslint \"./src/**/*.{ts,tsx}\""
としないといけないことに注意
TypeScriptでReactコンポーネント
次のJavaScript版Reactを例に、TypeScriptに変えていきます
import React from "react";
import ReactDOM from 'react-dom';
import PropTypes from "prop-types";
const Welcome = props => {
return <h1>Hello, {props.name}</h1>;
};
Welcome.propTypes = {
name: PropTypes.string
};
const element = <Welcome name="Sara" />;
ReactDOM.render(element, document.getElementById("root"));
- TypeScriptではprop-typesパッケージが不要になる
- prop-typesはpart5で登場する、
props
の型を制限するパッケージ - Reactコンポーネントはただの関数なので、prop-typesの代わりにTypeScriptで型定義可能
- prop-typesはpart5で登場する、
- TypeScriptでコンポーネントを書く例
const MyComp1 = () => {
// Typescript automatically infers the return type of this function
// (i.e., a react component) as `JSX.Element`.
return <div>Typescript has auto inference!</div>
}
const MyComp2 = (): JSX.Element => {
// We are explicityle defining the return type of a function here
// (i.e., a react component).
return <div>Typescript React is easy.</div>
}
interface MyProps{
lable: string;
price?: number;
}
const MyComp3 = ({lable, price}: MyProps): JSX.Element => {
// We are explicityle defining the parameter types using interface `MyProps`
// and return types as `JSX.Element` in this function (i.e., a react component).
return <div>Typescript is great.</div>
}
const MyComp4 = ({lable, price}: {lable: string, price: number}) => {
// We are explicityle defining the parameter types using an inline interface
// and typescript automatically infers the return type as JSX.Element of the function (i.e., a react component).
return <div>There is nothing like typescript.</div>
}
- ということで、Welcomeコンポーネントはこんな感じ
interface WelcomeProps {
name: string;
}
const Welcome = ({ name }: { name: string }) => {
return <h1>Hello, {name}</h1>;
};
const element = <Welcome name="Sara" />;
ReactDOM.render(element, document.getElementById("root"));
演習 9.14.
- part1で作ったcourseinfoを再構築する
- とくにつまづきポイントはなし
よりディープなTypeの使い方
- 演習で出てきたpartに追加フィールドができ、partごとに必要なフィールドが異なる場合を考えましょう
- たとえばpartがこんな感じの時
-
name
,description
は必須 -
description
,groupProjectCount
,exerciseSubmissionLink
はあったりなかったりする
const courseParts = [
{
name: "Fundamentals",
name: 10,
description: "This is an awesome course part"
},
{
name: "Using props to pass data",
exerciseCount: 7,
groupProjectCount: 3
},
{
name: "Deeper type usage",
exerciseCount: 14,
description: "Confusing description",
exerciseSubmissionLink: "https://fake-exercise-submit.made-up-url.dev"
}
];
- ここからがTypeScriptの出番ですぞ!
unionの使用
- まずはいろいろパートの型を定義する
interface CoursePartOne {
name: "Fundamentals";
exerciseCount: number;
description: string;
}
interface CoursePartTwo {
name: "Using props to pass data";
exerciseCount: number;
groupProjectCount: number;
}
interface CoursePartThree {
name: "Deeper type usage";
exerciseCount: number;
description: string;
exerciseSubmissionLink: string;
}
- 次にこれらの型のunion型を作成する
- これで属性の過不足があったときにエディタが自動で警告してくれる
- ためしに属性をコメントアウトしたりすると確認できる
type CoursePart = CoursePartOne | CoursePartTwo | CoursePartThree;
extendsの使用
- 上記の
CoursePartOne
~CoursePartThree
の型定義の重複をextends
で解消しましょう
interface CoursePartBase {
name: string;
exerciseCount: number;
}
interface CoursePartOne extends CoursePartBase {
name: "Fundamentals";
description: string;
}
interface CoursePartTwo extends CoursePartBase {
name: "Using props to pass data";
groupProjectCount: number;
}
interface CoursePartThree extends CoursePartBase {
name: "Deeper type usage";
description: string;
exerciseSubmissionLink: string;
}
- switchcase文でこれらの型を使い分けできる
-
name
を判別材料としてswitchcase文を書くことで型分別が可能 -
name
が「Using props to pass data」の場合にpart.description
を参照しようとするとTypeScriptは叱ってくれる-
CoursePartTwo
にはdescription
はないからね
-
-
exhaustive type checking
- 新しい型のコースが追加された場合を考える
- TypeScriptでは網羅チェック(exhaustive type checking)と呼ばれる手法がある
- 予期せぬ値に出くわしたら、never型として受け入れ、never型を返す関数を使いましょうという原則
- 簡単な形としてはこんな感じ
/**
* Helper function for exhaustive type checking
*/
const assertNever = (value: never): never => {
throw new Error(
`Unhandled discriminated union member: ${JSON.stringify(value)}`
);
};
- 前述のswitchcase文にて新規型のpartが入力されたら、defaultのcaseにいくので、defaultのところにも処理が必要ですね
- defaultに
assertNever
を入れればいいでしょう
- defaultに
default:
return assertNever(part);
- ここで試しに「Deeper type usage」のcaseブロックをコメントアウトすると...
-
CoursePartThree
型をnever型に割り当てられない、と怒られる -
CoursePartThree
型のものがassertNever()
に流れ込むようになっているから怒られている - このようにswitchcase分岐で未検討の型があると、TypeScriptが教えてくれるよ
-
演習 9.15.
-
CourseSpecialPart
型を作った - Contentをswitchcase文でTSXを分けた
- 特につまづきポイントはなし
type
とinterface
の違う点
-
type
とinterface
は似たようなことができる - ほとんどの場合は好みで使い分け、でOK
- ただし、同名のものを定義したときの動作が異なるので注意
-
type
: エラーになる「すでに同じ名前の型があるよ!」 -
interface
: マージされ、エラーにはならない
-
- FullstackOpenでは
interface
のほうを推奨します
既存コードを扱うときのヒント
このpart9の残りは既出のサンプルプロジェクト「patientor」を使って進めます。
- 既存コードを扱う場合、最初にプロジェクトの全体の規則と構造を把握するのがおすすめ
- ルートに
README.md
があるならそこに概要があるので読もう - README.mdがなかったり放置されている場合、
package.json
を見るのが有用- とりあえずアプリを起動していろいろクリックしてみるべし
- ルートに
- フォルダ構成を見ると、アプリの機能や構成のヒントになる
- 必ずしもきれいとは限らないのと、自分に馴染みのない手法を使っているかもしれないけど
- 「patientor」ではmodalやstateなどのフォルダがありますね
- フォルダ別とはいえ、機能ごとにスコープが異なるかもしれないことには注意
- ModalはUIレベルのコンポーネントな一方で、stateはビジネスロジックに相当する
-
types.ts
やそれに近いファイルを見るのも有用- 期待されているデータ構造、関数、コンポーネント、状態などがわかる
- VSCodeもtypes.tsなどの情報をもとに変数やパラメータをハイライトして助けてくれるので、大いに活用していこう
- 単体テスト、結合テスト、エンドtoエンドテスト(総合テストなどに近い?)がある場合は、テストケースを見るのも大事
- 既存機能を壊してないかを確認する必要があるので、リファクタリングや新機能作成のときはテストケースはめちゃめちゃ大事
- 既存コードを最初に読んでみてよくわからなかったとしても心配無用!
- コードを読むことはそれ自体がスキルなので。
- たくさんコーナーケースがあるかもしれないし、ロジックの断片がそこらじゅうに追加されているかもしれない
- 時間をかけてコードを読みましょう
- 今まで開発者たちがどんなトラブルに遭遇してきたかをイメージするのはとても難しい
- 年輪のように積み重なった結果が今なのだ
- コードやビジネス要件をたくさん掘り下げていかないとすべて分かるようにはならない
- コードを読めば読むほどうまくいくよ
- 書くよりずっと読むことになるものですよ
- 今まで開発者たちがどんなトラブルに遭遇してきたかをイメージするのはとても難しい
Patientorのフロントエンド作り
演習9.8-9.13で作ったバックエンドに対応するフロントエンドに手を入れていきましょう
まずはfront/endともに起動して動作確認
- 患者リストのページが表示される
- 患者のデータはバックエンド側に保持、がモックデータなのでバックエンドを終わらせるとデータは消える
- フロントからバックへの新規患者追加ができる
- UIについては気にしない方向で
では、コードを見てみましょう
- 見るべきものは
src
配下にある -
types.ts
が型定義で、演習を経て更新していきます- 型は基本的にfront/endで同一のものを使えるが、データ構造やユースケースが異なったりするので、だいたいはfrontとendでは型が異なりがち
- 例えば、フロントでは状態があるし、エンドでは配列のデータをオブジェクトやマップで保持したいときとかがある
- 例えば、バックエンドで保持しているフィールドすべてをフロントに必ずしも持つ必要はない。フロントに表示したいものだけあれば十分
- 型は基本的にfront/endで同一のものを使えるが、データ構造やユースケースが異なったりするので、だいたいはfrontとendでは型が異なりがち
フォルダ構成を見てみよう
-
AddPatientModal
とPatientListPage
という2つのメインコンポーネントがあることが分かる -
state
フォルダにはフロントエンドでの状態制御に関わるコードがある- データを一箇所にとどめ、状態を更新するための簡単な操作を提供する役割(後述)
状態制御
内部動作が複雑でかつ今までと実装の仕方が異なる部分があるので、状態制御についてもうすこし掘り下げます。
- 今回の状態管理にはReact Hooksの
useContext
とuseReducer
を使う- reduxやそれに似たライブラリを使わないのでアプリが軽くてすむ
- この構成については色々良い資料がありますよ。これとか。
- とはいえ、基本的に今回のコードはpart6で扱ったReduxベースのものと似ている
- ので、Reduxの動作が分かっていることが前提になります。例えばpart6-aはカバーしているものとします
- React Contextとは?
- 公式ドキュメント:Reactコンポーネントのツリー形式の中で「グローバル」なものに相当するデータを共有するために設計されたもの。例)現在認証されているユーザ、テーマ、言語設定
- 今回の場合、データ変更時に使うアプリケーションの状態とディスパッチ機能が「グローバルなもの」に相当
状態
- Contextは、状態と状態を変更するディスパッチャのタプルで構成する
- 状態は次のような感じ
-
patients
というキーを1つ持つオブジェクト -
patients
に対応する値もオブジェクトで、文字列をキー、値にPatientオブジェクトをもつ - インデックスシグネチャと呼ばれる構文を使っている
- インデックスには文字列または数値を指定可能
- ここで
id
という名前はTypeScriptには意味はない- 実際には
patients['testId']
のようにして使うので。
- 実際には
-
state.ts
export type State = {
patients: { [id: string]: Patient };
};
- が、この宣言だとキー(ここだと
id
)が本当に存在しているのかTypeScript側は知る術を持たない- 存在しないidで
patients
にアクセスしても、TypeScriptは戻り値がPatient型だと思いこんでいるので、そのプロパティにアクセスしてもエラーが起こらない
- 存在しないidで
const myPatient = state.patients['non-existing-id'];
console.log(myPatient.name); // no error, TypeScript believes that myPatient is of type Patient
- ので、unionで
undefined
型もつけてあげましょう
state.ts
export type State = {
patients: { [id: string]: Patient | undefined };
};
- これでTypeScriptが警告を出してくれるようになります
const myPatient = state.patients['non-existing-id'];
console.log(myPatient.name); // error, Object is possibly 'undefined'
- こういったセキュリティ処置は、外部データやユーザ入力データを使う場合には常に実施したほうがいい
- 逆に言うと、そういったデータソースを一切扱わないケースでは不要
-
Mapオブジェクトを使用する方法もある
- キーと値の両方の型を宣言できる
- Mapのアクセス関数
get()
だと値とundefined
のunionを常に返すので、TypeScriptが警告を出してくれる
interface State {
patients: Map<string, Patient>;
}
...
const myPatient = state.patients.get('non-existing-id'); // type for myPatient is now Patient | undefined
console.log(myPatient.name); // error, Object is possibly 'undefined'
console.log(myPatient?.name); // valid code, but will log 'undefined'
独自メモ
上記変更に伴い、PatientListPage/index.ts
でコンパイラエラーが出る
下記のハンドリングを追加して回避
PatientListPage/index.ts
Object.values(patients).map((patient: Patient | undefined) => {
if (patient !== undefined) {
return (
<Table.Row key={patient.id}>
<Table.Cell>{patient.name}</Table.Cell>
<Table.Cell>{patient.gender}</Table.Cell>
<Table.Cell>{patient.occupation}</Table.Cell>
<Table.Cell>
<HealthRatingBar showText={false} rating={1} />
</Table.Cell>
</Table.Row>
);
}
});
アクションとReducer
- すべての状態操作はreducerによって実施する(Reduxと同じ)
-
reducer.ts
にてAction型として定義する
-
reducer.ts
export type Action =
| {
type: "SET_PATIENT_LIST";
payload: Patient[];
}
| {
type: "ADD_PATIENT";
payload: Patient;
};
- Reducerにてアクションごとにケース分けして状態を変更
- part6のときと違い、状態が配列ではなく辞書型(オブジェクト)になっている
reducer.ts
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "SET_PATIENT_LIST":
return {
...state,
patients: {
...action.payload.reduce(
(memo, patient) => ({ ...memo, [patient.id]: patient }),
{}
),
...state.patients
}
};
case "ADD_PATIENT":
return {
...state,
patients: {
...state.patients,
[action.payload.id]: action.payload
}
};
default:
return state;
}
};
- コンテキストを設定を処理する
state.ts
の変更点はいろいろある- 状態やディスパッチ関数を作るのに
useReducer
が使われている - そしてそれをコンテキストプロバイダに渡している
- 状態やディスパッチ関数を作るのに
state.ts
export const StateProvider = ({
reducer,
children
}: StateProviderProps) => {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<StateContext.Provider value={[state, dispatch]}>
{children}
</StateContext.Provider>
);
};
- で、
index.ts
でプロバイダを呼び出す- これによりすべてのコンポーネントで使える状態やディスパッチ関数が作られる
index.ts
import { reducer, StateProvider } from "./state";
ReactDOM.render(
<StateProvider reducer={reducer}>
<App />
</StateProvider>,
document.getElementById('root')
);
-
useStateValue
というフックも作ってありますね- で、必要なコンポーネントで呼び出したりしてますね
state.ts
export const useStateValue = () => useContext(StateContext);
PatientListPage/index.ts
import { useStateValue } from "../state";
// ...
const PatientListPage = () => {
const [{ patients }, dispatch] = useStateValue();
// ...
}
患者リストページ
-
PatientListPage/index.ts
を見てみましょう- カスタムフックで状態を作り、ディスパッチャーで状態を更新している
- 状態の中の
patients
プロパティを展開するだけでリストを表示できる
import { useStateValue } from "../state";
const PatientListPage = () => {
const [{ patients }, dispatch] = useStateValue();
// ...
}
-
useState
フックによる状態も併用している- modalの表示切り替え、フォームのエラーハンドリングに使用
- 状態定義で型も定義している(モーダルならboolean、とか)
- これにより、useStateフックで返されるset関数は指定した型のみの引数をとる
- 厳密には
setModalOpen()
ならReact.Dispatch<React.SetStateAction<boolean>>
になる
- 厳密には
const [modalOpen, setModalOpen] = React.useState<boolean>(false);
const [error, setError] = React.useState<string | undefined>();
-
openModal
とcloseModal
という関数も用意されているね- より読みやすくかつ便利なので用意されている
- フロント側での型は、バックエンド開発時にどんな型にしたかに基づいて決まる
const openModal = (): void => setModalOpen(true);
const closeModal = (): void => {
setModalOpen(false);
setError(undefined);
};
- Appコンポーネントがマウントされると、axiosを使ってバックエンドから患者データがフェッチされる
-
axios.get()<void>
で型情報を与えていることに注意
-
React.useEffect(() => {
axios.get<void>(`${apiBaseUrl}/ping`);
const fetchPatientList = async () => {
try {
const { data: patients } = await axios.get<Patient[]>(
`${apiBaseUrl}/patients`
);
dispatch({ type: "SET_PATIENT_LIST", payload: patients });
} catch (e) {
console.error(e);
}
};
fetchPatientList();
}, [dispatch]);
-
ただし、ここで
axios.get()
に型情報を与えたところで、データのチェックはなにもされない- 外部APIを使用している場合はとても危険!
- ペイロード全体から正しい型を返す検証関数を作るか、型ガードを使ったほうがいい
- 異なるスキーマ間をバリデーションできるようにするライブラリもいろいろあるよ(例:io-ts)
-
useStateValueフックにあるディスパッチャーで状態を更新している
- 前述のAction型に沿ったstringやpayloadであるかがコンパイラによってチェックされている
dispatch({ type: "SET_PATIENT_LIST", payload: patients });
演習 9.16.-9.18.
演習 9.17
- React Routerに詳細ページを追加
-
/
のルートより上に追加しないと、全部/
側に行ってしまう。ここでつまづいたので注意。
-
App.tsx
<Switch>
<Route path="/patients/:id">
<PatientDetailPage />
</Route>
<Route path="/">
<PatientListPage />
</Route>
</Switch>
- GenderからIconのnameに変換する関数を独自で書いている
PatientDetailPage/index.tsx
const genderIconName = (gender: Gender): SemanticICONS => {
switch (gender) {
case 'male':
return 'mars';
case 'female':
return 'venus';
default:
return 'genderless';
}
};
- 9.16で作ったエンドポイントを呼び出す処理を追加
PatientDetailPage/index.tsx
const fetchPatient = async () => {
try {
if (id !== undefined) {
const { data: patient } = await axios.get<Patient>(
`${apiBaseUrl}/patients/${id}`
);
dispatch({ type: 'GET_PATIENT', payload: patient });
}
} catch (e) {
console.error(e);
}
};
- 上記を処理するAction
GET_PATIENT
を作成
reducer.ts
case 'GET_PATIENT':
return {
...state,
patients: {
...state.patients,
[action.payload.id]: action.payload,
},
};
entryの実装
患者の詳細ページができたので、entry項目を追加してみよう
- シードデータは新しいものに置き換えます
- .jsonじゃなくて.ts形式にしています。
Gender
などのenumにしたタイミングでtsに入れ替えているはず。うまくいかないなら要確認
- .jsonじゃなくて.ts形式にしています。
Entry型の定義
- Entry型を作っていきましょう
- データの
type
項目を見ると、OccupationalHealthcare
、Hospital
、HealthCheck
の3種類がありますね - 共通項目もあるので、ベースとなるインターフェースを定義し、これを継承して上記の3つを型で定義しよう
- 共通項目:
id
,description
,date
,specialist
-
OccupationalHealthCare
とHospital
:diagnosisCodes
がある。使わないときもあるのでオプション項目にする- オプション項目なので、
HealthCheck
タイプにも設けてちゃいましょう
- オプション項目なので、
- 以上からBaseEntryはこんな感じ
types.ts
interface BaseEntry {
id: string;
description: string;
date: string;
specialist: string;
diagnosisCodes?: string[];
}
- バックエンドで
Diagnosis
型を作成しているので、次のようにもできる-
Diagnosis['code'][]
と書くのは分かりづらいので、Array<Diagnosis['code']>
と書いています
-
types.ts
interface BaseEntry {
id: string;
description: string;
date: string;
specialist: string;
diagnosisCodes?: Array<Diagnosis['code']>;
}
3つの型の定義
まずはHealthCheckEntry
から作りますか
-
HealthCheckRaing
項目が必要:0(健康)~3(致命的)- これはenumがうってつけ
- ということでこんな感じ
types.ts
export enum HealthCheckRating {
"Healthy" = 0,
"LowRisk" = 1,
"HighRisk" = 2,
"CriticalRisk" = 3
}
interface HealthCheckEntry extends BaseEntry {
type: "HealthCheck";
healthCheckRating: HealthCheckRating;
}
- そんなかんじで
OccupationalHealthCareEntry
、HospitalEntry
も作る - で、Entry型を作ろう
types.ts
export type Entry =
| HospitalEntry
| OccupationalHealthcareEntry
| HealthCheckEntry;
- こんなふうにunion型を使う時、
Omit
がうまく動作しないことに注意! -
id
を除きたいときにOmit<Entry, 'id'>
としても、うまく行かない- 共通の項目しかひっぱられず、非共通の項目が含まれない
- 特殊なOmit関数を用意して回避すべし
// Define special omit for unions
type UnionOmit<T, K extends string | number | symbol> = T extends unknown ? Omit<T, K> : never;
// Define Entry without the 'id' property
type EntryWithoutId = UnionOmit<Entry, 'id'>;
患者追加フォーム
- Reactでフォームハンドリングするのは不便なことが起きがちなので、Formikを使っています
- Formikは厄介な3つの点をサポートしてくれる小さなライブラリです
- フォーム内外から状態の値を取得する
- バリデーションとエラーメッセージ
- フォーム投稿のハンドリング
フォームのソースを見ていく
-
src/AddPatientModal/AddPatientForm.tsx
がメインソース -
src/AddPatientModal/FormField.tsx
にいくつかフィールドのヘルパーがある -
PatientFormValues
という型が定義されていますね- Patient型から
id
,entries
が除かれたもの -
id
はバックエンド側で設定するし、entries
は既存の患者にのみ入力される想定で、どちらもユーザ入力を想定しないので、除いてある
- Patient型から
AddPatientForm.tsx
export type PatientFormValues = Omit<Patient, "id" | "entries">;
- フォームコンポーネントに渡す
props
には、onSubmit
とonCancel
が必要になっている- どちらもvoidを返すコールバック関数
interface Props {
onSubmit: (values: PatientFormValues) => void;
onCancel: () => void;
}
export const AddPatientForm = ({ onSubmit, onCancel }: Props) => {
// ...
}
フォーム要素のコンポーネントを見ていく
-
FormField.tsx
ではGenderOption
型、SelectField
とTextField
というコンポーネントが定義されている
SelectField
の仕組み
- まず、セレクトボックスの選択肢の共通の型が定義されている(ラベルと値を持つ)
- セレクトボックスにしたいものはこれを継承して、各種選択肢を作っている
-
GenderOption
はGenderの選択肢と対応させたいので、value
の型はGender
型にしている
-
- 上記
GenderOption
のリストとして、実際の選択肢GenderOptions
を定義している -
SelectFieldProps
の型はそのセレクトボックスの名前、値、上記選択肢を持つものとしている- 選択肢すなわち
options
にはGenderOptions
が入れられるよう、GenderOption[]
型
- 選択肢すなわち
-
SelectField
コンポーネントはシンプル。Propsの値通りに選択リストTSXを出力するのみ。
TextField
の仕組み
-
TextField
コンポーネントは、Semantic UIのForm.Fieldの中に、ラベルとFormikのFieldを含めて出力している - FormikのFieldは
name
とplaceholder
をpropとして取る - Formikのエラーメッセージは必要なときにだけ表示される仕組みなので、処理分岐等は考えなくてOK
- エラーメッセージを取り出したいときは、propの
form
を使用することで可能
export const TextField = ({ field, label, placeholder, form }: TextProps) => {
console.log(form.errors);
// ...
}
Formikコンポーネント
-
AddPatientForm.tsx
のフォームコンポーネントはFormikコンポーネントを出力している - Formikコンポーネントはラッパー、必要なpropは
initialValue
とonSubmit
- Formikはフォームの状態を追跡し、propsを通してメソッドやイベントハンドラを提供する
- 加えて任意propである
validate
を使用している- 起きているエラーを返す関数を指定する
- ここではテキスト項目がfalsyかどうかだけチェックしている(でも他にも色々簡単にできるよ)
- ここで定義したエラーメッセージが各項目上に表示される
- 全体のソースを見てからあれこれ説明しますね
AddPatientForm.tsx
interface Props {
onSubmit: (values: PatientFormValues) => void;
onCancel: () => void;
}
export const AddPatientForm = ({ onSubmit, onCancel }: Props) => {
return (
<Formik
initialValues={{
name: "",
ssn: "",
dateOfBirth: "",
occupation: "",
gender: Gender.Other
}}
onSubmit={onSubmit}
validate={values => {
const requiredError = "Field is required";
const errors: { [field: string]: string } = {};
if (!values.name) {
errors.name = requiredError;
}
if (!values.ssn) {
errors.ssn = requiredError;
}
if (!values.dateOfBirth) {
errors.dateOfBirth = requiredError;
}
if (!values.occupation) {
errors.occupation = requiredError;
}
return errors;
}}
>
{({ isValid, dirty }) => {
return (
<Form className="form ui">
<Field
label="Name"
placeholder="Name"
name="name"
component={TextField}
/>
<Field
label="Social Security Number"
placeholder="SSN"
name="ssn"
component={TextField}
/>
<Field
label="Date Of Birth"
placeholder="YYYY-MM-DD"
name="dateOfBirth"
component={TextField}
/>
<Field
label="Occupation"
placeholder="Occupation"
name="occupation"
component={TextField}
/>
<SelectField
label="Gender"
name="gender"
options={genderOptions}
/>
<Grid>
<Grid.Column floated="left" width={5}>
<Button type="button" onClick={onCancel} color="red">
Cancel
</Button>
</Grid.Column>
<Grid.Column floated="right" width={5}>
<Button
type="submit"
floated="right"
color="green"
disabled={!dirty || !isValid}
>
Add
</Button>
</Grid.Column>
</Grid>
</Form>
);
}}
</Formik>
);
};
export default AddPatientForm;
- Formikラッパーの中にはFormのTSXを返す関数が入っている
- この中で前述の
SelectField
やTextField
を使用している
- この中で前述の
- お尻の方でボタンを2つ作っている。キャンセルボタンと送信ボタン
- キャンセルボタンはpropで与えられたコールバックをそのまま実行
- 送信ボタンはFormikのonSubmitイベントをトリガーし、それがonSubmitのコールバックを実行する
- 直接コールバックを呼ばない。FormikのonSubmitイベントを経由すると、実際のデータ送信の前にバリデーションを実行してくれるから
- この時バリデーションNGになると、実際の送信をキャンセルしてくれる
- 送信ボタンは入力内容が有効かつdirty(編集されている)なときにのみ押せるようにしている
- ボタン2つはSemanticUIのGridの中に配置されているので、横に並んで表示される
onSubmitコールバック関数
- 基本的に患者リストページを継承した感じ
- HTTP POSTをバックエンドに送る
- バックエンドから返ってきたデータを状態
patients
に追加 - モーダルを閉じる
- エラーが発生したらフォームに表示させる
- ソースはこんな感じですね
const submitNewPatient = async (values: FormValues) => {
try {
const { data: newPatient } = await axios.post<Patient>(
`${apiBaseUrl}/patients`,
values
);
dispatch({ type: "ADD_PATIENT", payload: newPatient });
closeModal();
} catch (e) {
console.error(e.response.data);
setError(e.response.data.error);
}
};
さあこれで演習問題ができるはず!やってみて!
演習9.23~
いったんやらない。ボリュームが多そうで、他の章を読むことを優先したいので。