アクセシビリティ (A11y)
React Hook Form はネイティブフォームバリデーションをサポートします。 これにより、独自のルールで input のバリデーションを行うことができます。 私たちのほとんどはカスタムデザインとレイアウトを適用してフォームを構築しますが、 フォームのアクセシビリティ (A11y) を保証することも私たちの責任です。
下記のコードの例は、意図したとおりのバリデーションが動作しますが、 アクセシビリティについては改良することができます。
import React from "react"; import { useForm } from "react-hook-form"; export default function App() { const { register, handleSubmit, errors } = useForm(); const onSubmit = data => console.log(data); return ( <form onSubmit={handleSubmit(onSubmit)}> <label htmlFor="name">Name</label> <input type="text" id="name" name="name" ref={register({ required: true, maxLength: 30 })} /> {errors.name && errors.name.type === "required" && <span>This is required</span>} {errors.name && errors.name.type === "maxLength" && <span>Max length exceeded</span> } <input type="submit" /> </form> ); }
下記のコードの例は、 ARIA を活用した改良版です。
import React from "react"; import { useForm } from "react-hook-form"; export default function App() { const { register, handleSubmit, errors } = useForm(); const onSubmit = (data) => console.log(data); return ( <form onSubmit={handleSubmit(onSubmit)}> <label htmlFor="name">Name</label> {/* use aria-invalid to indicate field contain error */} <input type="text" id="name" name="name" aria-invalid={errors.name ? "true" : "false"} ref={register({ required: true, maxLength: 30 })} /> {/* use role="alert" to announce the error message */} {errors.name && errors.name.type === "required" && ( <span role="alert">This is required</span> )} {errors.name && errors.name.type === "maxLength" && ( <span role="alert">Max length exceeded</span> )} <input type="submit" /> </form> ); }
この改良後、スクリーンリーダーはこのように話すでしょう: “Name, edit, invalid entry, This is required.”
ウィザードフォーム・ファンネル
In this video tutorial, I have demonstrated the core concept of how to build multiple steps funnel with React Hook Form.
異なるページやセクション間でユーザーの情報を収集することは非常に一般的です。 このような場合、異なるページやセクション間でのユーザーの入力値を、 状態管理ライブラリを使用して保存しておくことをお勧めします。 この例では、状態管理ライブラリとして little state machine (より身近なものであれば、 redux として置き換えることができます) を使用します。
♦
ステップ1: ルーティングとストアを設定します。
import React from "react"; import { BrowserRouter as Router, Route } from "react-router-dom"; import { StateMachineProvider, createStore } from "little-state-machine"; import Step1 from "./Step1"; import Step2 from "./Step2"; import Result from "./Result"; createStore({ data: {} }); export default function App() { return ( <StateMachineProvider> <Router> <Route exact path="/" component={Step1} /> <Route path="/step2" component={Step2} /> <Route path="/result" component={Result} /> </Router> </StateMachineProvider> ); }
ステップ2: ページを作成し、フォームの送信データを収集し、 そのデータをストアに送信して次のページに移動するようにします。
import React from "react"; import { useForm } from "react-hook-form"; import { withRouter } from "react-router-dom"; import { useStateMachine } from "little-state-machine"; import updateAction from "./updateAction"; const Step1 = props => { const { register, handleSubmit } = useForm(); const { action } = useStateMachine(updateAction); const onSubmit = data => { action(data); props.history.push("./step2"); }; return ( <form onSubmit={handleSubmit(onSubmit)}> <input name="firstName" ref={register} /> <input name="lastName" ref={register} /> <input type="submit" /> </form> ); }; export default withRouter(Step1);
ステップ3: 最終的に、ストア内のすべてのフォームデータを使用して、 フォームを送信したりフォームデータの結果を表示します。
import React from "react"; import { useStateMachine } from "little-state-machine"; import updateAction from "./updateAction"; const Result = props => { const { state } = useStateMachine(updateAction); return <pre>{JSON.stringify(state, null, 2)}</pre>; };
上記のパターンに従って、複数のページ間でのユーザーの入力データを収集して、 ウィザードフォーム・ファンネルを構築できるはずです。
スマートフォームコンポーネント
ここでのアイデアは、input とフォームを簡単に組み合わせることができるということです。Form
コンポーネントを作成して、フォームデータを自動的に収集します。
import React from "react"; import { Form, Input, Select } from "./Components"; export default function App() { const onSubmit = data => console.log(data); return ( <Form onSubmit={onSubmit}> <Input name="firstName" /> <Input name="lastName" /> <Select name="gender" options={["female", "male", "other"]} /> <Input type="submit" value="Submit" /> </Form> ); }
♦
各コンポーネントがどのように構成されているか見てみましょう。
Form
Form
コンポーネントの責任は、全ての react-hook-form
のメソッドを子コンポーネントに注入することです。
import React from "react"; import { useForm } from "react-hook-form"; export default function Form({ defaultValues, children, onSubmit }) { const methods = useForm({ defaultValues }); const { handleSubmit } = methods; return ( <form onSubmit={handleSubmit(onSubmit)}> {React.Children.map(children, child => { return child.props.name ? React.createElement(child.type, { ...{ ...child.props, register: methods.register, key: child.props.name } }) : child; })} </form> ); }
Input / Select
Input / Select
コンポーネントの責任は、自分自身を react-hook-form
に登録することです。
import React from "react"; export function Input({ register, name, ...rest }) { return <input name={name} ref={register} {...rest} />; } export function Select({ register, options, name, ...rest }) { return ( <select name={name} ref={register} {...rest}> {options.map(value => ( <option key={value} value={value}> {value} </option> ))} </select> ); }
Form
コンポーネントを使用して、react-hook-form
の props
を子コンポーネントに注入することで、 アプリケーションで複雑なフォームを簡単に作成及び組み合わせることができます。
フィールド配列
この機能は、React Hook Formが提供する最良の機能の一つです。 この機能を実現するために (他のライブラリのように) コンポーネントをインポートする代わりに、 既存の HTML マークアップを活用することができます。 key は、 name
属性にあります。 React Hook Form において、 name
属性はあなたが使用したいデータ構造を表します。
注意: 私たちは、複雑なシナリオのためのカスタムフック useFieldArray も作成しました。
下記の例は、input の name
属性を操作してどのようにフィールド配列を作成できるかを示しています。
注意: アプリケーションにフィールドの削除や挿入、追加、先頭に追加などの機能が必要な場合は、 Controller を使用した実装のリンクを参照して下さい。
import React from "react"; import { useForm, useFieldArray } from "react-hook-form"; function App() { const { register, control, handleSubmit, reset, trigger, setError } = useForm({ // defaultValues: {}; you can populate the fields by this attribute }); const { fields, append, prepend, remove, swap, move, insert } = useFieldArray({ control, name: "test" }); return ( <form onSubmit={handleSubmit(data => console.log(data))}> <ul> {fields.map((item, index) => ( <li key={item.id}> <input name={`test[${index}].firstName`} ref={register()} defaultValue={item.firstName} // make sure to set up defaultValue /> <Controller as={<input />} name={`test[${index}].lastName`} control={control} defaultValue={item.lastName} // make sure to set up defaultValue /> <button type="button" onClick={() => remove(index)}>Delete</button> </li> ))} </ul> <button type="button" onClick={() => append({ firstName: "appendBill", lastName: "appendLuo" })} > append </button> <input type="submit" /> </form> ); }
エラーメッセージ
エラーメッセージは、入力に関連する問題があるときにユーザーに視覚的なフィードバックを与えることです。 React Hook Form では、エラーを簡単に取得できるように errors オブジェクトを提供しています。 ただし、画面のレンダリングエラーを改善する方法はいくつかあります。
Register
register
時にエラーメッセージを埋め込み、value
属性にエラーメッセージを簡単に挿入することができます。例:<input name="test" ref={register({ maxLength: { value: 2, message: "error message" } })} />
Optional Chaining
Optional chaining 演算子である
?.
は、null
またはundefined
によって発生するエラーを気にせずにerrors
オブジェクトを読み取ることができます。errors?.firstName?.message
Lodash
get
プロジェクトで lodash を使用している場合、lodash の
get
関数を活用することができます。例:get(errors, 'firstName.message')
接続フォーム
フォームを作成するときに、深くネストされたコンポーネントツリーの中に input が存在することがあり、 そのような場合は FormContext が非常に便利です。ConnectForm
コンポーネントを作成して React のrenderProps を活用することで、 DX を更に向上することができます。 ConnectForm
コンポーネントの利点は、input をどこからでも React Hook Form に接続できることです。
import { FormProvider, useForm, useFormContext } from "react-hook-form"; export const ConnectForm = ({ children }) => { const methods = useFormContext(); return children({ ...methods }); }; export const DeepNest = () => ( <ConnectForm> {({ register }) => <input ref={register} name="deepNestedInput" />} </ConnectForm> ); export const App = () => { const methods = useForm(); return ( <FormProvider {...methods} > <form> <DeepNest /> </form> </FormProvider> ); }
FormContext パフォーマンス
React Hook Form の FormContext は、 React の Context API 上に構築されています。 これにより、全ての階層で手動で props を渡す必要なく、 コンポーネントツリーを介してデータを渡す問題を解決します。 これにより、React Hook Form は状態を更新する度に、 コンポーネントツリーが再レンダリングされる問題を引き起こしますが、 必要に応じて下記の例のようにアプリを最適化することができます。
import React, { memo } from "react"; import { useForm, FormProvider, useFormContext } from "react-hook-form"; // we can use React.memo to prevent re-render except isDirty state changed const NestedInput = memo( ({ register, formState: { isDirty } }) => ( <div> <input name="test" ref={register} /> {isDirty && <p>This field is dirty</p>} </div> ), (prevProps, nextProps) => prevProps.formState.isDirty === nextProps.formState.isDirty ); export const NestedInputContainer = ({ children }) => { const methods = useFormContext(); return <NestedInput {...methods} />; }; export default function App() { const methods = useForm(); const onSubmit = data => console.log(data); console.log(methods.formState.isDirty); // make sure formState is read before render to enable the Proxy return ( <FormProvider {...methods}> <form onSubmit={methods.handleSubmit(onSubmit)}> <NestedInputContainer /> <input type="submit" /> </form> </FormProvider> ); }
条件付き制御されたコンポーネント
React Hook Form を使用すると、条件付きフィールドを非常にシンプルに扱えます。 input がコンポーネントツリーから削除されると、自動的に unregister
されるからです。 そのような動作をこちらの例に示します 。ただし、制御されたコンポーネントでは ref
が登録されていないため、 同じように自動的に登録解除されません。 対処方法は下記の通りです。
Controller をインポートしてコンポーネントをラップし、 登録および登録解除を管理できるようにします
useEffect
を使用して、カスタム登録として input を登録し、 コンポーネントのアンマウント後に登録を解除します
下記に例を示します:
import * as React from "react"; import { useForm } from "react-hook-form"; import { ModalForm } from "./ModalForm"; type FormValues = { toggle: boolean; mail: string; ghost: string; keepValue: string; }; export default function App() { const [modalFormData, setModalFormData] = React.useState(""); const { watch, register, setValue, getValues, handleSubmit } = useForm<FormValues>(); const [showModal, setShowModal] = React.useState(false); const { toggle, mail } = watch(); React.useEffect(() => { setValue("mail", modalFormData); }, [setValue, modalFormData]); const onSubmit = (data: FormValues) => console.log(data); return ( <> <form onSubmit={handleSubmit(onSubmit)}> <input type="checkbox" name="toggle" ref={register} /> {toggle && ( <button type="button" onClick={() => setShowModal(!showModal)}> Show Modal </button> )} <input name="mail" placeholder="mail" ref={register} /> <input name="keepValue" placeholder="keepValue" ref={register} style={{ display: toggle ? "block" : "none" // toggle the visbility of an input }} /> <input type="submit" /> </form> {/* working with a modal pop up, make sure to create separate form */} {showModal && ( <ModalForm mail={mail} setModalFormData={setModalFormData} /> )} </> ); }
制御されたコンポーネントと非制御コンポーネントの組み合わせ
React Hook Form は、非制御コンポーネントをサポートしていますが、 制御されたコンポーネントとも互換性があります。 Material-UI や Antd などの UI ライブラリのほとんどは、 制御されたコンポーネントのみをサポートして構築されています。 さらに、React Hook Form を使用することで制御されたコンポーネントの再レンダリングも最適化されます。 下記は、制御されたコンポーネントと非制御コンポーネントのフォームバリデーションを組み合わせた例です。
import React, { useEffect } from "react"; import { Input, Select, MenuItem } from "@material-ui/core"; import { useForm, Controller } from "react-hook-form"; const defaultValues = { select: "", input: "" }; function App() { const { handleSubmit, reset, watch, control } = useForm({ defaultValues }); const onSubmit = data => console.log(data); return ( <form onSubmit={handleSubmit(onSubmit)}> <Controller as={ <Select> <MenuItem value={10}>Ten</MenuItem> <MenuItem value={20}>Twenty</MenuItem> </Select> } control={control} name="select" defaultValue={10} /> <Input inputRef={register} name="input" /> <button type="button" onClick={() => reset({ defaultValues })}>Reset</button> <input type="submit" /> </form> ); }
バリデーションリゾルバーを使ったカスタムフック
バリデーションリゾルバーとしてカスタムフックを構築できます。 カスタムフックは yup/Joi/Superstruct を使って、 バリデーションリゾルバーの中で使われるバリデーションメソッドに簡単に統合することができます。
- メモ化されたバリデーションスキームを定義する (または依存関係を持たないならばコンポーネントの外にバリデーションスキームを定義する)
- バリデーションスキームを渡してカスタムフックを使う
- useForm フックにバリデーションリゾルバーを渡す
import React, { useCallback, useMemo } from "react"; import { useForm } from "react-hook-form"; import * as yup from "yup"; const useYupValidationResolver = validationSchema => useCallback( async data => { try { const values = await validationSchema.validate(data, { abortEarly: false }); return { values, errors: {} }; } catch (errors) { return { values: {}, errors: errors.inner.reduce( (allErrors, currentError) => ({ ...allErrors, [currentError.path]: { type: currentError.type ?? "validation", message: currentError.message } }), {} ) }; } }, [validationSchema] ); export default function App() { const validationSchema = useMemo( () => yup.object({ firstName: yup.string().required("Required"), lastName: yup.string().required("Required") }), [] ); const resolver = useYupValidationResolver(validationSchema); const { handleSubmit, register } = useForm({ resolver }); return ( <form onSubmit={handleSubmit(data => console.log(data))}> <input name="firstName" ref={register} /> <input name="lastName" ref={register} /> <input type="submit" /> </form> ); }
バーチャルリストで動かす
データの表があるシナリオを想像してください。 この表は100または1000以上の列を含み、 それぞれの列には入力欄があります。 一般的にはビューポート内にあるアイテムのみをレンダリングしますが、 これはアイテムがビューの外に出た時にDOMから削除されて、 再追加されるため問題が発生します。 これはアイテムが再びビューポートに入った時に、 アイテムがデフォルトの値にリセットされる原因となります。
この問題を回避するためには、 手動でフィールドを登録し、 プログラムによってフィールドの値をセットします。
以下に react-window を使用した例を示します。
import React from "react"; import { FormContext, useForm, useFormContext } from "react-hook-form"; import { VariableSizeList as List } from "react-window"; const items = Array.from(Array(1000).keys()).map((i) => ({ title: "List ${i}", quantity: Math.floor(Math.random() * 10), })); const WindowedRow = React.memo(({ index, style, data, setValue }) => { const qtyKey = "[${index}].quantity"; const qty = getValues()[index].quantity ?? items[index].quantity; return ( <input // Rather than ref={register}, we use defaultValue and setValue defaultValue={qty} onChange={(e) => { setValue( qtyKey, isNaN(Number(e.target.value)) ? 0 : Number(e.target.value) ); }} /> ); }); export default React.memo(({ items }) => { const formMethods = useForm({ defaultValues: items }); const onSubmit = (data) => console.log(data); // We manually call register here for each field. React.useEffect(() => { items.forEach((item, idx) => { formMethods.register("[${idx}].quantity"); }); }, [formMethods, items]); return ( <form onSubmit={formMethods.handleSubmit(onSubmit)}> <List itemCount={items.length} itemSize={() => 50} itemData={items} {{ ...formMethods }} > {WindowedRow} </List> <button type="submit">Submit</button> </form> ); });
フォームをテストする
テストはバグやミスを防いだり、 コードをリファクタリングする時にコードの安全性を保証するため、 とても重要なものです。
私たちは testing-library を使うことをお勧めします。なぜなら、テストコードはシンプルで、そしてテストはよりユーザーの行動にフォーカスしています。
♦
Step 1: テスト環境を設定する
react-hook-form は DOM からアンマウントされた input 要素を検出するために MutationObserver
を使うため @testing-library/jest-dom を jest
の最新バージョンとともにインストールしてください。
注意: React Native を使用している場合は @testing-library/jest-dom は必要ありません。
npm install -D @testing-library/jest-dom
そして @testing-library/jest-dom をインポートするために setup.js
を作成してください。
注意: React Native を使用している場合は setup.js を作成し、window
オブジェクトを定義する必要があります。
import "@testing-library/jest-dom";
最後に、setup.js
を jest.config.js
で読み込む必要があります。
module.exports = { setupFilesAfterEnv: ["<rootDir>/setup.js"] // or .ts for TypeScript App // ...other settings };
Step 2:ログインフォームを作成する
私たちは、role 属性を設定しています。それらの属性はテストを書いたり、アクセシビリティーを改善する時に役に立ちます。 詳しくは testing-library のドキュメントを参照してください。
import React from "react"; import { useForm } from "react-hook-form"; export default function App({ login }) { const { register, handleSubmit, errors, reset } = useForm(); const onSubmit = async data => { await login(data.email, data.password); reset(); }; return ( <form onSubmit={handleSubmit(onSubmit)}> <label htmlFor="email">email</label> <input id="email" name="email" ref={register({ required: "required", pattern: { value: /S+@S+.S+/, message: "Entered value does not match email format" } })} type="email" /> {errors.email && <span role="alert">{errors.email.message}</span>} <label htmlFor="password">password</label> <input id="password" name="password" ref={register({ required: "required", minLength: { value: 5, message: "min length is 5" } })} type="password" /> {errors.password && <span role="alert">{errors.password.message}</span>} <button type="submit">SUBMIT</button> </form> ); }
Step 3: テストを書く
テストでカバーしようとしているのは以下の条件です:
送信時のテストに失敗
handleSubmit
は非同期で実行されるので、 送信したことを検出するためにwaitFor
やfind*
メソッドを使います。それぞれの入力に関するバリデーションをテストする
異なる要素を探す時に
*ByRole
を使います。なぜなら、このようにしてユーザーはUIコンポーネントを認識するからです。送信時のテストに成功
import React from "react"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import App from "./App"; const mockLogin = jest.fn((email, password) => { return Promise.resolve({ email, password }); }); describe("App", () => { beforeEach(() => { render(<App login={mockLogin} />); }); it("should display required error when value is invalid", async () => { fireEvent.submit(screen.getByRole("button")); expect(await screen.findAllByRole("alert")).toHaveLength(2); expect(mockLogin).not.toBeCalled(); }); it("should display matching error when email is invalid", async () => { fireEvent.input(screen.getByRole("textbox", { name: /email/i }), { target: { value: "test" } }); fireEvent.input(screen.getByLabelText("password"), { target: { value: "password" } }); fireEvent.submit(screen.getByRole("button")); expect(await screen.findAllByRole("alert")).toHaveLength(1); expect(mockLogin).not.toBeCalled(); expect(screen.getByRole("textbox", { name: /email/i }).value).toBe("test"); expect(screen.getByLabelText("password").value).toBe("password"); }); it("should display min length error when password is invalid", async () => { fireEvent.input(screen.getByRole("textbox", { name: /email/i }), { target: { value: "test@mail.com" } }); fireEvent.input(screen.getByLabelText("password"), { target: { value: "pass" } }); fireEvent.submit(screen.getByRole("button")); expect(await screen.findAllByRole("alert")).toHaveLength(1); expect(mockLogin).not.toBeCalled(); expect(screen.getByRole("textbox", { name: /email/i }).value).toBe( "test@mail.com" ); expect(screen.getByLabelText("password").value).toBe("pass"); }); it("should not display error when value is valid", async () => { fireEvent.input(screen.getByRole("textbox", { name: /email/i }), { target: { value: "test@mail.com" } }); fireEvent.input(screen.getByLabelText("password"), { target: { value: "password" } }); fireEvent.submit(screen.getByRole("button")); await waitFor(() => expect(screen.queryAllByRole("alert")).toHaveLength(0)); expect(mockLogin).toBeCalledWith("test@mail.com", "password"); expect(screen.getByRole("textbox", { name: /email/i }).value).toBe(""); expect(screen.getByLabelText("password").value).toBe(""); }); });
Strictly Typed
厳密な入力フォームを構築することは、登録時に柔軟な名前属性の性質上、困難な場合があります。これを可能にするためのプラグインを追加しました。
npm install @hookform/strictly-typed
入力を TypedController
でラップする必要があります。 入力された文字列名を配列の形に変換します。
import { useTypedController } from '@hookform/strictly-typed'; import { useForm } from 'react-hook-form'; import { TextField, Checkbox } from '@material-ui/core'; type FormValues = { flat: string; nested: { object: { test: string }; array: { test: boolean }[]; }; }; export default function App() { const { control, handleSubmit } = useForm<FormValues>(); const TypedController = useTypedController<FormValues>({ control }); const onSubmit = handleSubmit((data) => console.log(data)); return ( <form onSubmit={onSubmit}> <TypedController name="flat" defaultValue="" render={(props) => <TextField {...props} />} /> <TypedController as="textarea" name={['nested', 'object', 'test']} defaultValue="" rules={{ required: true }} /> <TypedController name={['nested', 'array', 0, 'test']} defaultValue={false} render={({ onChange, value, ...rest }) => ( <Checkbox {...rest} onChange={e => onChange(e.target.checked)} checked={value} /> )} /> {/* ❌: Type '"notExists"' is not assignable to type 'DeepPath<FormValues, "notExists">'. */} <TypedController as="input" name="notExists" defaultValue="" /> {/* ❌: Type 'number' is not assignable to type 'string | undefined'. */} <TypedController as="input" name={['nested', 'object', 0, 'notExists']} defaultValue="" /> {/* ❌: Type 'true' is not assignable to type 'string | undefined'. */} <TypedController as="input" name="flat" defaultValue={true} /> <input type="submit" /> </form> ); }
Extend Controller
React Hook Formの強みは、プリミティブであることと シンプルな API を使用することで、より良い開発者体験と軽量化を実現します。 図書館のために。別の隠された宝石もあります。 既存の機能をより強力なものにするために、それらのAPIをコンパイルします。 を使用して、これらのプリミティブ API から派生したコンポーネントをビルドします。この中では セクションで、Controller コンポーネントを見てみましょう。 機能を拡張します。
以下は標準的なものですController
:
<Controller name="test" control={control} render={props => <input {...props} />} />>
render
プロップは子コンポーネントにプロップを渡します。 これらは onChange, onBlur, value
です。を拡張することができます。 の機能を継承しています。isDirty, isTouched, warning
のラッパーを構築することでController
をラップするコンポーネント。
const PowerController = (props: Props) => { const { formState } = useFormContext(); // we are reading formState from context or you can pass down props too const isDirty = !!formState.dirtyFields[props.name]; const isTouched = !!formState.touched[props.name]; return ( <Controller control={props.control} name={props.name} defaultValue={props.defaultValue} render={(innerProps) => { return props.render({ ...innerProps, isDirty, // new isDirty prop isTouched, // new isTouched prop warning: props.warn(innerProps.value) // include warning message user }); }} /> ); };
変換と解析
ネイティブ入力の戻り値は通常 string
です。 形式で呼び出されない限り、valueAsNumber
または を使用します。valueAsDate
の下で詳細を読むことができます 。本節. しかし、完璧ではありません。isNaN
またはnull
の値です。のままにしておいた方が良いでしょう。 をコンポーネントレベルで変換します。次の例では の機能を含むように、Controller
を使用して 値の入力と出力を変換します。また、同様の の結果は、カスタムのregister
を使用しています。
const ControllerPlus = ({ control, transform, name, defaultValue }) => ( <Controller defaultValue={defaultValue} control={control} name={name} render={(props) => ( <input onChange={(e) => props.onChange(transform.output(e))} value={transform.input(props.value)} /> )} /> ); // usage below: <ControllerPlus<string, number> transform={{ input: (value) => isNaN(value) || value === 0 ? "" : value.toString(), output: (e) => { const output = parseInt(e.target.value, 10); return isNaN(output) ? 0 : output; } }} control={control} name="number" defaultValue="" />
あなたのサポートが必要です
React プロジェクトで React Hook Form が役立つと思う場合は、リポジトリとコントリビューターをサポートするためにスターを付けてください ❤