React Hook Form

最近仕事でReact Hook Formを使っているのでここにメモを残す

React Hook Formとは

Reactでフォームを簡潔に書くことができるライブラリ https://react-hook-form.com/

React Hook Formを使うとuseStateを使うよりもレンダー回数を減らせたりバリデーション実装が楽になったりする

import { SubmitHandler, useForm } from 'react-hook-form'

// 入力値の型
type LoginInfo = {
  email: string
  password: string
}

export const LoginForm = (): JSX.Element => {
  const { handleSubmit, register } = useForm<LoginInfo>()

  // ログインボタンを押したときの処理
  const login: SubmitHandler<LoginInfo> = (loginInfo) => {
    console.log(loginInfo)
  }

  return (
    <form onSubmit={handleSubmit(login)}>
      <input type="email" placeholder="email" {...register('email')} />
      <input type="password" placeholder="Password" {...register('password')} />
      <button type="submit">ログイン</button>
    </form>
  )
}

ちなみにsubmitボタンをformタグで囲めない場合は、buttonタグのformプロパティにformのidと同じ文字列を書く

export const LoginForm = (): JSX.Element => {
  const { handleSubmit, register } = useForm<LoginInfo>()

  // ログインボタンを押したときの処理
  const login: SubmitHandler<LoginInfo> = (loginInfo) => {
    console.log(loginInfo)
  }

  return (
    <>
      <form id="login" onSubmit={handleSubmit(login)}>
        <input type="email" placeholder="email" {...register('email')} />
        <input type="password" placeholder="Password" {...register('password')} />
      </form>
      <button form="login" type="submit">ログイン</button>
    <>
  )
}

axiosでAPIをコールして認証切れだった場合、トークンを再取得してからリクエストをリトライする(リトライ回数上限付き)

const axiosConfig = {
  baseURL: baseUrl,
  retries: 1, // リトライ回数
  retryCount: 0,
}

const client = axios.create(axiosConfig)

// レスポンス時の処理
client.interceptors.response.use(
  response => {
    return response
  },
  async error => {
    if (error.response?.status === 401) {
      // 認証切れ
      if ((error.config.retries ?? 0) > (error.config.retryCount ?? 0)) {
        // リトライ回数が上限に達してない場合
        // リトライ回数を更新
        error.config.retryCount = (error.config.retryCount ?? 0) + 1
        // トークンを再発行
        const res = await client.post('/auth/v1/refresh', {
          username: xxxx,
        })
        // error.configからリクエスト情報を取り出す
        const config = error.config
        // config.headersは未定義になる可能性があるバグがあるため、暫定でJSON.parse
        config.headers = JSON.parse(
          JSON.stringify(config.headers || {})
        ) as RawAxiosRequestHeaders
        // トークンをヘッダーにつける
        config.headers['Authorization'] = `Bearer ${res.token}`
        // 元々のリクエストを再送信
        return client.request(config)
      }
    }
    return Promise.reject(error)
  }
)

Error Boundary

公式 - Error Boundary

error boundary は自身の子コンポーネントツリーで発生した JavaScript エラーをキャッチし、エラーを記録し、クラッシュしたコンポーネントツリーの代わりにフォールバック用の UI を表示する React コンポーネント

クラスコンポーネントに、ライフサイクルメソッドの static getDerivedStateFromError() か componentDidCatch() のいずれか(または両方)を定義すると、error boundary になる

つまりerror boundaryは通常クラスコンポーネントの書き方でしか定義できないが、react-error-boundaryというラッパーを使うと関数コンポーネントとして定義できる

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // 次のレンダリングでフォールバックUIが表示されるように状態を更新する
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // エラーをエラー報告サービスに記録する(任意)
    logErrorToMyService(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // 任意のカスタムフォールバックUIをレンダリングする
      return <h1>エラーが発生しました</h1>;
    }

    return this.props.children; 
  }
}

呼び出し方

<ErrorBoundary>
  <MyWidget />
</ErrorBoundary>

Suspense

Suspenseとは、React 18から追加された機能で、Suspenseを使用することで、その配下のツリーにレンダーする準備ができていないコンポーネントがあるときに表示するローディングインジケータを指定できる

使用前

import { useQuery } from 'react-query'

export const Content: React.FC = () => {
  const { isLoading, message } = useQuery('message', loadMessage)

  if (isLoading) {
    // ローディング中
    return <p>Loading...</p>
  }
  return <Message message={message} />
}

export const App: React.FC = () => {
  return <Content/>
}

使用後

import { Suspense } from 'react'
import { useQuery } from 'react-query'

export const Content: React.FC = () => {
  const { message } = useQuery('message', loadMessage)
  return <Message message={message} />
}

export const App: React.FC = () => {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <Content />
    </Suspense>
  )
}

ユーザー定義型ガード

