Open7

Full Stack open 2020 を読む【Part 5-b】

pocpoc

概要

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

リポジトリ

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

要約

  • Reactコンポーネントは<Parent>Code as you like...</Parent>のように子要素を持てる(コンポーネントも入れられる)
    • 親からはprops.childrenで子要素を呼び出せる
    • propの型はReact.PropsWithChildren<Props>を使えばchildrenを使っても怒られない
  • 状態や関数は共有されるものの最も近い祖先に置くべき
  • useRefuseImperativeHandle forwardRefを組み合わせると、子コンポーネント内で定義している関数などを親から呼び出し可能
    • ただしコードはあまりキレイじゃない
  • Array.sort()で並び替えが可能(破壊関数)
    • パラメータにはコンペア関数を入れられる
    • コンペア関数は2つの要素を引数に取り、戻り値に数値をとる
    • 戻り値が正だと要素の順番を入れ替える
  • PropTypesというライブラリがあるが、TypeScriptを使っているなら不要
  • ESlintでplugin:react/recommendedを継承するとReact向けのチェックができる
    • コンポーネントのdisplayNameが無いと怒られる場合はComponentName.displayName = 'ComponentName'のように設定して回避可能
  • jestでのテストをする場合はeslint-plugin-jestパッケージを入れる

以下、読メモ

pocpoc

適切な場合にのみログインフォームを表示する

  • ログインフォームがデフォルトでは表示されないようにする
    • loginボタンを押すことでログインフォームが表示される
    • cancelボタンを推すとログインフォームを閉じられる
  • まずはログインフォームを独自コンポーネントにする
    • ここではpropsは分散構文で受け取っていることに注意
components/LoginForm.tsx
import React from 'react';

type Props = {
  handleSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
  handleUsernameChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
  handlePasswordChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
  username: string;
  password: string;
};

const LoginForm = ({
  handleSubmit,
  handleUsernameChange,
  handlePasswordChange,
  username,
  password,
}: Props) => {
  return (
    <div>
      <h2>Login</h2>

      <form onSubmit={handleSubmit}>
        <div>
          username
          <input value={username} onChange={handleUsernameChange} />
        </div>
        <div>
          password
          <input
            type="password"
            value={password}
            onChange={handlePasswordChange}
          />
        </div>
        <button type="submit">login</button>
      </form>
    </div>
  );
};

export default LoginForm;
  • AppコンポーネントのloginForm関数を変更
    • ログイン画面の描画を切り替える状態loginVisibleを定義
    • loginボタンとcancelボタンのonClickにはsetLoginVisible()を当てる
    • コンポーネントの描画切り替えは<div style={hideWhenVisible}>で切り替える
App.tsx
const App = () => {
  const [loginVisible, setLoginVisible] = useState(false)

  // ...

  const loginForm = () => {
    const hideWhenVisible = { display: loginVisible ? 'none' : '' }
    const showWhenVisible = { display: loginVisible ? '' : 'none' }

    return (
      <div>
        <div style={hideWhenVisible}>
          <button onClick={() => setLoginVisible(true)}>log in</button>
        </div>
        <div style={showWhenVisible}>
          <LoginForm
            username={username}
            password={password}
            handleUsernameChange={({ target }) => setUsername(target.value)}
            handlePasswordChange={({ target }) => setPassword(target.value)}
            handleSubmit={handleLogin}
          />
          <button onClick={() => setLoginVisible(false)}>cancel</button>
        </div>
      </div>
    )
  }

  // ...
}

コンポーネントの子要素(props.children)

  • ログインフォームの表示or非表示のロジックは、Appからは切り離し、独自のコンポーネントにしておくべき
  • こんなふうに使えるTogglableコンポーネントを作りましょう
<Togglable buttonLabel='login'>
  <LoginForm
    username={username}
    password={password}
    handleUsernameChange={({ target }) => setUsername(target.value)}
    handlePasswordChange={({ target }) => setPassword(target.value)}
    handleSubmit={handleLogin}
  />
</Togglable>

  • このコンポーネントは今までと違って終了タグがありますね
  • Reactコンポーネントはこのように親子関係を作れる
  • で、Togglableコンポーネントはこんな感じ
    • {props.children}でその子コンポーネントの要素を出力できる(ここならログインフォーム)
    • ここでpropsにはProps型をつけず、コンポーネント型でReact.FC<Props>というようにPropsをつける。これでprops.childrenが使用可能
components/Togglable.tsx
import React, { useState } from 'react';

type Props = {
  buttonLabel: string;
};

const Togglable: React.FC<Props> = (props) => {
  const [visible, setVisible] = useState(false);

  const hideWhenVisible = { display: visible ? 'none' : '' };
  const showWhenVisible = { display: visible ? '' : 'none' };

  const toggleVisibility = () => {
    setVisible(!visible);
  };

  return (
    <div>
      <div style={hideWhenVisible}>
        <button onClick={toggleVisibility}>{props.buttonLabel}</button>
      </div>
      <div style={showWhenVisible}>
        {props.children}
        <button onClick={toggleVisibility}>cancel</button>
      </div>
    </div>
  );
};

export default Togglable;

  • props.childrenはReactで自動的に追加される要素なので、パラメータとして渡す必要はない
  • />でコンポーネントが閉じられている場合はprops.childrenは空配列[]になる

Togglableを流用

  • 新規ノート作成フォームにもTogglableを流用できますね
  • その前にノート作成フォームをコンポーネント化しましょう
components/NoteForm.tsx
import React from 'react';

type Props = {
  onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
  handleChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
  value: string;
};

const NoteForm: React.FC<Props> = ({ onSubmit, handleChange, value }) => {
  return (
    <div>
      <h2>Create a new note</h2>

      <form onSubmit={onSubmit}>
        <input value={value} onChange={handleChange} />
        <button type="submit">save</button>
      </form>
    </div>
  );
};

export default NoteForm;

で、Togglableの中にフォームを入れる

App.tsx
<Togglable buttonLabel="new note">
  <NoteForm
    onSubmit={addNote}
    value={newNote}
    handleChange={handleNoteChange}
  />
</Togglable>
pocpoc

フォームでの状態

  • ここで、状態定義をどこのコンポーネントで持つべきか、という問題を考える
  • React公式は「状態が共有されるものたちの最も近い祖先に置くべき」としている
  • いま、ほぼ全ての状態はAppにあるが、例えば状態newNoteNoteFormでしか使ってないので、そこで定義すべき。
  • NoteFormコンポーネントにnewNoteなどもろもろを引っ越しましょう
    • 最終的にpropsに残るのはcreateNoteだけになりますね
components/NoteForm.tsx
import React, { useState } from 'react';
import { NewNote } from '../utils/types';

type Props = {
  createNote: (newNote: NewNote) => void;
};

const NoteForm: React.FC<Props> = ({ createNote }) => {
  const [newNote, setNewNote] = useState<string>('');

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

  const addNote = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    createNote({
      content: newNote,
      date: new Date().toISOString(),
      important: Math.random() > 0.5,
    });

    setNewNote('');
  };

  return (
    <div>
      <h2>Create a new note</h2>

      <form onSubmit={addNote}>
        <input value={newNote} onChange={handleChange} />
        <button type="submit">save</button>
      </form>
    </div>
  );
};

export default NoteForm;

  • App側がこんな感じですっきりします
App.tsx
const App = () => {
  // ...
  const addNote = async (noteObject: NewNote) => {
    const returnedNote = await noteService.create(noteObject);
    setNotes(notes.concat(returnedNote));
  };
  // ...
  const noteForm = () => (
    <Togglable buttonLabel="new note">
      <NoteForm createNote={addNote} />
    </Togglable>
  );

  // ...
}

【独自】ログインフォームも修正

  • ログインフォームも同様にスッキリさせましょう(テキストではオプション課題をしている)
components/LoginForm.tsx
import React, { useState } from 'react';

type Props = {
  login: (user: { username: string; password: string }) => void;
};

const LoginForm = ({ login }: Props) => {
  const [username, setUsername] = useState<string>('');
  const [password, setPassword] = useState<string>('');

  const handleLogin = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    login({ username, password });
    setUsername('');
    setPassword('');
  };

  return (
    <div>
      <h2>Login</h2>

      <form onSubmit={handleLogin}>
        <div>
          username
          <input
            value={username}
            onChange={({ target }) => setUsername(target.value)}
          />
        </div>
        <div>
          password
          <input
            type="password"
            value={password}
            onChange={({ target }) => setPassword(target.value)}
          />
        </div>
        <button type="submit">login</button>
      </form>
    </div>
  );
};

