FullStackOpen Part9-d React with Types メモ

ReactでTypescriptを使うと以下のようなメリットがある

  • 余分/不要なpropを渡そうとするときにエラーを検知できる

  • 必要なpropがないときに検知できる

  • 異なるtypeのpropを渡したときに検知できる

Create React App with TypeScript

TypeScriptでreactアプリを作るときは以下のコマンドで始める

npx create-react-app my-app --template typescript

create-react-appで作ったアプリには自動で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,
    "react/react-in-jsx-scope": 0
  }
}

package.jsonのscriptには以下を追加

"lint": "eslint "./src/**/*.{ts,tsx}""

React components with TypeScript

ReactのApp.tsxを書くのであれば以下のようにする

interface WelcomeProps {
  name: string;
}


const Welcome = (props: WelcomeProps)  => {
  return <h1>Hello, {props.name}</h1>;
};

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <Welcome name="Sarah" />
)

props渡しにinterfaceを使用することで過不足なく引数を渡すことができる。

createRootの引数で、document.getElementById('root')に型アサーションでas HTMLElementとしているのは、返り値の方がHTMLElement | nullとなっているため。

Deeper type usage

coursePartsの中身がすべて均一な構造になっているとは限らない。
例えば以下のような場合:

const courseParts = [
  {
    name: "Fundamentals",
    exerciseCount: 10,
    description: "This is an awesome course part"
  },
  {
    name: "Using props to pass data",
    exerciseCount: 7,
    groupProjectCount: 3
  },
  {
    name: "Basics of type Narrowing",
    exerciseCount: 7,
    description: "How to go from unknown to string"
  },
  {
    name: "Deeper type usage",
    exerciseCount: 14,
    description: "Confusing description",
    backgroundMaterial: "https://type-level-typescript.com/template-literal-types"
  },
];

これではinterfaceとして当てはめることができない。
このような場合はinterfaceの拡張ユニオン型を使用する。

interface CoursePartBase {
  name: string;
  exerciseCount: number;
}

interface CoursePartBasic extends CoursePartBase {
  description: string;
  kind: "basic"
}

interface CoursePartGroup extends CoursePartBase {
  groupProjectCount: number;
  kind: "group"
}

interface CoursePartBackground extends CoursePartBase {
  description: string;
  backgroundMaterial: string;
  kind: "background"
}

type CoursePart = CoursePartBasic | CoursePartGroup | CoursePartBackground;

合わせてcoursePartsも変更する
kindプロパティを追加。

const courseParts: Array<CoursePart> = [
    {
      name: "Fundamentals",
      exerciseCount: 10,
      description: "This is an awesome course part",
      kind: 'basic'
    },
    {
      name: "Using props to pass data",
      exerciseCount: 7,
      groupProjectCount: 3,
      kind: 'group'
    },
    {
      name: "Basics of type Narrowing",
      exerciseCount: 7,
      description: "How to go from unknown to string",
      kind: 'basic'
    },
    {
      name: "Deeper type usage",
      exerciseCount: 14,
      description: "Confusing description",
      backgroundMaterial: "https://type-level-typescript.com/template-literal-types",
      kind: 'background'
    },
  ];

More type narrowing

CoursePartが複数の種類を持つ場合、それぞれに固有なプロパティにアクセスするには工夫をする必要がある。
switch文やif分を使って型絞り込みをする必要がある。

const assertNever = (value: never): never => {
  throw new Error(`Unhandled discriminated union member: ${JSON.stringify(value)}`);
};