外部からデータを受け取る場合、アプリが停止することを防ぐためにデータの型をチェックする必要がある typeof演算子やin演算子の他、ユーザー定義型ガードを利用することでチェックできる

{
  "setting1": 1,
  "setting2": "a"
}
import * as data from "./json/data.json";

type Data = {
  setting1: number,
  setting2: string,
}

/**
 * ユーザ定義型ガード
 * 関数の返り値の型を 引数 X is T と書くことで、
 * 条件がtrueを返す場合は X は T であり、
 * falseを返す場合は T ではないとTypeScriptに指示することができる
 */
const isData = (arg: unknown): arg is Data => {
  const data = arg as Data
  return (
    typeof data?.setting1 === "number" && typeof data?.setting2 == "string"
  )
};

if (isData(data)) {
  console.log("OK")
} else {
  console.log("NG")
}
// OK

備考

ユーザー定義型ガードを自分で書くのは面倒かつ危険なのでライブラリが色々ある io-ts など

型アサーション

アサーションとは、TypeScriptのコンパイラがコードから推論した型を開発者が上書きすること

使用前

let value = {}

value.name = "logosware" // error TS2339: Property 'name' does not exist on type '{}'

使用後

type User = {
  name: string
}

// 型アサーション
// 「これは空のオブジェクトだけどUser型です!私を信じて!」とコンパイラに伝える
let value = {} as User

value.name = "logosware" // コンパイラに怒られない

【Vue】ディレクティブ(フォーム)

双方向データバインディング

テンプレートとデータオブジェクトの双方向のデータを反映することを双方向データバインディングと呼ぶ

以下のソースコードではテキストボックスに入力された名前に応じて「こんにちは、○○さん!」という挨拶を表示する
※v-modelを利用した場合、テキストボックスの初期値は紐づいたプロパティの値となり、value属性は無視される

<div id="app">
  <form>
    <label for="name">氏名:</label>
    <input type="text" id="name" v-model="myName" />
  </form>
  <div>こんにちは、{{ myName }}さん!</div>
</div>
new Vue({
  el: '#app',
  data: {
    myName: '匿名',
  },
})

ラジオボタン

ラジオボタンを実装する場合、すべての選択オプションに対して同一のv-modelを渡す
これによってv-modelの値とvalue属性が等しいオプションが選択状態になる

  <div id="app">
    <form>
      <label for="dog">いぬ</label>
      <input type="radio" id="dog" value="いぬ" v-model="pet" />
      <br />
      <label for="dog">ねこ</label>
      <input type="radio" id="cat" value="ねこ" v-model="pet" />
      <br />
      <label for="dog">その他</label>
      <input type="radio" id="other" value="その他" v-model="pet" />
      <br />
    </form>
    <p>ペット:{{ pet }}</p>
  </div>
new Vue({
  el: '#app',
  data: {
    pet: 'いぬ',
  },
})

チェックボックス(単一)

チェックボックスは単一でon/offを表す場合と、リストで複数選択オプションを表す場合とがある
以下はon/offを表す書き方

  <div id="app">
    <form>
      <label for="agree">同意する</label>
      <input type="checkbox" id="agree" v-model="agree" />
      <!-- bool値以外で書く場合
      <input type="checkbox" id="agree" v-model="agree" true-value="yes" false-type="no" />-->
    </form>
    <div>回答:{{ agree }}</div>
  </div>
new Vue({
  el: '#app',
  data: {
    agree: true,
  },
})

チェックボックス(複数)

複数のチェックボックスを並べる場合には、ラジオボタンの場合と同じく、これらすべてに対して同一のv-modelを渡す

<div id="app">
  <form>
    <div>お使いのOSは?</div>
    <label for="windows">Windows</label>
    <input type="checkbox" id="windows" value="Windows" v-model="os" />
    <label for="linux">Linux</label>
    <input type="checkbox" id="linux" value="Linux" v-model="os" />
    <label for="mac">macOS</label>
    <input type="checkbox" id="macOS" value="macOS" v-model="os" />
  </form>
  <div>回答:{{ os }}</div>
</div>
new Vue({
  el: '#app',
  data: {
    os: ['Windows', 'macOS'],
  },
})

選択ボックス

select要素にv-modelを指定するだけ

<div id="app">
  <form>
    <label for="os">お使いのOSは?</label>
    <select v-model="os" multiple size="3">
      <option>Windows</option>
      <option>Linux</option>
      <option>macOS</option>
    </select>
  </form>
  <div>回答:{{ os }}</div>
</div>
new Vue({
  el: '#app',
  data: {
    os: '',
  },
})
補足:オブジェクトをバインドする

ラジオボタンや選択ボックスには文字列だけでなくオブジェクトを渡すことができる