export default LoginForm;

App.tsx
const App: React.FC = () => {

  //... 
  type PropsHandleLogin = { username: string; password: string };

  const handleLogin = async ({ username, password }: PropsHandleLogin) => {
    try {
      const user = await loginService.login({ username, password });
      window.localStorage.setItem('loggedNoteappUser', JSON.stringify(user));
      noteService.setToken(user.token);
      setUser(user);
    } catch (exception) {
      setErrorMessage('Wrong credentials');
      setTimeout(() => {
        setErrorMessage(null);
      }, 5000);
    }
  };

  //...

  const loginForm = () => {
    return (
      <Togglable buttonLabel="login">
        <LoginForm login={handleLogin} />
      </Togglable>
    );
  };

pocpoc

refによるコンポーネントの参照

  • 新規ノートが作成された後は、新規ノートのフォームを非表示にしましょう
  • フォームの表示非表示はTogglableコンポーネントの変数visibleで制御されている、どうやってここにアクセスする?
  • 今回はrefを使う方法でやってみましょう
  • App側でuseRefフックを定義、Togglableの呼び出しでrefオプションをつける
    • これがTogglableコンポーネントへの参照として使われる
App.js
import React, { useState, useEffect, useRef } from 'react'

const App = () => {
  // ...
  const noteFormRef = useRef()

  const noteForm = () => (
    <Togglable buttonLabel='new note' ref={noteFormRef}>
      <NoteForm createNote={addNote} />
    </Togglable>
  )

  // ...
}

  • Togglableはこんな感じに変えます
    • コンポーネント定義をReact.forwardRefで括る
    • パラメータはpropsref
    • useImperativeHandletoggleVisibilityを返すことで、外部でこの関数を使えるようになる
togglable
import React, { useState, useImperativeHandle } from 'react'

const Togglable = React.forwardRef((props, ref) => {
  const [visible, setVisible] = useState(false)

  const hideWhenVisible = { display: visible ? 'none' : '' }
  const showWhenVisible = { display: visible ? '' : 'none' }

  const toggleVisibility = () => {
    setVisible(!visible)
  }

  useImperativeHandle(ref, () => {
    return {
      toggleVisibility
    }
  })

  return (
    <div>
      <div style={hideWhenVisible}>
        <button onClick={toggleVisibility}>{props.buttonLabel}</button>
      </div>
      <div style={showWhenVisible}>
        {props.children}
        <button onClick={toggleVisibility}>cancel</button>
      </div>
    </div>
  )
})

export default Togglable
  • App側でtoggleVisibilityが使えるようになったので、使いましょう
const App = () => {
  // ...
  const addNote = (noteObject) => {
    noteFormRef.current.toggleVisibility()
    noteService
      .create(noteObject)
      .then(returnedNote => {     
        setNotes(notes.concat(returnedNote))
      })
  }
  // ...
}

  • まとめると...
  • useImperativeHandle関数はReactのフック
  • コンポーネント内の関数をコンポーネントの外から呼び出すために使用する
  • コードが少し見苦しいのが欠点(従来のclassベースのコンポーネントのほうがキレイに実装できる唯一のケース)
  • なお、コンポーネントへのアクセス以外にもrefの使い所はある

【独自】TypeScriptの場合

TypeScriptの場合、型情報付与が必要だが複雑で難しい

  • TogglableReact.forwardRef<Handler, React.PropsWithChildren<Props>>のように<>で型を付ける
    • <>の中の一個目はuseImperativeHandleに入れる内容、ここではtoggleVisibilityの型を入れる
    • <>の中の二個目はpropsの型を入れるのだが、このコンポーネントではchildrenを使いたいのでReact.PropsWithChildren<Props>にする
Togglable.tsx
//...

type Handler = {
  toggleVisibility: () => void;
};

const Togglable = React.forwardRef<Handler, React.PropsWithChildren<Props>>((props, ref) => {
  
  //...

  useImperativeHandle(ref, () => {
    return {
      toggleVisibility,
    };
  });

  //...
});

export default Togglable;
  • useRefの中は上記でいうHandler型にする
    • これをしないとnoteFormRefundefinedになり、noteFormRef.current.toggleVisibility()が呼び出せない
  • Togglable側のprops型をPropsWithChildrenにしておかないと、こっち側でchildrenなんて無いよって怒られてしまう
App.tsx
const App: React.FC = () => {
  //...
  const noteFormRef = useRef({} as { toggleVisibility: () => void });

  //...

  const addNote = async (noteObject: NewNote) => {
    noteFormRef.current.toggleVisibility();
    const returnedNote = await noteService.create(noteObject);
    setNotes(notes.concat(returnedNote));
  };

  //...

  const noteForm = () => (
    <Togglable buttonLabel="new note" ref={noteFormRef}>
      <NoteForm createNote={addNote} />
    </Togglable>
  );

  //...

}

参考にしたサイト

https://qiita.com/otanu/items/994fdf9d8fb7327d41d5

https://stackoverflow.com/questions/54654303/using-a-forwardref-component-with-children-in-typescript

コンポーネントについて補足

  • こんなふうにrefを個別に与えると、個別で開閉を制御するTogglableが作れるよ
<div>
  <Togglable buttonLabel="1" ref={togglable1}>
    first
  </Togglable>

  <Togglable buttonLabel="2" ref={togglable2}>
    second
  </Togglable>

  <Togglable buttonLabel="3" ref={togglable3}>
    third
  </Togglable>
</div>
pocpoc

演習問題 5.5.から5.10.

5.5 ブログリストのフロントエンド、ステップ5

  • ブログ投稿フォームをボタンによるTogglableにしましょう
  • ブログが作成されたらフォームは閉じるようにしましょう

5.6 ブログリストのフロントエンド、ステップ6

  • 新規ブログ作成フォームを独自のコンポーネントに分離し、必要な状態を全て移動しましょう

5.7* ブログリストのフロントエンド (ステップ7)

  • 各ブログにボタンを追加し、クリックするとブログの詳細が見れるようにしましょう(テキストの画像参照)
  • 再度ボタンをクリックすると詳細は隠れる
  • likeボタンの処理は未実装でOK
  • サンプル画像ではCSSをいじってる
    • こんな感じでインラインスタイルを設定できるんでしたね(part2の復習)
const Blog = ({ blog }) => {
  const blogStyle = {
    paddingTop: 10,
    paddingLeft: 2,
    border: 'solid',
    borderWidth: 1,
    marginBottom: 5
  }

  return (
    <div style={blogStyle}>
      <div>
        {blog.title} {blog.author}
      </div>
      // ...
  </div>
)}
  • Togglableとやってることは似ていますが、それを直接は流用できません
  • 各Blogに表示形式を制御する状態を追加するのが一番楽

5.8*: ブログリストのフロントエンド, step8

  • likesボタンを実装しましょう
  • PUTの動作はBlogのデータ全てを置き換えるものなので、likes以外の値も渡すことに注意

5.9*: ブログリストのフロントエンド, step9

  • Blogをlikesの数で並び替えましょう
  • sort()で並び替え可能
    • Array.sort()の中にはコンペア関数を入れられる
    • コンペア関数は2つの要素をとる関数で、戻り値が正の数だとその2つを入れ替える

5.10*: ブログリストのフロントエンド, step10

  • ブログ記事を削除するボタンを追加しましょう
    • 確認ダイアログにはwindow.confirm()が使えるよ
    • 作成者のみが削除できることに注意
pocpoc

PropTypes

  • Togglableコンポーネントは属性buttonLabelでボタンのテキストが与えられることが前提になっている
    • <Togglable buttonLabel="login">hogehoge</Togglable>
    • buttonLabelが与えられないとテキストなしのボタンになってしまう
  • 属性buttonLabelを必須属性にしましょう
  • prop-typesパッケージでできる
  • が、ここでは独自でTypeScriptで実装しているので、prop-typesはなくともできている
prop-typesの説明(TypeScriptの場合は不要)
  • インストール: npm install prop-types
  • こんな感じでpropsの条件を設定できる
import PropTypes from 'prop-types'

const Togglable = React.forwardRef((props, ref) => {
  // ..
})

Togglable.propTypes = {
  buttonLabel: PropTypes.string.isRequired
}
  • 上記設定した上でbuttonLabelなしで使うと、コンソールにエラーメッセージが表示される
    • あくまでアプリは動作してしまい、定義を強制する力はない
    • ブラウザのコンソールに表示するというアプローチは望ましくはないことに注意
  • LoginFormコンポーネントのPropTypesも設定すると、こんな感じ
import PropTypes from 'prop-types'

const LoginForm = ({
   handleSubmit,
   handleUsernameChange,
   handlePasswordChange,
   username,
   password
  }) => {
    // ...
  }

LoginForm.propTypes = {
  handleSubmit: PropTypes.func.isRequired,
  handleUsernameChange: PropTypes.func.isRequired,
  handlePasswordChange: PropTypes.func.isRequired,
  username: PropTypes.string.isRequired,
  password: PropTypes.string.isRequired
}

ESlint

  • フロントエンドでもESlintを使ってみましょう
  • create-react-appでESlintがインストールされているので.eslintrc.jsを定義するだけ
  • eslint --initは使用しないこと
    • create-react-appと互換性のない最新版のESlintが別途インストールされてしまうので
  • 次の章でフロントエンドのテストに着手するが、望ましくないLinterエラーを避けるため、eslint-plugin-jestパッケージをインストールする
    • npm install -save-dev eslint-plugin-jest
  • .eslintrcを作成する
    • テキストに記載されている設定や次のページを参考にしつつ、自分で設定しました
    • extendsplugin:react/recommendedを追加
    • pluginsreactjestを追加
    • env"jest/globals": trueを追加
    • rules.react/prop-typesはTypeScriptにより不要なのでoff
    • ParserOptionにちょこちょこ追加
    • settingsでReactのバージョン自動検出を追加

https://qiita.com/sprout2000/items/ee4fc97f83f45ba1d227

.eslintrc
{
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:@typescript-eslint/recommended-requiring-type-checking",
    "plugin:react/recommended"
  ],
  "plugins": ["react", "@typescript-eslint", "jest"],
  "env": {
    "browser": true,
    "es6": true,
    "node": true,
    "jest/globals": true
  },
  "rules": {
    "@typescript-eslint/semi": ["error"],
    "@typescript-eslint/explicit-function-return-type": "off",
    "@typescript-eslint/explicit-module-boundary-types": "off",
    "@typescript-eslint/restrict-template-expressions": "off",
    "@typescript-eslint/restrict-plus-operands": "off",
    "@typescript-eslint/no-unsafe-member-access": "off",
    "@typescript-eslint/no-unused-vars": [
      "error",
      { "argsIgnorePattern": "^_" }
    ],
    "no-case-declarations": "off",
    "react/prop-types": "off"
  },
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "project": "./tsconfig.json",
    "ecmaFeatures": {
      "jsx": true
    },
    "ecmaVersion": 2018,
    "sourceType": "module"
  },
  "settings": {
    "react": {
      "version": "detect"
    }
  }
}

  • ESLintプラグインとVSCodeを併用している場合、追加で設定が必要かも?

    • Failed to load plugin react: Cannot find module 'eslint-plugin-react'というエラーが出る場合、追加設定が必要
    • settings.json"eslint.workingDirectories": [{ "mode": "auto" }]を追加すればよい
    • 詳細はコチラを参照
    • 自分の環境では特に出なかった
  • .eslintignoreを作成し、buildnode_modulesはLint対象外にする

node_modules
build
.eslintrc
  • lintを実行するnpmスクリプトeslintも作っておく
    また、lintを実行するためのnpmスクリプトを作成してみましょう。
"lint": "eslint ."
  • ESlintを入れるとTogglableコンポーネントでComponent definition is missing display nameと怒られる
    • react-devtoolsでも、コンポーネント名がAnonymousになってることがわかる
    • Togglable.displayNameの定義を追加しておく
Togglable.tsx
import React, { useState, useImperativeHandle } from 'react'

const Togglable = React.forwardRef<Handler, React.PropsWithChildren<Props>>(
  // ...
);

Togglable.displayName = 'Togglable'

export default Togglable
pocpoc

練習問題 5.11.~5.12.

5.11: ブログリストのフロントエンド、ステップ11

  • アプリケーションのコンポーネントの1つにPropTypesを定義しましょう
    • TypeScriptを使用しているので割愛

5.12: ブログ・リスト・フロントエンド, step12

  • .eslintrcを定義し、エラーをすべて修正しましょう
    • .eslintrcは本文での設定をそのまま使う
    • 手元の環境では特にエラーは出なかったので、設定して完了