Help us understand the problem. What is going on with this article?

Reactで扱うFormとEntityの管理について

More than 3 years have passed since last update.

これは、初心者歓迎!Reactとvte.cxでWebアプリケーションを作成する#2<動作確認〜ソース解説>の説明に使う予定の資料です。

まずはじめに、React+vte.cxで業務アプリを作る #1(環境設定〜動作確認まで)を参考に、環境設定を行ってください。

今回はReactのFormの話とEntityとの関係について説明します。
Reactをしっかり身につけるまでは、まだReduxなどのFlux系を覚える必要はありませんので、ここではあくまでReactのみで作ることを前提とします。

Entityとスキーマについて

vte.cxにおけるEntityとは、ドメインデータのことであり、アプリケーションで管理する項目のデータのことです。具体的には、stateで管理するfeedオブジェクトになります。また、これはREST通信によってサーバから送受信されるデータとしても使います。
feedデータの項目名と構造はスキーマで定義します。ブランクプロジェクトでは、以下のように、userinfoの子要素にidやemail、nameといった項目が定義されています。
JavaScriptからは、JSONオブジェクトとして、this.state.feed.entry[n].userinfo.id などのように扱います。また、値が更新されたら再描画させるために、entryの配列を保持するfeedをstateに格納します。

  • /setup/_settings/template.xmlにスキーマを定義する
userinfo
 id(int)
 email
 name
favorite
 food!=^.{3}$
 music=^.{5}$
hobby{}
 type
 name
 updated(date)
account
 firstname
 lastname
 tel
 prefecture_code
 address1
 address2
 email
 postcode

UnControlled ComponentとControlled Componentの違い

ReactにおけるFormは、UnControlled ComponentとControlled Componentの2つがあります。
UnControlled Componentはシンプルに記述できるのですが、初期表示において値をセットできないなど自由度に難があります。例えば、以下のようにしても初期値を更新できません。

<input type="text" value={this.state.value} />

なので、初期値の更新を伴うフォームではControlled Componentにする必要があります。
Controlled Componentでは、以下のように、onChangeイベントでhandleChange()が呼ばれ、this.state.valueが更新されるようになっています。
(e)=>this.handleChange(e)のようにまどろっこしい表記になっているのは、JavaScriptの性質上、クラス内のhandleChange()を素直に呼べないからです。=>のようなアロー関数にすると呼べるようになります。あるいは、this.handleChange(e).bind(this)と記述しても呼べます。

handleChange(e) {
  this.setState({value: e.target.value});
}
<input type="text" value={this.state.value} onChange={(e)=>this.handleChange(e)} />

更新フォーム以外にも、外部コンポーネントでは多くがControlled Componentになっていますので、それらを利用する場合はおのずとControlled Componentにする必要がでてきます。本ブランクプロジェクトでは、reCaptchaやpassword強度チェックなどの外部コンポーネントを利用しています。

React-bootstrapとUnControlled Componentの例

登録だけを行うフォームではUnControlled Componentでも構いません。
ブランクプロジェクトのLogin画面などではUnControlled Componentで作成しています。

UnControlled Componentでは、以下のように、FormのonSubmitに関数を一つだけ指定します。ボタン押下時に、FormGroupのcontrolId(ここではaccount)に入力した値が入り、handleSubmit内でe.target.account.valueとして取得できます。FormGroup内に複数のFormControlがある場合は、e.target.account[0].valueのように配列で取得できます。
UnControlled Componentを使う場合は、handleSubmitにおいて、すべての入力項目のチェックと組み立てを行う必要があります。
ちなみに、handleSubmit内のe.preventDefault()は、リンクを押しても画面遷移しないようにするオマジナイと覚えてください。

handleSubmit(e:InputEvent){
    e.preventDefault()
    const authToken = getAuthToken(e.target.account.value,e.target.password.value)
・・・
}
・・・

<Form horizontal onSubmit={(e)=>this.handleSubmit(e)}>
    <FormGroup controlId="account">
    <Col sm={12}>
        <ControlLabel>メールアドレス</ControlLabel>
        <FormControl type="email" placeholder="email" />
    </Col>
