ReactでinputRef使用時、参照を保持する変数がundefinedになった原因と対処法

ReactでinputRef使用時、参照を保持する変数がundefinedになり、エラーが発生しました。その原因と対処法を紹介します。

先に結論

参照を保持する変数は、ローカル変数でなく、インスタンス変数にします。インスタンス変数はReact.Componentを拡張したクラスで使用できます(関数コンポーネントでは使用できません)。

良い例

import TextField from '@material-ui/core/TextField'

class AddTodo extends React.Component {
  render() {
    return (
      <TextField
        inputRef={node => {
          this.inputElement = node
        }}
      />
    )
  }
}

よくない例

import TextField from '@material-ui/core/TextField'

const AddTodo = ({uid, dispatch}) => {
  let input;

  // 略
  return (
    <TextField
      inputRef={node => {
        input = node
      }}
    />
  )
}

エラーが発生するコード

以下のコードは、Redux公式に載っているreact-reduxのサンプルを変更したものです。

propsにuid(ユーザID)とsending(送信中を表す真偽値)を追加しています。 Material-UIを利用し、 <input><TextField><button><Button>に変更しています。sendingtrue(データを送信中)の時は、<TextField><Button>をdisabledにしています。<TextField>のrefをinputRefに変更した理由については以下のリンクをご覧ください。

yucatio.hatenablog.com

src/AddTodo.js

import Button from '@material-ui/core/Button'
import TextField from '@material-ui/core/TextField'
// 他のimportは略

const AddTodo = ({uid, sending, dispatch}) => {
  let input;

  return (
    <div>
      <form
        onSubmit={ e => {
          e.preventDefault()
          if (!input.value.trim()) {
            return
          }
          dispatch(addTodo(uid, input.value))
          input.value = ''
        }}
      >
        <TextField
          inputRef={node => {
            input = node
          }}
          disabled={sending}
        />
        {' '}
        <Button variant="contained" type="submit" disabled={sending}>
          タスクを追加
        </Button>
      </form>
    </div>
  )
}

// 以下略

動作イメージ

テキストフィールドに文字列を入力して、タスクを追加ボタンを押します。

f:id:yucatio:20181118232815p:plain

サーバにデータにデータを送信中は上記コードのsendingtrueになり、テキストフィールドとボタンが操作できなくなります。

f:id:yucatio:20181118232833p:plain

サーバへのデータ送信が完了すると、sendingfalseになり、入力が可能になります。

f:id:yucatio:20181118232847p:plain

エラー再現手順

  1. ページを表示する
  2. タスクを追加する
  3. 再びタスクを追加する

2回目のタスク追加で、以下のようなエラーが表示されます。inputの値がundefinedになっています。

TypeError: Cannot read property 'value' of undefined

f:id:yucatio:20181118233211p:plain

原因究明

確認のためのログを追記してもう一度実行します。

src/AddTodo.js

// 前略

const AddTodo = ({uid, sending, dispatch}) => {
  console.log('render')  // 追加
  let input;
  console.log('input', input)  // 追加
  console.log('sending', sending)  // 追加

  return (
    <div>
      <form
        onSubmit={ e => {
          e.preventDefault()
          console.log('in onSubmit')  // 追加
          console.log('input', input)  // 追加
          if (!input.value.trim()) {
            return
          }
          dispatch(addTodo(uid, input.value))
          input.value = ''
        }}
      >
        <TextField
          inputRef={node => {
            console.log('in inputRef callback')  // 追加
            input = node
            console.log('input', input)  // 追加
          }}
          disabled={sending}
        />
        {' '}
        <Button variant="contained" type="submit" disabled={sending}>
          タスクを追加
        </Button>
      </form>
    </div>
  )
}

// 後略

再びエラー再現

1. ページを表示する

renderが呼ばれ、refコールバックが呼ばれます。input変数は、宣言直下ではundefinedですが、refコールバック内でnodeがセットされます。

f:id:yucatio:20181118234857p:plain

2. タスクを追加する

sendingの値が変化したので、renderが呼ばれ、input変数が再びundefinedに変更されます。この時refコールバックは呼ばれません。ここがバグの原因のようです。

f:id:yucatio:20181118234913p:plain

3. 再びタスクを追加する

エラーが表示されました。onSubmit中でもinputは undefinedのままです。

f:id:yucatio:20181118234930p:plain

修正

Reactの公式サンプル (Refs and the DOM – React) にあるように、参照を保持する変数をインスタンス変数 (this.inputElement)に変更します。

インスタンス変数を使用するために、関数コンポーネントからReact. Componentを拡張したクラスに変更します。

// 前略

class AddTodo extends React.Component {  // 変更
  render() {  // 変更
    const {uid, sending, dispatch} = this.props  // 変更

    return (
      <div>
        <form
          onSubmit={ e => {
            e.preventDefault()
            if (!this.inputElement.value.trim()) {  // 変更
              return
            }
            dispatch(addTodo(uid, this.inputElement.value))  // 変更
            this.inputElement.value = ''  // 変更
          }}
        >
          <TextField
            inputRef={node => {
              this.inputElement = node  // 変更
            }}
            disabled={sending}
          />
          {' '}
          <Button variant="contained" type="submit" disabled={sending}>
            タスクを追加
          </Button>
        </form>
      </div>
    )
  }
}

// 後略

動作確認

  1. ページを表示する
  2. タスクを追加する
  3. 再びタスクを追加する

2回目のタスク追加でもエラーなくタスクを追加できました。

f:id:yucatio:20181118235656p:plain

<input>がrender内に書いてある場合

Redux公式のコードでは、<input>がAddTodoのrender内に書いてあります。このように書いてあると、AddTodoのrenderが呼ばれるたびにrefコールバックが実行されます。そのため、2回目以降もinput変数に値が入っている状態になり、エラーにはなりません。しかし、TextFieldに変更すると初回のしかrefコールバックが実行されないため、エラーになってしまいました。

あとがき

2回目のタスク追加でエラーになるとは予想外でした。見つけにくいバグなので2回連続でタスク追加するパターンをテストケースに追加しようと思いました。

バージョン

  • react : 16.5.0
  • @material-ui/core : 3.4.0