Open11

Full Stack open 2020 を読む【Part 9-d】

pocpoc

概要

https://fullstackopen.com/en/part9/react_with_types
これを読んだメモ。再翻訳の手間をなくすことを主な目的としてメモする。

リポジトリ

https://github.com/poc-sleepy/full-stack-open-2020

読メモ

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.jsonallowJsをtrueからfalseに変えましょう
    • 今回は純粋なTypeScriptプロジェクトにしたいので、falseにする
    • JavaScriptとTypeScriptを混在させる場合はtrueにする
    • JavaScriptのプロジェクトをTypeScriptに変換中の場合とかね
  • eslintの設定をする
    • eslint自体のインストールや依存関係の設定はcreate-react-appで作成されているので実施不要
    • .eslintrcの設定はこんな感じで
.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}\""としないといけないことに注意
pocpoc

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で型定義可能
  • 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を再構築する
  • とくにつまづきポイントはなし
pocpoc

よりディープな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の使用

  • 上記のCoursePartOneCoursePartThreeの型定義の重複を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:
  return assertNever(part);
  • ここで試しに「Deeper type usage」のcaseブロックをコメントアウトすると...
    • CoursePartThree型をnever型に割り当てられない、と怒られる
    • CoursePartThree型のものがassertNever()に流れ込むようになっているから怒られている
    • このようにswitchcase分岐で未検討の型があると、TypeScriptが教えてくれるよ

演習 9.15.

  • CourseSpecialPart型を作った
  • Contentをswitchcase文でTSXを分けた
  • 特につまづきポイントはなし

typeinterfaceの違う点

  • typeinterfaceは似たようなことができる
  • ほとんどの場合は好みで使い分け、でOK
  • ただし、同名のものを定義したときの動作が異なるので注意
    • type: エラーになる「すでに同じ名前の型があるよ!」
    • interface: マージされ、エラーにはならない
  • FullstackOpenではinterfaceのほうを推奨します
pocpoc

既存コードを扱うときのヒント

このpart9の残りは既出のサンプルプロジェクト「patientor」を使って進めます。

  • 既存コードを扱う場合、最初にプロジェクトの全体の規則と構造を把握するのがおすすめ
    • ルートにREADME.mdがあるならそこに概要があるので読もう
    • README.mdがなかったり放置されている場合、package.jsonを見るのが有用
      • とりあえずアプリを起動していろいろクリックしてみるべし
  • フォルダ構成を見ると、アプリの機能や構成のヒントになる
    • 必ずしもきれいとは限らないのと、自分に馴染みのない手法を使っているかもしれないけど
    • 「patientor」ではmodalやstateなどのフォルダがありますね
      • フォルダ別とはいえ、機能ごとにスコープが異なるかもしれないことには注意
      • ModalはUIレベルのコンポーネントな一方で、stateはビジネスロジックに相当する
  • types.tsやそれに近いファイルを見るのも有用
    • 期待されているデータ構造、関数、コンポーネント、状態などがわかる
    • VSCodeもtypes.tsなどの情報をもとに変数やパラメータをハイライトして助けてくれるので、大いに活用していこう
  • 単体テスト、結合テスト、エンドtoエンドテスト(総合テストなどに近い?)がある場合は、テストケースを見るのも大事
    • 既存機能を壊してないかを確認する必要があるので、リファクタリングや新機能作成のときはテストケースはめちゃめちゃ大事
  • 既存コードを最初に読んでみてよくわからなかったとしても心配無用!
    • コードを読むことはそれ自体がスキルなので。
    • たくさんコーナーケースがあるかもしれないし、ロジックの断片がそこらじゅうに追加されているかもしれない
  • 時間をかけてコードを読みましょう
    • 今まで開発者たちがどんなトラブルに遭遇してきたかをイメージするのはとても難しい
      • 年輪のように積み重なった結果が今なのだ
      • コードやビジネス要件をたくさん掘り下げていかないとすべて分かるようにはならない
    • コードを読めば読むほどうまくいくよ
    • 書くよりずっと読むことになるものですよ
pocpoc

Patientorのフロントエンド作り

演習9.8-9.13で作ったバックエンドに対応するフロントエンドに手を入れていきましょう
まずはfront/endともに起動して動作確認

  • 患者リストのページが表示される
  • 患者のデータはバックエンド側に保持、がモックデータなのでバックエンドを終わらせるとデータは消える
  • フロントからバックへの新規患者追加ができる
  • UIについては気にしない方向で

では、コードを見てみましょう

  • 見るべきものはsrc配下にある
  • types.tsが型定義で、演習を経て更新していきます
    • 型は基本的にfront/endで同一のものを使えるが、データ構造やユースケースが異なったりするので、だいたいはfrontとendでは型が異なりがち
      • 例えば、フロントでは状態があるし、エンドでは配列のデータをオブジェクトやマップで保持したいときとかがある
      • 例えば、バックエンドで保持しているフィールドすべてをフロントに必ずしも持つ必要はない。フロントに表示したいものだけあれば十分