const Content = (props: CourseProps) => {
  props.courseParts.forEach(part => {
    switch (part.kind) {
      case 'basic':
        console.log(part.name, part.description, part.exerciseCount);
        break;
      case 'group':
        console.log(part.name, part.exerciseCount, part.groupProjectCount);
        break;
      case 'background':
        console.log(part.name, part.description, part.exerciseCount, part.backgroundMaterial);
        break;
      default:
        return assertNever(part);
    }
  })

switchで型を絞ってからだと固有プロパティにアクセス可能。
またneverを返すassetNeverヘルパー関数を用意して、defaultに入ったときにエラーを返すようにした。
このように確実にどれかのcaseに当てはまるようにすることをExhausive type checkingと呼ぶ。

演習の答え

こんな感じで実装(もっといいやり方はあるかも)

const Header = ({ courseName }: { courseName: string }) => {
  return (
    <h1>
      {courseName}
    </h1>
  );
};

interface CoursePartBase {
  name: string;
  exerciseCount: number;
}

interface CoursePartWithDescription extends CoursePartBase {
  description: string;
}

interface CoursePartBasic extends CoursePartWithDescription {
  kind: 'basic';
}

interface CoursePartGroup extends CoursePartBase {
  groupProjectCount: number;
  kind: 'group';
}

interface CoursePartBackgroud extends CoursePartWithDescription {
  backgroundMaterial: string;
  kind: 'background';
}

interface CoursePartSpecial extends CoursePartWithDescription {
  requirements: Array<string>;
  kind: 'special';
}

type CoursePart = CoursePartBasic | CoursePartGroup | CoursePartBackgroud | CoursePartSpecial;

interface CourseProps {
  courseParts: Array<CoursePart>;
}

const assertNever = (value: never): never => {
  throw new Error(`Unhandled discriminated union member: ${JSON.stringify(value)}`);
};

interface PartProps {
  part: CoursePart;
}

const Part = (props: PartProps) => {
  const part: CoursePart = props.part;
  switch (part.kind) {
    case 'basic':
      return (
        <div>
          <h2>{part.name}: {part.exerciseCount}</h2>
          <p>{part.description}</p>
        </div>
      );
    case 'group':
      return (
        <div>
          <h2>{part.name}: {part.exerciseCount}</h2>
          <p>project exercises: {part.groupProjectCount}</p>
        </div>
      );
    case 'background':
      return (
        <div>
          <h2>{part.name}: {part.exerciseCount}</h2>
          <p>{part.description}</p>
          <p>submit to {part.backgroundMaterial}</p>
        </div>
      );
    case 'special':
      return (
        <div>
          <h2>{part.name}: {part.exerciseCount}</h2>
          <p>{part.description}</p>
          <p>Requied skills: {part.requirements.join(',')}</p>
        </div>
      );
    default:
      return assertNever(part);
  }
}

const Content = (props: CourseProps) => {
  return (
    <div>
      {props.courseParts.map(p => <Part part={p} key={p.name} />)}
    </div>
  );
};

const Footer = (props: CourseProps) => {
  return (
    <div>
      <p>
        Number of exercises: {''}
        {props.courseParts.reduce((carry, part) => carry + part.exerciseCount, 0)}
      </p>
    </div>
  );
};

const App = () => {
  const courseName = 'Half Stack application development';
  const courseParts: Array<CoursePart> = [
    {
      name: "Fundamentals",
      exerciseCount: 10,
      description: "This is an awesome course part",
      kind: 'basic'
    },
    {
      name: "Using props to pass data",
      exerciseCount: 7,
      groupProjectCount: 3,
      kind: 'group'
    },
    {
      name: "Basics of type Narrowing",
      exerciseCount: 7,
      description: "How to go from unknown to string",
      kind: 'basic'
    },
    {
      name: "Deeper type usage",
      exerciseCount: 14,
      description: "Confusing description",
      backgroundMaterial: "https://type-level-typescript.com/template-literal-types",
      kind: 'background'
    },
    {
      name: "Backend development",
      exerciseCount: 21,
      description: "Typing the backend",
      requirements: ["nodejs", "jest"],
      kind: "special"
    }
  ];

  return (
    <div>
      <Header courseName={courseName} />
      <Content courseParts={courseParts} />
      <Footer courseParts={courseParts} />
    </div>
  );
};

export default App;

React app with state

通常はhookのstateを定義するときに型を指定する必要はなく、Reactが型推定をしてくれる。
ただし型を指定する場面はいくつかあり、以下のようにジェネリックとして指定しよう

  const [notes, setNotes] = useState<Note[]>([]);

これでノートの作成フォームを書くとこんな感じ

interface Note {
  id: number,
  content: string
}

const App = () => {
  const [notes, setNotes] = useState<Note[]>([]);
  const [newNote, setNewNote] = useState('');


  const noteCreation = (event: React.SyntheticEvent) => {
    event.preventDefault()
    const noteToAdd = {
      content: newNote,
      id: notes.length + 1
    }
    setNotes(notes.concat(noteToAdd));
    setNewNote('')
  };

  return (
    <div>
      <form onSubmit={noteCreation}>
        <input value={newNote} onChange={(event) => setNewNote(event.target.value)} />
        <button type='submit'>add</button>
      </form>
      <ul>
        {notes.map(note =>
          <li key={note.id}>{note.content}</li>
        )}
      </ul>
    </div>
  )
}

注意する点:
noteCreationはeventを引数にとるが、この型はReact.SynteticEvent。
このようにTypeScript特有の型がわからないときはチートシートを参照する

React Typescript Cheetsheet

Communicating with the server

Axiosを使って通信する際、axios.get()およびaxios.post()はジェネリック関数のため、型を指定することができる
型を指定しない場合は、response.dataがany型となってしまう。

import axios from 'axios';
import { Note, NewNote } from "./types";

const baseUrl = 'http://localhost:3001/notes'

export const getAllNotes = () => {
  return axios
    .get<Note[]>(baseUrl)
    .then(response => response.data)
}

export const createNote = (object: NewNote) => {
  return axios
    .post<Note>(baseUrl, object)
    .then(response => response.data)
}

ただし、get<Note[]>をして本当にNote配列が返ってくるかはわからない。
堅牢なシステムを築きたい場合はProofing requestsの章でやったように入力データの検証をする必要があるが、自分で作ったサーバーとやり取りする場合はバックエンド側に比べるとそこまで厳しくやる必要はないかも。

A note about defining object types

実はObject型を定義するときにはinterfaceでもtypeでもOK

//これもOK

type DiaryEntry = {
  id: number;
  date: string;
  weather: Weather;
  visibility: Visibility;
  comment?: string;
} 

しかしTypescriptのドキュメントではinterfaceの使用をしていることが多い

演習の答え

Flight-diaryはこんな感じでやってみました。

import { useEffect, useState } from 'react';
import { DiaryEntry, NewDiaryEntry, Visibility, Weather } from './types';
import diaryService from './services/diaryService';

const useField = (type: string) => {
  const [value, setValue] = useState('');

  const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setValue(event.target.value);
  }

  return {
    type,
    value,
    onChange,
  }
}

