- React and React Native
- Adam Boduch
- 2216字
- 2021-07-09 19:28:56
Initializing properties and state
In this section, you'll see how to implement initialization code in React components. This involves using lifecycle methods that are called when the component is first created. First, we'll walk through a basic example that sets the component up with data from the API. Then, you'll see how state can be initialized from properties, and also how state can be updated as properties change.
Fetching component data
One of the first things you'll want to do when your components are initialized is populate their state or properties. Otherwise, the component won't have anything to render other than its skeleton markup. For instance, let's say you want to render the following user list component:
import React from 'react'; import { Map as ImmutableMap } from 'immutable'; // This component displays the passed-in "error" // property as bold text. If it's null, then // nothing is rendered. const ErrorMessage = ({ error }) => ImmutableMap() .set(null, null) .get( error, (<strong>{error}</strong>) ); // This component displays the passed-in "loading" // property as italic text. If it's null, then // nothing is rendered. const LoadingMessage = ({ loading }) => ImmutableMap() .set(null, null) .get( loading, (<em>{loading}</em>) ); export default ({ error, loading, users, }) => ( <section> { /* Displays any error messages... */ } <ErrorMessage error={error} /> { /* Displays any loading messages, while waiting for the API... */ } <LoadingMessage loading={loading} /> { /* Renders the user list... */ } <ul> {users.map(i => ( <li key={i.id}>{i.name}</li> ))} </ul> </section> );
There are three pieces of data that this JSX relies on:
loading
: This message is displayed while fetching API dataerror
: This message is displayed if something goes wrongusers
: Data fetched from the API
There's also two helper components used here: ErrorMessage
and LoadingMessage
. They're used to format the error and the loading state, respectively. However, if error
or loading
are null, neither do we want to render anything nor do we want to introduce imperative logic into these simple functional components. This is why we're using a cool little trick with Immutable.js
maps.
First, we create a map that has a single key-value pair. The key is null, and the value is null. Second, we call get()
with either an error
or a loading
property. If the error
or loading
property is null, then the key is found and nothing is rendered. The trick is that get()
accepts a second parameter that's returned if no key is found. This is where we pass in our truthy value and avoid imperative logic all together. This specific component is simple, but the technique is especially powerful when there are more than two possibilities.
How should we go about making the API call and using the response to populate the users
collection? The answer is to use a container component, introduced in the preceding chapter that makes the API call and then renders the UserList
component:
import React, { Component } from 'react'; import { fromJS } from 'immutable'; import { users } from './api'; import UserList from './UserList'; export default class UserListContainer extends Component { state = { data: fromJS({ error: null, loading: 'loading...', users: [], }), } // Getter for "Immutable.js" state data... get data() { return this.state.data; } // Setter for "Immutable.js" state data... set data(data) { this.setState({ data }); } // When component has been rendered, "componentDidMount()" // is called. This is where we should perform asynchronous // behavior that will change the state of the component. // In this case, we're fetching a list of users from // the mock API. componentDidMount() { users().then( (result) => { // Populate the "users" state, but also // make sure the "error" and "loading" // states are cleared. this.data = this.data .set('loading', null) .set('error', null) .set('users', fromJS(result.users)); }, (error) => { // When an error occurs, we want to clear // the "loading" state and set the "error" // state. this.data = this.data .set('loading', null) .set('error', error); } ); } render() { return ( <UserList {...this.data.toJS()} /> ); } }
Let's take a look at the render()
method. It's sole job is to render the <UserList>
component, passing in this.state
as its properties. The actual API call happens in the componentDidMount()
method. This method is called after the component is mounted into the DOM. This means that <UserList>
will have rendered once, before any data from the API arrives. But this is fine, because we've set up the UserListContainer
state to have a default loading
message, and UserList
will display this message while waiting for API data.
Once the API call returns with data, the users
collection is populated, causing the UserList
to re-render itself, only this time, it has the data it needs. So, why would we want to make this API call in componentDidMount()
instead of in the component constructor, for example? The rule-of-thumb here is actually very simple to follow. Whenever there's asynchronous behavior that changes the state of a React component, it should be called from a lifecycle method. This way, it's easy to reason about how and when a component changes state.
Let's take a look at the users()
mock API function call used here:
// Returns a promise that's resolved after 2 // seconds. By default, it will resolve an array // of user data. If the "fail" argument is true, // the promise is rejected. export function users(fail) { return new Promise((resolve, reject) => { setTimeout(() => { if (fail) { reject('epic fail'); } else { resolve({ users: [ { id: 0, name: 'First' }, { id: 1, name: 'Second' }, { id: 2, name: 'Third' }, ], }); } }, 2000); }); }
It simply returns a promise that's resolved with an array after 2 seconds. Promises are a good tool for mocking things like API calls because this enables you to use more than simple HTTP calls as a data source in your React components. For example, you might be reading from a local file or using some library that returns promises that resolve data from unknown sources.
Here's what the UserList
component renders when the loading
state is a string, and the users
state is an empty array:
Here's what it renders when loading
is null
and users
is non-empty:
I can't promise that this is the last time I'm going to make this point in the book, but I'll try to keep it to a minimum. I want to hammer home the separation of responsibilities between the UserListContainer
and the UserList
components. Because the container component handles the lifecycle management and the actual API communication, this enables us to create a very generic user list component. In fact, it's a functional component that doesn't require any state, which means this is easy to reuse throughout our application.
Initializing state with properties
The preceding example showed you how to initialize the state of a container component by making an API call in the componentDidMount()
lifecycle method. However, the only populated part of the component state is the users
collection. You might want to populate other pieces of state that don't come from API endpoints.
For example, the error
and loading
state messages have default values set when the state is initialized. This is great, but what if the code that is rendering UserListContainer
wants to use a different loading message? You can achieve this by allowing properties to override the default state. Let's build on the UserListContainer
component:
import React, { Component } from 'react'; import { fromJS } from 'immutable'; import { users } from './api'; import UserList from './UserList'; class UserListContainer extends Component { state = { data: fromJS({ error: null, loading: null, users: [], }), } // Getter for "Immutable.js" state data... get data() { return this.state.data; } // Setter for "Immutable.js" state data... set data(data) { this.setState({ data }); } // Called before the component is mounted into the DOM // for the first time. componentWillMount() { // Since the component hasn't been mounted yet, it's // safe to change the state by calling "setState()" // without causing the component to re-render. this.data = this.data .set('loading', this.props.loading); } // When component has been rendered, "componentDidMount()" // is called. This is where we should perform asynchronous // behavior that will change the state of the component. // In this case, we're fetching a list of users from // the mock API. componentDidMount() { users().then( (result) => { // Populate the "users" state, but also // make sure the "error" and "loading" // states are cleared. this.data = this.data .set('loading', null) .set('error', null) .set('users', fromJS(result.users)); }, (error) => { // When an error occurs, we want to clear // the "loading" state and set the "error" // state. this.data = this.data .set('loading', null) .set('error', error); } ); } render() { return ( <UserList {...this.data.toJS()} /> ); } } UserListContainer.defaultProps = { loading: 'loading...', }; export default UserListContainer;
You can see that loading
no longer has a default string value. Instead, we've introduced defaultProps
, which provide default values for properties that aren't passed in through JSX markup. The new lifecycle method we've added is componentWillMount()
, and it uses the loading
property to initialize the state. Since the loading
property has a default value, it's safe to just change the state. However, calling setState()
(via this.data
) here doesn't cause the component to re-render itself. The method is called before the component mounts, so the initial render hasn't happened yet.
Let's see how we can pass state data to UserListContainer
now:
import React from 'react'; import { render } from 'react-dom'; import UserListContainer from './UserListContainer'; // Renders the component with a "loading" property. // This value ultimately ends up in the component state. render(( <UserListContainer loading="playing the waiting game..." /> ), document.getElementById('app') );
Pretty cool, right? Just because the component has state, doesn't mean that we can't be flexible and allow for customization of this state. We'll look at one more variation on this theme—updating component state through properties.
Here's what the initial loading message looks like when UserList
is first rendered:
Updating state with properties
You've seen how the componentWillMount()
and componentDidMount()
lifecycle methods help get your component the data it needs. There's one more scenario that we should consider here—re-rendering the component container.
Let's take a look at a simple button
component that tracks the number of times it's been clicked:
import React from 'react'; export default ({ clicks, disabled, text, onClick, }) => ( <section> { /* Renders the number of button clicks, using the "clicks" property. */ } <p>{clicks} clicks</p> { /* Renders the button. It's disabled state is based on the "disabled" property, and the "onClick()" handler comes from the container component. */} <button disabled={disabled} onClick={onClick} > {text} </button> </section> );
Now, let's implement a container component for this feature:
import React, { Component } from 'react'; import { fromJS } from 'immutable'; import MyButton from './MyButton'; class MyFeature extends Component { state = { data: fromJS({ clicks: 0, disabled: false, text: '', }), } // Getter for "Immutable.js" state data... get data() { return this.state.data; } // Setter for "Immutable.js" state data... set data(data) { this.setState({ data }); } // Sets the "text" state before the initial render. // If a "text" property was provided to the component, // then it overrides the initial "text" state. componentWillMount() { this.data = this.data .set('text', this.props.text); } // If the component is re-rendered with new // property values, this method is called with the // new property values. If the "disabled" property // is provided, we use it to update the "disabled" // state. Calling "setState()" here will not // cause a re-render, because the component is already // in the middle of a re-render. componentWillReceiveProps({ disabled }) { this.data = this.data .set('disabled', disabled); } // Click event handler, increments the "click" count. onClick = () => { this.data = this.data .update('clicks', c => c + 1); } // Renders the "<MyButton>" component, passing it the // "onClick()" handler, and the state as properties. render() { return ( <MyButton onClick={this.onClick} {...this.data.toJS()} /> ); } } MyFeature.defaultProps = { text: 'A Button', }; export default MyFeature;
The same approach as the preceding example is taken here. Before the component is mounted, set the value of the text state to the value of the text property. However, we also set the text state in the componentWillReceiveProps()
method. This method is called when property values change, or in other words, when the component is re-rendered. Let's see how we can re-render this component and whether or not the state behaves as we'd expect it to:
import React from 'react'; import { render as renderJSX } from 'react-dom'; import MyFeature from './MyFeature'; // Determines the state of the button // element in "MyFeature". let disabled = true; function render() { // Toggle the state of the "disabled" property. disabled = !disabled; renderJSX( (<MyFeature {...{ disabled }} />), document.getElementById('app') ); } // Re-render the "<MyFeature>" component every // 3 seconds, toggling the "disabled" button // property. setInterval(render, 3000); render();
Sure enough, everything goes as planned. Whenever the button is clicked, the click counter is updated. But as you can see, <MyFeature>
is re-rendered every 3 seconds, toggling the disabled state of the button. When the button is re-enabled and clicking resumes, the counter continues from where it left off.
Here is what the MyButton
component looks like when first rendered:
Here's what it looks like after it has been clicked a few times and the button has moved into a disabled state: