Structuring state and components

Structuring state and components
WIP

In an earlier lesson, we looked at uncontrolled inputs. To jog your memory, these are inputs that can have different states for the same set of props. For example, an input element without a value prop will look different after you’ve typed in it, so it is uncontrolled.

In fact, it’s not just inputs that can be uncontrolled. Class components that store state can be uncontrolled too. And this raises the question:

Are stateful class components… the dark side?

#You can’t escape state

Let’s consider the App component from your contact list app. It fetches an array of your billionaire friends in componentDidMount()… and stores the array under this.state.

But the thing about your App component is that the state never leaves it. Unlike uncontrolled inputs, your App component doesn’t have any callbacks. The state is contained.

You can’t escape the fact that one way or another, a useful app is going to have state. The trick is, you never want that state to escape its container component.

#Container and Presentational components

Container components, or containers, are a name that people use for components that primarly manage state. Containers don’t usually render any DOM elements, instead passing their state to child components that handle presentation.

As you’ve probably guessed, your App component is a container component.

In contrast, presentational components are those that handle markup and styling. They don’t usually have any state. They can be class components or function components. And they can have callbacks — but the handlers just forward events to parent components via callback props.

Your contact app has a number of presentational components. In fact, the very first components that you build — Contact and ContactList — are presentational components.

But what about your ContactForm component?

#Oh no.

Let’s take another look at your ContactForm component.

index.js
styles.css
import React from 'react'
import ReactDOM from 'react-dom'

export class ContactForm extends React.Component {
  constructor() {
    super()
    this.state = {
      name: "",
      nameError: null,

      email: "",
      emailError: null,
     }
    this.handleChangeName = this.handleChangeName.bind(this)
    this.handleChangeEmail = this.handleChangeEmail.bind(this)
    this.handleSubmit = this.handleSubmit.bind(this)
  }

  render() {
    return (
      <form
        className='ContactForm'
        onSubmit={this.handleSubmit}>
        <label>
          <span>Name</span>
          <input
            value={this.state.name}
            onChange={this.handleChangeName}
          />
          {this.state.nameError && (
            <div className='ContactForm-error'>
              {this.state.nameError}
            </div>
          )}
        </label>
        <label>
          <span>E-mail</span>
          <input
            value={this.state.email}
            onChange={this.handleChangeEmail}
          />
          {this.state.emailError && (
            <div className='ContactForm-error'>
              {this.state.emailError}
            </div>
          )}
        </label>
        <button type="submit">
          Add
        </button>
      </form>
    )
  }
  
  handleChangeName(event) {
    this.setState({
      name: event.target.value,
      nameError: null,
    })
  }
  
  handleChangeEmail(event) {
    let value = event.target.value
  
    this.setState({
      email: value,
      emailError: isEmailValid(value) ? null : this.state.emailError,
    })
  }
  
  handleSubmit(event) {
    event.preventDefault()
    
    let errors = {}
    if (!this.state.name) {
      errors.nameError = "You must enter a name."
    }
    if (!this.state.email) {
      errors.emailError = "You must enter an email."
    }
    else if (!isEmailValid(this.state.email)) {
      errors.emailError = "That doesn't look like a valid e-mail."
    }
   
    if (Object.keys(errors).length === 0) {
      this.props.onAddContact({
        name: this.state.name,
        email: this.state.email,
      })
    }
    else {
      this.setState(errors)
    }
  }
}

function isEmailValid(value) {
  return value.indexOf('@') !== -1
}

ReactDOM.render(
  <ContactForm onAddContact={() => {}} />,
  document.getElementById('root')
)
Build In Progress

When the user clicks the “Add” button, a contact details object is passed out as an argument to onAddContact(). For example, if you were to add a mythical Japanese character to your contacts, the call to onAddContact() may look something like this:

this.props.onAddContact({
  name: "Momo Taro",
  email: "momotaro@example.com",
})

The problem with this is that the state is now in two places:

  1. The form itself
  2. Wherever the onAddContact function just left it

Now you may be thinking, but you could just clear the form — then it would only be in one place again? And sure, this might work. But then again, it might not. Can you think of some reasons why?

The form component itself has no control over what the onAddContact function does.

  • What if it fails?
  • What if nothing is added in the first place due to failed validation?

If you clear the form whenever “Add” is pressed, the user could lose their data!

The problem with uncontrolled components is that they cause state to be in multiple places.

Luckily, you rarely ever actually need an uncontrolled component. And if you do end up with one by accident (or because I didn’t explain things properly the first time), then there’s a simple process to fix it.

#Lifting state

Whenever you find yourself with an uncontrolled component, you can convert it to a controlled component by moving, or lifting, the state into a parent component.

To lift state out of a component, you’ll need to do three things:

  1. Rewrite the component to get the current value from this.props instead of this.state.
  2. Rewrite your handlers to call a new onChange prop, instead of calling this.setState().
  3. Update the parent component to pass in a new callback and value.

Let’s practice this by rewriting your contact form.

To start you off, I’ve updated the propTypes object to your ContactForm component, detailing what the new props should look like. I’ve also added a newContact object to your App component’s state.

Your task is to:

  • Remove any reference to this.state from your ContactForm component.
  • Add new handlers to your ContactForm component.
  • Add new handlers to your App component.

A few hints:

  • The onSubmit handler doesn’t need any arguments. The form component just needs to notify its parent that “submit” was clicked.
  • The handlers for your form’s input elements will need to create a new object in the same shape as this.props.value, and pass that as an argument to this.props.onChange.
  • Don’t forget to bind your new handler methods!
index.js
Contacts.js
api.js
styles.css
index.html
import React from 'react'
import ReactDOM from 'react-dom'
import { getRecords } from './api'
import { Contact, ContactForm, ContactList } from './Contacts'

class App extends React.Component {
  constructor() {
    super()
    this.state = {
      contacts: [],
      contactsError: null,
    }
    this.refresh = this.refresh.bind(this)
  }

  componentDidMount() {
    this.refresh()
  }

  render() {
    let content
    if (this.state.contactsError) {
      content = (
        <p>
          {this.state.contactsError}
        </p>
      )
    }
    else {
      content = this.state.contacts.map((contact, i) =>
        React.createElement(Contact, { ...contact, key: i })
      )
    }
  
    return (
      <ContactList onClickRefresh={this.refresh}>
        {content}
        <ContactForm onAddContact={(contact) => {
          this.setState({
            contacts: this.state.contacts.concat(contact)
          })
        }} />
      </ContactList>
    )
  }

  refresh() {
    getRecords().then(
      (response) => {
        this.setState({
          contacts: response.data,
          contactsError: null,
        })
      },
      (error) => {
        this.setState({
          contactsError: "Your contacts couldn't be loaded :-("
        })
      }
    ) 
  }
}

ReactDOM.render(
  <App />,
  document.getElementById('root')
)
Build In Progress

You’ll know when you’re done, as everything will work as expected, and ContactForm will no longer have any references to this.state!

If you do get stuck, take a peek at my solution. And then go back and finish off your version.

And once you’re done? Then click on through to put the final touches on your contact list. And after that, we’ll discuss what comes after this course.