interface NewDiaryFormProps {
  postNewDiary: (newDiary: NewDiaryEntry) => void;
}

const NewDiaryForm = (props: NewDiaryFormProps) => {
  const [newDiary, setNewDiary] = useState<NewDiaryEntry>();
  const date = useField('date');
  const [visibility, setVisibility] = useState<Visibility>();
  const [weather, setWeather] = useState<Weather>();
  const comment = useField('text');

  const onClickSubmit = (event: React.SyntheticEvent) => {
    event.preventDefault();
    setNewDiary({
      date: date.value,
      weather: weather as Weather,
      visibility: visibility as Visibility,
      comment: comment.value,
    })
  }

  const onChangeVisibility = (event: React.ChangeEvent<HTMLInputElement>) => {
    setVisibility(event.target.value as Visibility);
  }

  const onChangeWeather = (event: React.ChangeEvent<HTMLInputElement>) => {
    setWeather(event.target.value as Weather);
  }

  useEffect(() => {
    if (newDiary) {
      props.postNewDiary(newDiary as NewDiaryEntry)
    }
  }, [newDiary])

  return (
    <div>
      <h1>
        Add new entry
      </h1>
      <form onSubmit={onClickSubmit}>
        <div>
          <label>
            Date:
            <input {...date} />
          </label>
        </div>
        <div>
          <label>
            Visibility:
            <div>
              <input type='radio' id='visibilityGreat' name='visibility' value={Visibility.Great} onChange={onChangeVisibility} />
              <label htmlFor='visibilityGreat'>Great</label>
              <input type='radio' id='visibilityGood' name='visibility' value={Visibility.Good} onChange={onChangeVisibility} />
              <label htmlFor='visibilityGood'>Good</label>
              <input type='radio' id='visibilityOk' name='visibility' value={Visibility.Ok} onChange={onChangeVisibility} />
              <label htmlFor='visibilityOk'>OK</label>
              <input type='radio' id='visibilityPoor' name='visibility' value={Visibility.Poor} onChange={onChangeVisibility} />
              <label htmlFor='visibilityPoor'>Poor</label>
            </div>
          </label>
        </div>
        <div>
          <label>
            Weather:
            <div>
              <input type='radio' id='weatherSunny' name='weather' value={Weather.Sunny} onChange={onChangeWeather} />
              <label htmlFor='weatherSunny'>Sunny</label>
              <input type='radio' id='weatherRainy' name='weather' value={Weather.Rainy} onChange={onChangeWeather} />
              <label htmlFor='weatherRainy'>Rainy</label>
              <input type='radio' id='weatherCloudy' name='weather' value={Weather.Cloudy} onChange={onChangeWeather} />
              <label htmlFor='weatherCloudy'>Cloudy</label>
              <input type='radio' id='weatherStormy' name='weather' value={Weather.Stormy} onChange={onChangeWeather} />
              <label htmlFor='weatherStormy'>Stormy</label>
              <input type='radio' id='weatherWindy' name='weather' value={Weather.Windy} onChange={onChangeWeather} />
              <label htmlFor='weatherWindy'>Windy</label>
            </div>
          </label>
        </div>
        <div>
          <label>
            Comment:
            <input {...comment} />
          </label>
        </div>
        <button type='submit'>submit</button>
      </form>
    </div>
  );
};

