Open7
Full Stack open 2020 を読む【Part 5-b】
概要
これを読んだメモ。再翻訳の手間をなくすことを主な目的としてメモする。
リポジトリ
要約
- Reactコンポーネントは
<Parent>Code as you like...</Parent>
のように子要素を持てる(コンポーネントも入れられる)- 親からは
props.children
で子要素を呼び出せる - propの型は
React.PropsWithChildren<Props>
を使えばchildren
を使っても怒られない
- 親からは
- 状態や関数は共有されるものの最も近い祖先に置くべき
-
useRef
、useImperativeHandle
、forwardRef
を組み合わせると、子コンポーネント内で定義している関数などを親から呼び出し可能- ただしコードはあまりキレイじゃない
-
Array.sort()
で並び替えが可能(破壊関数)- パラメータにはコンペア関数を入れられる
- コンペア関数は2つの要素を引数に取り、戻り値に数値をとる
- 戻り値が正だと要素の順番を入れ替える
-
PropTypes
というライブラリがあるが、TypeScriptを使っているなら不要 - ESlintで
plugin:react/recommended
を継承するとReact向けのチェックができる- コンポーネントの
displayName
が無いと怒られる場合はComponentName.displayName = 'ComponentName'
のように設定して回避可能
- コンポーネントの
- jestでのテストをする場合は
eslint-plugin-jest
パッケージを入れる
以下、読メモ
適切な場合にのみログインフォームを表示する
- ログインフォームがデフォルトでは表示されないようにする
- 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>
フォームでの状態
- ここで、状態定義をどこのコンポーネントで持つべきか、という問題を考える
- React公式は「状態が共有されるものたちの最も近い祖先に置くべき」としている
- いま、ほぼ全ての状態は
App
にあるが、例えば状態newNote
はNoteForm
でしか使ってないので、そこで定義すべき。 -
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>
);
};
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
で括る - パラメータは
props
とref
-
useImperativeHandle
でtoggleVisibility
を返すことで、外部でこの関数を使えるようになる
- コンポーネント定義を
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の場合、型情報付与が必要だが複雑で難しい
-
Togglable
はReact.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
型にする- これをしないと
noteFormRef
がundefined
になり、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>
);
//...
}
参考にしたサイト
コンポーネントについて補足
- こんなふうに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>
演習問題 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()
が使えるよ - 作成者のみが削除できることに注意
- 確認ダイアログには
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
を作成する- テキストに記載されている設定や次のページを参考にしつつ、自分で設定しました
-
extends
にplugin:react/recommended
を追加 -
plugins
にreact
、jest
を追加 -
env
に"jest/globals": true
を追加 -
rules.react/prop-types
はTypeScriptにより不要なのでoff
-
ParserOption
にちょこちょこ追加 -
settings
でReactのバージョン自動検出を追加
.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
を作成し、build
とnode_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
の定義を追加しておく
- react-devtoolsでも、コンポーネント名が
Togglable.tsx
import React, { useState, useImperativeHandle } from 'react'
const Togglable = React.forwardRef<Handler, React.PropsWithChildren<Props>>(
// ...
);
Togglable.displayName = 'Togglable'
export default Togglable
練習問題 5.11.~5.12.
5.11: ブログリストのフロントエンド、ステップ11
- アプリケーションのコンポーネントの1つにPropTypesを定義しましょう
- TypeScriptを使用しているので割愛
5.12: ブログ・リスト・フロントエンド, step12
-
.eslintrc
を定義し、エラーをすべて修正しましょう-
.eslintrc
は本文での設定をそのまま使う - 手元の環境では特にエラーは出なかったので、設定して完了
-