<div id="app">
  <form>
    <label for="million">百万:</label>
    <input type="radio" id="million" v-model="unit" v-on:change="onchange"
      v-bind:value="{name:'百万', size: 1000000}" /><br>
    <label for="million">十億:</label>
    <input type="radio" id="billion" v-model="unit" v-on:change="onchange"
      v-bind:value="{name:'十億', size: 1000000000}" /><br>
    <label for="million">一兆:</label>
    <input type="radio" id="trillion" v-model="unit" v-on:change="onchange"
      v-bind:value="{name:'一兆', size: 1000000000000}" /><br>
  </form>
</div>
new Vue({
  el: '#app',
  data: {
    unit: {},
  },
  methods: {
    onchange: function () {
      console.log(this.unit.name + ':' + this.unit.size)
    },
  },
})

ファイル入力ボックス

他のフォーム要素と異なり、ファイル入力ボックスはユーザが指定した値をアプリが受け取るだけで、アプリが特定の値(ファイル)を指定することはできない
input type="file"要素には、後でイベントハンドラーからアクセスできるように、ref属性で名前を付けておく(①) ref属性はVueで予約された特殊な属性で、ここで命名された要素は、イベントハンドラーなどからthis.$refs.hogeの形式でアクセスできる(②)

<div id="app">
  <form>
    <input ref="upfile" type="file" v-on:change="onchange" /> <!-- ① -->
  </form>
  <div>{{ message }}</div>
</div>
new Vue({
  el: '#app',
  data: {
    message: '',
  },
  methods: {
    onchange: function () {  // ②
      // アップロードファイルを準備
      let fl = this.$refs.upfile.files[0]
      let deta = new FormData()
      deta.append('upfile', fl, fl.name)
      // サーバにデータを送信
      fetch('upload.php', {
        method: 'POST',
        body: deta,
      })
        // 成功時には結果を表示
        .then(function (response) {
          return response.text()
        })
        .then(function (text) {
          this.message = text
        })
        .catch(function (error) {
          windows.alert('Error' + ErrorEvent.message)
        })
    },
  },
})

バインドの動作オプションを設定する

ディレクティブの後ろに修飾子を付与することで、バインド時の挙動を細かく制御できる

入力値を数値としてバインドする.number修飾子

ユーザからの入力値は既定で文字列とみなされるが、.number修飾子を利用することでコード側での数値変換が不要になる

<div id="app">
  <label for="temperature">サウナの温度</label>
  <input type="text" id="temperature" v-model.number="temperature" v-on:change="onchange" />
</div>
new Vue({
  el: '#app',
  data: {
    temperature: 0,
  },
  methods: {
    // 入力値を小数点以下1位に丸目、ログ出力
    onchange: function () {
      console.log(this.temperature.toFixed(1))
    },
  },
})

入力値の前後の空白を除去する.trim修飾子

.trim修飾子を利用することで、入力値をプロパティにバインドする前に、前後の空白を除去できる

<div id="app">
  <label for="memo">メモ:</label>
  <input type="text" id="memo" v-model.trim="memo" v-on:change="onchange" />
</div>
new Vue({
  el: '#app',
  data: {
    memo: '',
  },
  methods: {
    // 入力値をログ出力
    onchange: function () {
      console.log('入力値は「' + this.memo + '」です。')
    },
  },
})

バインドのタイミングを遅延させる.lazy修飾子

v-modelによるバインドの既定タイミングはinputイベントの発生時 .lazy修飾子を利用することで、このタイミングをフォーム要素からフォーカスが移動したタイミングにすることができる

<div id="app">
  <form>
    <label for="name">氏名:</label>
    <input type="text" id="name" v-model.lazy="myName" />
  </form>
  <div>こんにちは、{{ myName }}さん!</div>
</div>
new Vue({
  el: '#app',
  data: {
    myName: '匿名',
  },
})

双方向データバインドのカスタマイズ

双方向データバインドを実装するには通常v-modelを利用するが、入力された値をプロパティにバインドする際になんらかの処理を挟みたい場合はv-bind:valueやv-on:inputの組み合わせを利用する
以下は、入力されたメールアドレス(セミコロン区切り)を分割し、配列としてmailsプロパティに反映させる例

<div id="app">
  <form>
    <label for="mail">メールアドレス:</label>
    <!--
      ①v-bind:valueで入力されたセミコロン区切りの文字列をsplitで分割し、mailsプロパティに反映
      ②v-on:inputで再びjoinメソッドで連結した上で、<textarea>要素にバインド
    -->
    <textarea id="mail" v-bind:value="mails.join(';')" v-on:input="mails=$event.target.value.split(';')"></textarea>
  </form>
  <ul>
    <li v-for="mail in mails">
      {{ mail }}
    </li>
  </ul>
</div>
new Vue({
  el: '#app',
  data: {
    mails: [],
  },
})