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>
に変更しています。sending
がtrue
(データを送信中)の時は、<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'
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>
)
}
動作イメージ
テキストフィールドに文字列を入力して、タスクを追加ボタンを押します。
サーバにデータにデータを送信中は上記コードのsending
がtrue
になり、テキストフィールドとボタンが操作できなくなります。
サーバへのデータ送信が完了すると、sending
がfalse
になり、入力が可能になります。
エラー再現手順
- ページを表示する
- タスクを追加する
- 再びタスクを追加する
2回目のタスク追加で、以下のようなエラーが表示されます。inputの値がundefined
になっています。
TypeError: Cannot read property 'value' of undefined
原因究明
確認のためのログを追記してもう一度実行します。
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がセットされます。
2. タスクを追加する
sendingの値が変化したので、renderが呼ばれ、input変数が再びundefinedに変更されます。この時refコールバックは呼ばれません。ここがバグの原因のようです。
3. 再びタスクを追加する
エラーが表示されました。onSubmit中でもinputは undefinedのままです。
修正
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>
)
}
}
動作確認
- ページを表示する
- タスクを追加する
- 再びタスクを追加する
2回目のタスク追加でもエラーなくタスクを追加できました。
<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