</FormGroup>

Controlled Componentの例

ブランクプロジェクトの更新画面では初期値を表示する必要があるため、Controlled Componentで作成しています。

以下のように、FormControlに、value={this.state.id} onChange={(e)=>this.handleChange(e)}が追加されています。
handleChangeでは、state(event.target.name)を、event.target.valueの値で更新します。
event.target.nameを[]で括ることで、キーを変数にすることができます。event.target.nameには、FormControlのname(以下の例ではid)が入ります。

handleChange(event:InputEvent) {
    this.setState({ [event.target.name]: event.target.value })
}
・・・
<Form horizontal onSubmit={(e)=>this.handleSubmit(e)}>
    <PageHeader>更新</PageHeader>
    <FormGroup controlId="id">
    <FormControl.Static>ユーザ情報</FormControl.Static>        
    <ControlLabel>ID</ControlLabel>
    <FormControl type="text" placeholder="数字" name="id" value={this.state.id} onChange={(e)=>this.handleChange(e)}/>
    </FormGroup>

・・・

Controlled Componentとentity項目

Controlled Componentは表示値を更新できるので便利なのですが、コードが複雑になるというデメリットがあります。
例えば、すべてのFormControlについて、nameとonChange()を指定しなければなりませんし、表示用の変数として新たにstateに追加しなければなりません。
ブランクプロジェクトの更新画面でも、実際にデータ更新(onSubmit)するためのfeed項目とは別に、表示のための項目をstateに持っています。しかしこれでは、同じ項目が2ヶ所に別れて存在し、DRY(Don't Repeat Yourself)的にまずいコードになります。
また、feedをstateで管理すると、feedの子要素を更新したい場合に面倒になります。なぜなら、state更新による再描画(render)は直下の子要素の更新しか反応しないからです。
例えば、以下のコードは、更新画面のhobby配列を1行追加するものですが、stateの再描画が反応するためにはfeed自体を更新する必要があり、その子要素であるhobby配列に追加した関数の実行結果であるfeedをsetStateに渡すという具合に、とてもわかりにくいコードになっています。

    addRow() {
        this.setState((prevState) => ({
            feed: ((prevState) => { 
                if (!prevState.feed.entry[0].hobby) {
                    prevState.feed.entry[0].hobby = []
                }
                prevState.feed.entry[0].hobby.push({ type: '', name: '' })
                return prevState.feed               
            })(prevState)
        }))         
    }

entity項目を統一する

そこで、ユーザ情報登録画面のように、entityを一箇所で管理する方法を考えます。方針としては以下のようになります。

  • stateにではなく、外にthis.entryを1ヶ所だけ定義する。(複数件の場合は、this.feed)
  • handleChange()ではthis.entryの値を直接更新する。(Validationはここで行う)
    • その際、再描画が必要になるので、this.forceUpdate()を実行する。
  • handleSubmit()では値を組み立てるのではなく、this.entryの値をそのまま送信する。

以下はソースコードの抜粋とその解説です。

  • constructorにおいて、this.entryをthis.stateの外に定義します。
  • componentWillMount()において、画面を表示する直前にサーバからデータを取得し、this.entryにセットします。(this.entry = response.data.feed.entry[0]の部分)
  • 入力項目に変化があると、handleChange()が呼ばれ、this.entry.account[event.target.name] = event.target.valueが実行されます。ただし、stateの更新ではないため、this.forceUpdate()を呼ぶことで再描画を実行しなければなりません。
  • サブミットボタンが押されると、handleSubmit()が呼ばれ、this.entryの内容がそのままサーバに送信されます。

axiosを使ったサーバ通信の部分について補足します。すべての通信に、'X-Requested-With': 'XMLHttpRequest'ヘッダがついていますが、これはXHR通信であることをサーバに知らせるためのもので、オマジナイだと思ってください。

  • componentWillMount()の最初の通信はuidというユーザ識別子を取得するためにGET /d/?_whoamiを実行しています。
  • 次の通信は'/d/' + id + '/group/userinfo?e'でユーザ情報を実際に取得しています。
  • handleSubmit()の最初の通信はユーザ情報の更新リクエストです。
  • 次の通信は、サーバサイドJavaScript(getrxid)を実行するリクエストで、RXIDというワンタイムトークンを取得するために実行しています。(この部分は無視していただいて結構です)
class Signup2 extends React.Component {
    state: State

    constructor(props:Props) {
        super(props)
        this.state = { isConfirmed: false,isForbidden:false,isError:false,isRegisterd:false }
        this.entry = { 'account' : {'lastname' :'','firstname' :'','tel' :'','postcode' :'','prefecture_code' :'','address1' :'','address2' :''}}
    }

    componentWillMount() {
        axios({
            url: '/d/?_whoami',
            method: 'get',
            headers: {
                'X-Requested-With': 'XMLHttpRequest'
            }

        }).then((response) => {
            this.entry.account.email = response.data.feed.entry[0].title
            const id = response.data.feed.entry[0].id.substring(1, response.data.feed.entry[0].id.indexOf(','))

            this.entry.link = []
            const link = { ___href: '/' + id + '/group/userinfo', ___rel: 'self' }
            this.entry.link.push(link)

            axios({
                url: '/d/' + id + '/group/userinfo?e',
                method: 'get',
                headers: {
                    'X-Requested-With': 'XMLHttpRequest'
                }
            }).then((response) => {
                if (response.data.feed.entry && response.data.feed.entry.length > 0) {
                    this.entry = response.data.feed.entry[0]
                    this.setState({isRegisterd:true})
                } else {
                    this.setState({isRegisterd:false})                  
                }
            })

        }).catch((error) => {
            if (error.response&&error.response.status===401) {
                this.setState({isForbidden: true})
            } else if (error.response.status === 403) {
                alert('実行権限がありません。ログインからやり直してください。')
                location.href = 'login.html'
            } else {
                this.setState({isError: true,errmsg:error.response.data.feed.title})
            }
        })

    }


    handleSubmit(e: InputEvent) {
        e.preventDefault()
        let reqdata = {'feed': {'entry': []}}
        reqdata.feed.entry.push(this.entry)

        axios({
            url: '/d',
            method: 'put',
            headers: {
                'X-Requested-With': 'XMLHttpRequest'
            },
            data : reqdata

        }).then(() => {
            axios({
                url: '/s/getrxid',
                method: 'get',
                headers: {
                    'X-Requested-With': 'XMLHttpRequest'
                }
            }).then(() => {
                location.href = 'index.html'
            })
        }).catch((error) => {
            if (error.response&&error.response.status===401) {
                this.setState({isForbidden: true})
            } else if (error.response.status === 403) {
                alert('実行権限がありません。ログインからやり直してください。')
                location.href = 'login.html'
            } else {
                this.setState({isError: true,errmsg:error.response.data.feed.title})
            }
        })

    }

    handleConfirm(e:InputEvent){
        e.preventDefault()      
        this.setState({ isConfirmed: true })
    }

    handleChange(event:InputEvent) {
        this.entry.account[event.target.name] = event.target.value
        this.forceUpdate()
    }

    render() {
        return (
                        <Form horizontal onSubmit={(e) => this.handleConfirm(e)}>

                            <FormGroup controlId="name">
                                <Col md={6}>
                                    <ControlLabel></ControlLabel>
                                    <FormControl type="text" placeholder="" name="lastname" value={this.entry.account.lastname} onChange={(e)=>this.handleChange(e)} />
                                </Col>
                                <Col md={6}>
                                    <ControlLabel></ControlLabel>
                                    <FormControl type="text" placeholder="" name="firstname" value={this.entry.account.firstname} onChange={(e)=>this.handleChange(e)} />
                                </Col>
                            </FormGroup>

・・・

これで大変すっきりとしたコードになりました。
本日は以上です。

stakezaki
アイコンに顔が似ているといわれると喜びます
http://blog.virtual-tech.net
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away