こんにちは、id:mashabow です。この記事は、Shopify開発を盛り上げる Advent Calendar 2020 の 8 日目にあたります。昨日は minozo さんの「ShopifyでARを実装する方法(2020/12版)」でした。
現在弊社では、ソーシャルPLUS の Shopify アプリ版を開発しており、わたしは埋め込みアプリ(Embedded App)のフロントエンド担当です。わかりやすく言えば、マーチャントの方に触っていただく設定画面を作っているわけです。
そんな設定画面に必要不可欠で、かつ意外と厄介なのがフォームの実装です。今回は、フォームの状態管理に @shopify/react-form というライブラリを使ってみました。この記事では、その @shopify/react-form の使い方を紹介したいと思います。
フォーム管理ライブラリを選ぶ
前提として、フレームワークには React(Next.js)を、コンポーネントライブラリは Shopify 本家の Polaris を使用しています。Polaris はあくまでもコンポーネントライブラリなので、フォームの状態を管理するための機能は入っていません。useState
を使うなり、他のフォーム管理ライブラリを使うなりして、自分でよしなに管理する必要があります。
実は、最初は軽量・高速な React Hook Form を使おうかとぼんやり考えていました。しかしよくよく見てみると、Polaris のコンポーネントは React Hook Form に必要な ref
を受け取ることができません。Controlled components として作られていますしね。一応、React Hook Form の Controller
でラップして使う手があるようですが、それも手間がかかりそうです。
とりあえず他の人のやり方を参考にしようか……と検索してみたところ、どこかで @shopify/react-form というライブラリを見つけました1。
README を読むと
The hooks provided here also work swimmingly with @shopify/polaris.
との言葉があり、期待が持てそうです。Shopify 公式サイトからの言及が皆無なのが謎で若干不安になりますが、Shopify 公式のパッケージです。なお、この記事の執筆時点では v0.9.0 が最新です。
@shopify/react-form でフォームを定義する
前置きが長くなりましたが、実際に使ってみましょう。使い方としては、Formik に似ています。詳しいところは README に譲るとして、まずは単純な例を用意しました。
最初に、useForm
でフォームの定義をします。その中では、useField
を使って各フィールドの定義をしています。fields
のキー(下の例では name
にしました)が、フィールドの識別子になります。
const { fields, submit } = useForm({ fields: { // 「名前」フィールドの定義 name: useField({ value: "Shopify大好きパーソン", // 初期値 validates: [ notEmpty("必須項目です"), lengthMoreThan(2, "3文字以上で入力してください") ] }), ... }, onSubmit: ... });
useField
の引数の value
は初期値です。実際には空 ""
だったり、前回保存した値をバックエンド API から取ってきて入れたりすることが多いでしょう。validates
には、バリデーションに使う関数を指定します。上の例では @shopify/react-form に組み込まれているバリデーション関数を使っていますが、種類はそれほど多くはありません。自作することもできます。
useForm
の onSubmit
には submit 時の処理を書きますが、これは次節で説明します。
さて、これでフォームの定義ができました。useForm
の戻り値の fields
に、各フィールドの状態やハンドラが入っているので、今度はこれを Polaris のコンポーネントに結びつけます。上の例では name
という識別子のフィールドを定義したので、fields.name
を spread して
<TextField label="名前" {...fields.name} />
と渡してやるだけで TextField
コンポーネントが動作します。@shopify/react-form が、Polaris のインターフェイスに合わせて作られているおかげですね。べんり!
また、先ほどバリデーション関数を指定したので、バリデーションもちゃんと動きます。
blur 時にバリデーションが実行され、エラーが表示されているのがわかります。
submit 処理を実装する
フォームが一通りできたら、submit 時の処理を実装しましょう。useFleid
の引数の onSubmit
に、async で処理内容を書きます。各フィールドの値は、引数 fieldValues
として onSubmit
に渡されます。
以下に例を示します。実際のアプリでは、onSubmit
の中で fieldValues
をバックエンドに送信するケースがほとんどかと思いますが、今回はサンプルなので、「送信して結果が返ってきたつもり」の処理を入れています。
const { fields, submit } = useForm({ fields: { name: useField({ ... }), age: useField({ ... }), christmas: useField({ ... }), }, onSubmit: async (fieldValues) => { console.log(fieldValues); // => { name: "...", age: "...", christmas: "..." } // 実際のアプリではバックエンドに送信するが、 // const result = await submitToBackend(fieldValues); // 今回はサンプルなので、適当な待ち時間を挟んでここで結果を返す await new Promise((resolve) => setTimeout(resolve, 1000)); return { status: "success" }; } });
onSubmit
関数の戻り値によって、submit が成功したか失敗したかを表現します。成功の場合は { status: "success" }
を返すきまりになっています。失敗した場合は、{ status: "fail", errors: [...] }
という形式ですが、こちらについては次の節で説明します。
useForm
の戻り値に、submit を実行する submit
関数が入っているので、Form
コンポーネントの onSubmit
prop に渡します。これで、[保存] ボタンをクリックしたときに、先ほど実装した onSubmit
関数の処理が実行されるようになりました2。
<Form onSubmit={submit}> <FormLayout> <TextField label="名前" {...fields.name} /> <TextField label="年齢" type="number" {...fields.age} /> <Select label="クリスマスといえば?" options={christmasOptions} {...fields.christmas} /> <Button submit primary>保存</Button> </FormLayout> </Form>
さらに使いやすくするために、
- submit 処理中は、[保存] ボタンを loading 状態にする
- どのフィールドも変更されていなれば、[保存] ボタンを disabled にする
という制御を追加しましょう。useForm
から submitting
, dirty
という boolean 値が返ってくるので、それを使えば簡単に実装できます。
const { fields, submit, submitting, dirty } = useForm({ ... });
<Button submit primary loading={submitting} disabled={!dirty}> 保存 </Button>
submit に失敗した場合
実際のアプリでは、submit してもバックエンド側のバリデーションに弾かれたり、そもそもバックエンドが落ちていたりと、失敗するケースがいろいろあります。失敗時の処理はどうすればいいでしょうか?
上で少し触れましたが、失敗した場合は { status: "fail", errors: [...] }
という形式でエラーの内容を返すようにします。以下にサンプルを挙げました。「名前が "Shopify大嫌いパーソン"
だったらエラーを返す」ようなバックエンドを想像してみてください。
const { fields, submit, submitErrors, submitting, dirty } = useForm({ fields: { name: useField({ ... }), age: useField({ ... }), christmas: useField({ ... }), }, onSubmit: async (fieldValues) => { // 今回はサンプルなので、適当な待ち時間を挟んでここで結果を返す await new Promise((resolve) => setTimeout(resolve, 1000)); const result = fieldValues.name === "Shopify大嫌いパーソン" ? // submit 失敗の例 { status: "fail" as const, errors: [ // 特定のフィールドにひもづかないエラー { message: "なんか失敗しました" }, // 特定のフィールドにひもづくエラー { message: "この名前は使えません", field: ["name"] } ] } : // submit 成功の例 { status: "success" as const }; return result; } });
このように、errors
には複数のエラーを含めることができます。また、field
プロパティによって、エラーとフィールドをひもづけることもできます。このひもづけを行うと、フィールドの傍にエラーメッセージが表示されるようになります。
一方、フィールドにひもづかないエラーは、そのままでは UI 上に何も表示されません。useForm
の戻り値の submitErrors
には、(フィールドにひもづくか否かに関わらず)すべてのエラーが入っているので、これを Banner
コンポーネントに表示させてみましょう。
<Form onSubmit={submit}> <FormLayout> {submitErrors.length > 0 && ( <Banner status="critical"> <p>保存に失敗しました。</p> <ul> {submitErrors.map(({ message }, i) => ( <li key={i}>{message}</li> ))} </ul> </Banner> )} {/* 略(フォームの中身) */} </FormLayout> </Form>
これでいい感じのフォームができあがりました! さわって試してみてください。
おまけの補足
Checkbox
やRadioButton
に props を渡す場合は、asChoiceField
を使う必要があります。- 実際の埋め込みアプリのフォームでは、さらに App Bridge の
ContextualSaveBar
を併用するのがおすすめです。機能としては Polaris のContexualSaveBar
と似ていますが、App Bridge のContextualSaveBar
を使った方が、一貫した UI/UX を提供できます。
おわりに
というわけで、@shopify/react-form を簡単に紹介しました。Shopify 公式が開発しているだけあって、手軽に Polaris と組み合わせられるところが嬉しいですね。
明日のアドベントカレンダーは mixlogue さんによる「サードパーティクッキーの問題を解決するApp Bridgeの新しい仕様、セッショントークンに対応する(Next.js版)」です。ちょうど弊アプリもセッショントークンを採用したところなので、mixlogue さんがどのように実装されたのか、とても気になります!
ではでは。