interface DiaryEntriesProps {
  diaryEntries: Array<DiaryEntry>;
}

interface DiaryEntryProps {
  diary: DiaryEntry;
}

const DiaryEntrySingle = (props: DiaryEntryProps) => {
  const diary = props.diary;
  return (
    <div>
      <h2>{diary.date}</h2>
      <p>Visibility: {diary.visibility}</p>
      <p>weather: {diary.weather}</p>
      <p>Comment: {diary.comment}</p>
    </div>
  );
};

const DiaryEntries = (props: DiaryEntriesProps) => {
  return (
    <div>
      <h1>
        Diary Entries
      </h1>
      {
        props.diaryEntries.map(d => {
          return <DiaryEntrySingle diary={d} key={d.id} />
        })
      }
    </div>
  );
};

const App = () => {
  const [diaries, setDiaries] = useState<DiaryEntry[]>([]);

  const postNewDiary = (newDiary: NewDiaryEntry) => {
    console.log(newDiary);
    const response = diaryService.create(newDiary);
    response.then(res => {
      setDiaries(diaries.concat(res.data as DiaryEntry));
    }).catch(error => console.log(error));
  };

  useEffect(() => {
    diaryService.getAll().then(response => {
      setDiaries(response.data);
    }).catch(error => {
      console.log(error);
      alert(error);
    });
  }, []);

  return (
    <div>
      <NewDiaryForm postNewDiary={postNewDiary} />
      <DiaryEntries diaryEntries={diaries} />
    </div>
  );
};

export default App;

この記事が気に入ったらサポートをしてみませんか?