React update form question - how to make this work?

I’m getting myself into a bit of a muddle with React and Meteor. I’m trying to create an update form where the user types into fields and then validation messages are shown below the fields if the data fails validation. Then ultimately the user will press the submit button to save the whole lot to the database.

In my GetMeteorData function I get a customer:

getMeteorData() {

    const customerId = () => FlowRouter.getParam('_id');
    var handle = Meteor.subscribe('CustomerCompany.get', customerId());

    cust = CustomerCompanies.findOne({_id: customerId()});

    return {
        customerLoading: !handle.ready(),
        customer: cust,
        errors: {}
    };
},

and then in my render() method I pass that customer to my dumb form:

render() {

    //console.log("render started")
    if (this.data.customerLoading) {
        return ( <h3>Loading</h3> );
    }
    return (
        <CustomerEditComponent
            customer={this.data.customer}
            onChange={this.onChangeHandler}
            onSave={this.saveCustomer}
            errors={this.data.errors}
        />
    );
}

When the user makes a change that’s then handled by this code:

onChangeHandler: function (event) {

    // update our customer data to reflect the new value in the UI
    var field = event.target.name;
    var value = event.target.value;
    this.data.customer[field];

    // validation logic chopped out for brevity

    return this.setState({customer: this.data.customer});
},

and if they save:

saveCustomer(event) {
    event.preventDefault();

    // how to get data from state to the db?
}

The problem with that however is that typing in the fields on the form does not cause the UI to update. That’s because the render method never gets rerun, and I think I understand that that’s because I’m using this.data.customer rather than this.state.customer to populate the form and React isn’t watching for changes to this.data.customer.

Furthermore, I probably shouldn’t be writing to this.data.customer anyway as that is the actual database doc, where as all I want to do is update the state so the UI is re-rendered with the users update and any validation is performed.

So, assuming some of that is right, how do I get a version of the customer into the state and update it so that the UI responds, and then how do I get it back to the database once the user submits?

I tried adding this.setState({customer: cust) into getMeteorData(), but that just got me this error:

Uncaught Error: Can’t call setState inside getMeteorData as this could cause an endless loop. To respond to Meteor data changing, consider making this component a “wrapper component” that only fetches data and passes it in as props to a child component. Then you can use componentWillReceiveProps in that child component.

Any help gratefully received!

You could try setting this.data.customer in the initial state:

setInitialState() {
  return {
    customer: {}
  }
}

Then update the state after the data is loaded and componentDidMount:

componentDidMount() {
  if (!this.data.customerLoading) {
    this.setState({customer: this.data.customer});
  }
}

Maybe then you could use this.state.customer in the html instead of this.data.customer
Not sure if this will work, but just trying to lend some new eyes.

And for saving the customer, you can use a Meteor method on the server side that handles it and use a Meteor.call to send the data.

From the client file:

Meteor.call( 'newCustomer', this.state.customer, ( error, response ) => {
  if ( error ) {
    // Handle error
  } else {
    // Handle response
  }
});

Define the method on the server:

Meteor.methods({
  newCustomer( customer ) {
    check( customer, Object );
    return Customers.insert( customers );
  }
});

There are probably a million different ways to do these things, but hopefully this helps.

1 Like

That’s great info, many thanks @devato. I tried the componentDidMount approach, but this.data.customerLoading is still true at that point. I guess that doesn’t surprise me thinking about it. Ah… the penny may have dropped, I’ve read talk of using wrappers, if I split that component into two parts I should then be able to use componentDidMount on the child component to hook the data to the state. I shall give that a go. Sounds like a bit of a faff though.

OK, so I got this working in the end by adding a wrapper layer so that I could hold off from rendering the main control until I’d actually got the data. The code ends up looking like this:

customer-edit-page-wrapper.jsx:

// Top of the stack, represents the whole page
CustomerEditPageWrapper = React.createClass({
    // This mixin makes the getMeteorData method work
    mixins: [ReactMeteorData],

    // Loads items from the Tasks collection and puts them on this.data.tasks
    getMeteorData() {
        console.log("CustomerEditForm.getMeteorData");

        const customerId = () => FlowRouter.getParam('_id');
        var handle = Meteor.subscribe('CustomerCompany.get', customerId());

        cust = CustomerCompanies.findOne({_id: customerId()});

        return {
            customerLoading: !handle.ready(),
            customer: cust
        };
    },

    // this code could be moved down to the CustomerEditPage, but it seems cleaner
    // to keep all the db access in one place.  This does effectively separate the child  
    // component from the data access which may be good for disconnected data scenarios
    saveCustomer(customer) {
        //console.log("submitted customer: ", customer);

        const custId = FlowRouter.getParam('_id');

        // call the method for upserting the data
        CustomerCompanies.methods.updateManualForm.call({
            customerId: custId,
            data: customer
        }, (err, res) => {
            //console.log ("CustomerCompanies.methods.updateManualForm.call was called");
            if (err) {
                sAlert.error(err.message);
            } else {
                sAlert.success("Save successful")
            }
        });

    },

    render() {
        //console.log("render started")
        if (this.data.customerLoading) {
            return ( <h3>Loading</h3> );
        }
        return (
            <CustomerEditPage
                customer={this.data.customer}
                onSave={this.saveCustomer}
            />
        );
    }
});

Customer-edit-page.jsx - this page can now assume that the customer data has already been loaded into the state:

// this page is wrapped by the wrapper
CustomerEditPage = React.createClass({
    propTypes: {
        customer: React.PropTypes.object.isRequired,
        onSave: React.PropTypes.func.isRequired
    },

    getInitialState() {
        //console.log("CustomerEditPage.getInitialState");
        return {
            errorsList: new ReactiveDict(),
            customer: this.props.customer,
            errors: {}
        };
    },

    onChangeHandler: function (event) {

        // update our customer state to reflect the new value in the UI
        var field = event.target.name;
        var value = event.target.value;
        this.state.customer[field] = value;

        // validation stuff to check table against schema in realtime

        // Update the state, this will then cause the re-render
        return this.setState({customer: this.state.customer});
    },

    saveCustomer(event) {
        event.preventDefault();

        this.props.onSave(this.state.customer);
    },

    render() {
        //console.log("render state ", this.state);
        return (
            <CustomerEditForm
                customer={this.state.customer}
                onChange={this.onChangeHandler}
                onSave={this.saveCustomer}
                errors={this.state.errors}
            />
        );
    }
});

And then there’s a CustomerEditForm component that has the actual fields on (it has no state, just props).

I’d be interested to hear if anyone has a shorter way of achieving that, it seems like a lot of boilerplate really.

2 Likes