Feedforce Developer Blog

フィードフォース開発者ブログ

Shopify 埋め込みアプリのフォームを @shopify/react-form で作る

こんにちは、id:mashabow です。この記事は、Shopify開発を盛り上げる Advent Calendar 2020 の 8 日目にあたります。昨日は minozo さんの「ShopifyでARを実装する方法(2020/12版)」でした。

現在弊社では、ソーシャルPLUS の Shopify アプリ版を開発しており、わたしは埋め込みアプリ(Embedded App)のフロントエンド担当です。わかりやすく言えば、マーチャントの方に触っていただく設定画面を作っているわけです。

f:id:mashabow:20201207160849p:plain

そんな設定画面に必要不可欠で、かつ意外と厄介なのがフォームの実装です。今回は、フォームの状態管理に @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

www.npmjs.com

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 に組み込まれているバリデーション関数を使っていますが、種類はそれほど多くはありません。自作することもできます。

useFormonSubmit には submit 時の処理を書きますが、これは次節で説明します。

さて、これでフォームの定義ができました。useForm の戻り値の fields に、各フィールドの状態やハンドラが入っているので、今度はこれを Polaris のコンポーネントに結びつけます。上の例では name という識別子のフィールドを定義したので、fields.name を spread して

        <TextField label="名前" {...fields.name} />

と渡してやるだけで TextField コンポーネントが動作します。@shopify/react-form が、Polaris のインターフェイスに合わせて作られているおかげですね。べんり!

また、先ほどバリデーション関数を指定したので、バリデーションもちゃんと動きます。

f:id:mashabow:20201207104404g:plain

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>

f:id:mashabow:20201207104838g:plain

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 プロパティによって、エラーとフィールドをひもづけることもできます。このひもづけを行うと、フィールドの傍にエラーメッセージが表示されるようになります。

f:id:mashabow:20201207105611p:plain

一方、フィールドにひもづかないエラーは、そのままでは 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>

f:id:mashabow:20201207105711p:plain

これでいい感じのフォームができあがりました! さわって試してみてください。

おまけの補足

  • CheckboxRadioButton に props を渡す場合は、asChoiceField を使う必要があります。
  • 実際の埋め込みアプリのフォームでは、さらに App Bridge の ContextualSaveBar を併用するのがおすすめです。機能としては Polaris の ContexualSaveBar と似ていますが、App Bridge の ContextualSaveBar を使った方が、一貫した UI/UX を提供できます。

おわりに

というわけで、@shopify/react-form を簡単に紹介しました。Shopify 公式が開発しているだけあって、手軽に Polaris と組み合わせられるところが嬉しいですね。

明日のアドベントカレンダーは mixlogue さんによる「サードパーティクッキーの問題を解決するApp Bridgeの新しい仕様、セッショントークンに対応する(Next.js版)」です。ちょうど弊アプリもセッショントークンを採用したところなので、mixlogue さんがどのように実装されたのか、とても気になります!

ではでは。


  1. よく覚えていませんが、おそらくここ?

  2. ただし、onSubmit 関数が実行される前にクライアントサイドバリデーションが走ります。クライアントサイドバリデーションに引っかかった場合は、onSubmit 関数の中身は実行されません。