フォルダ構成を見てみよう

  • AddPatientModalPatientListPageという2つのメインコンポーネントがあることが分かる
  • stateフォルダにはフロントエンドでの状態制御に関わるコードがある
    • データを一箇所にとどめ、状態を更新するための簡単な操作を提供する役割(後述)

状態制御

内部動作が複雑でかつ今までと実装の仕方が異なる部分があるので、状態制御についてもうすこし掘り下げます。

  • 今回の状態管理にはReact HooksのuseContextuseReducerを使う
    • 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型だと思いこんでいるので、そのプロパティにアクセスしてもエラーが起こらない
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();
  // ...
}
pocpoc

患者リストページ

  • 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>();
  • openModalcloseModalという関数も用意されているね
    • より読みやすくかつ便利なので用意されている
    • フロント側での型は、バックエンド開発時にどんな型にしたかに基づいて決まる
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 });
pocpoc

演習 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);
  }
};
  • 上記を処理するActionGET_PATIENTを作成
reducer.ts
case 'GET_PATIENT':
  return {
    ...state,
    patients: {
      ...state.patients,
      [action.payload.id]: action.payload,
    },
  };
pocpoc

entryの実装

患者の詳細ページができたので、entry項目を追加してみよう

  • シードデータは新しいものに置き換えます
    • .jsonじゃなくて.ts形式にしています。Genderなどのenumにしたタイミングでtsに入れ替えているはず。うまくいかないなら要確認

Entry型の定義

  • Entry型を作っていきましょう
  • データのtype項目を見ると、OccupationalHealthcareHospitalHealthCheckの3種類がありますね
  • 共通項目もあるので、ベースとなるインターフェースを定義し、これを継承して上記の3つを型で定義しよう
  • 共通項目:id, description, date, specialist
  • OccupationalHealthCareHospitaldiagnosisCodesがある。使わないときもあるのでオプション項目にする
    • オプション項目なので、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;
}
  • そんなかんじでOccupationalHealthCareEntryHospitalEntryも作る
  • で、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'>;
pocpoc

患者追加フォーム

  • Reactでフォームハンドリングするのは不便なことが起きがちなので、Formikを使っています
  • Formikは厄介な3つの点をサポートしてくれる小さなライブラリです
    • フォーム内外から状態の値を取得する
    • バリデーションとエラーメッセージ
    • フォーム投稿のハンドリング

フォームのソースを見ていく

  • src/AddPatientModal/AddPatientForm.tsxがメインソース
  • src/AddPatientModal/FormField.tsxにいくつかフィールドのヘルパーがある
  • PatientFormValuesという型が定義されていますね
    • Patient型からid, entriesが除かれたもの
    • idはバックエンド側で設定するし、entriesは既存の患者にのみ入力される想定で、どちらもユーザ入力を想定しないので、除いてある
AddPatientForm.tsx
export type PatientFormValues = Omit<Patient, "id" | "entries">;
  • フォームコンポーネントに渡すpropsには、onSubmitonCancelが必要になっている
    • どちらもvoidを返すコールバック関数
interface Props {
  onSubmit: (values: PatientFormValues) => void;
  onCancel: () => void;
}

export const AddPatientForm = ({ onSubmit, onCancel }: Props) => {
  // ...
}

フォーム要素のコンポーネントを見ていく

  • FormField.tsxではGenderOption型、SelectFieldTextFieldというコンポーネントが定義されている

SelectFieldの仕組み

  • まず、セレクトボックスの選択肢の共通の型が定義されている(ラベルと値を持つ)
  • セレクトボックスにしたいものはこれを継承して、各種選択肢を作っている
    • GenderOptionはGenderの選択肢と対応させたいので、valueの型はGender型にしている
  • 上記GenderOptionのリストとして、実際の選択肢GenderOptionsを定義している
  • SelectFieldPropsの型はそのセレクトボックスの名前、値、上記選択肢を持つものとしている
    • 選択肢すなわちoptionsにはGenderOptionsが入れられるよう、GenderOption[]
  • SelectFieldコンポーネントはシンプル。Propsの値通りに選択リストTSXを出力するのみ。

TextFieldの仕組み

  • TextFieldコンポーネントは、Semantic UIのForm.Fieldの中に、ラベルとFormikのFieldを含めて出力している
  • FormikのFieldはnameplaceholderをpropとして取る
  • Formikのエラーメッセージは必要なときにだけ表示される仕組みなので、処理分岐等は考えなくてOK
  • エラーメッセージを取り出したいときは、propのformを使用することで可能
export const TextField = ({ field, label, placeholder, form }: TextProps) => {
  console.log(form.errors); 
  // ...
}
pocpoc

Formikコンポーネント

  • AddPatientForm.tsxのフォームコンポーネントはFormikコンポーネントを出力している
  • Formikコンポーネントはラッパー、必要なpropはinitialValueonSubmit
  • 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を返す関数が入っている
    • この中で前述のSelectFieldTextFieldを使用している
  • お尻の方でボタンを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);
  }
};

さあこれで演習問題ができるはず!やってみて!

pocpoc

演習9.23~
いったんやらない。ボリュームが多そうで、他の章を読むことを優先したいので。