Lifting State Up
Lifting state up is a common refactoring in React app. This section will discuss when you need to do that and how to do that.
Allow user to Edit Movie
Currently our app only allow us to create movie, but it would be better if our app allows us to edit the existing movie too. Let’s do that in this section.
Prerequisite: API Service for Edit Movie
Similar to create movie, the prerequisite for our app to support edit movie is the backend service supports it.
Let’s test the API with Restlet Client that we’ve installed in previous section:
- select “PUT” in the METHOD dropdown.
- enter the following URL in the URL bar:
https://react-intro-movies.herokuapp.com/movies/{movieId}
- you need to replace
{movieId}
in the URL with the id of the movie that you want to update. - you can get the movie id by inspecting the payload of the GET request. For instance, id for “Aquaman” is 1, while id for “A Star Is Born” is 4.
- you need to replace
- add the following content in the BODY field (change it to the movie you want to update):
{ "id": 4, "name": "A Star Is Born v2", "releaseDate": "2018-10-03" }
- click send
Now when you load your app, you should be see your update is shown.
Add Ajax Call Function to Make the PUT Request
Create a saveMovie
function in api.js
:
export const saveMovie = (movie) =>
axios
.put(`https://react-intro-movies.herokuapp.com/movies/${movie.id}`, movie)
.then((res) => res.data);
Since there is some duplications of the URL, let’s refactor them:
import axios from 'axios';
const MOVIE_ENDPOINT = 'https://react-intro-movies.herokuapp.com/movies';
export const loadMovies = () => axios(MOVIE_ENDPOINT).then((res) => res.data);
export const createMovie = (movie) => axios.post(MOVIE_ENDPOINT, movie).then((res) => res.data);
export const saveMovie = (movie) =>
axios.put(`${MOVIE_ENDPOINT}/${movie.id}`, movie).then((res) => res.data);
- similar to
axios.put
, the second parameter ofaxios.put
is the data you want to submit.
The Interaction of Edit Movie
Let’s discuss how we want the edit movie interaction be like.
- By default, the form will be used to create movie.
- When user click on a movie on the movie list, the form will be populated with the details of the movie, and the form will be used to edit the movie.
- User can click “Cancel” button to restore the form to become create movie form again.
Implementation Overview of Edit Movie
Now that we understand the interaction, let’s discuss how are we going to implements it before we write the code.
We need to complete the following tasks:
- a state to track if any movie is selected. If a movie selected, make the form as an edit form, else is a create form.
- enhance
MovieForm
so it will display different text based on if movie is selected, e.g. submit button is labelled “Save” instead of “Create”, the form title is “Edit Movie” instead of “Create Movie”. - add a Reset button in
MovieForm
to unselect Movie and clear out all its current form value. - a way to update the form values when movie is selected, so that the values will be defaulted to the movie data.
- when it is edit, call
saveMovie
instead ofcreateMovie
api function.
Let’s implements these.
Add isEdit State
In the App
component, let’s add a isEdit
state (which will be passed to MovieForm
component) while setIsEdit
will be passed to Movie
component as onClick
props:
function App() {
const [moviesShown, toggleShowMovies] = useToggle(false);
const { movies, isLoading, loadMoviesData } = useMovieData();
// highlight-start
const [isEdit, setIsEdit] = React.useState(false);
const resetForm = () => {
setIsEdit(false);
};
// highlight-end
return (
<div>
<TitleBar>
<h1>React Movie App</h1>
</TitleBar>
<div className="container">
<div>
<div className="button-container">
<Button onClick={toggleShowMovies}>{moviesShown ? 'Hide' : 'Show'} Movies</Button>
</div>
{moviesShown && (
<BusyContainer isLoading={isLoading}>
{movies.map((movie) => (
<Movie
name={movie.name}
releaseDate={movie.releaseDate}
onClick={() => setIsEdit(true)} // [!code highlight]
key={movie.id}
/>
))}
</BusyContainer>
)}
</div>
<div>
<MovieForm
onSubmitSuccess={loadMoviesData}
onReset={resetForm} // [!code highlight:2]
isEdit={isEdit}
/>
</div>
</div>
</div>
);
}
Let’s update Movie
component to call the onClick
props:
export const Movie = (props) => (
<div
className="movie-container selectable" // [!code highlight:11]
onClick={props.onClick}
onKeyDown={(e) => {
if (
e.keyCode === 32 || // space
e.keyCode === 13 // enter
) {
props.onClick();
}
}}
tabIndex={0}
>
<h1>{props.name}</h1>
<h2>{props.releaseDate}</h2>
</div>
);
- the
selectable
class is added for better visual hint during mouse hover. onClick
props ondiv
will be called when thediv
and its descendents are clicked.tabIndex
is added so that so that it will be target of keyboard tab, andonKeyDown
is to handle keyboard press enter or space.
And update MovieForm
component to show different text and ability to resetForm:
import { Button } from './components/button'; // [!code highlight]
// existing code
export const MovieForm = ({ isEdit, onSubmitSuccess, onReset }) => {
// [!code highlight]
const { values, setName, setReleaseDate } = useMovieFormData();
const handleSubmit = (ev) => {
ev.preventDefault();
createMovie(values).then(() => {
onSubmitSuccess();
setName('');
setReleaseDate('');
});
};
return (
<div className="movie-form">
<form onSubmit={handleSubmit}>
<legend>{isEdit ? 'Edit' : 'Create'} Movie</legend>
<div className="field">
<label htmlFor="name" className="label">
Name
</label>
<input
className="input"
value={values.name}
id="name"
name="name"
onChange={(ev) => setName(ev.target.value)}
required
/>
</div>
<div className="field">
<label htmlFor="releaseDate" className="label">
Release Date
</label>
<input
className="input"
value={values.releaseDate}
id="releaseDate"
name="releaseDate"
type="date"
onChange={(ev) => setReleaseDate(ev.target.value)}
required
/>
</div>
<div className="button-container">
<button type="submit" className="submit-button">
{isEdit ? 'Save' : 'Create'}
</button>
<Button onClick={onReset}>Cancel</Button>
</div>
</form>
</div>
);
};
If you try your app now, when you click the “Cancel” button, you would realize it would actually cause the page to reload.
This is because by default, clicking button in form would cause the form to be submitted. To avoid this behavior, you need to pass type="button
to the button element. And since we would prefer this to be default behavior, let’s update our Button
component to default the type
to "button"
:
export const Button = ({ type = 'button', ...props }) => (
<button className="button" type={type} {...props} />
);
Now try your app, you should able to click any movie and the form labels will be updated to edit mode, and click “Cancel” will make it create mode again.
Our current status:
a state to track if any movie is selected. If a movie selected, make the form as an edit form, else is a create form.enhanceMovieForm
so it will display different text based on if movie is selected, e.g. submit button is labelled “Save” instead of “Create”, the form title is “Edit Movie” instead of “Create Movie”.- add a Reset button in
MovieForm
tounselect Movieand clear out all its current form value. - a way to update the form values when movie is selected, so that the values will be defaulted to the movie data.
- when it is edit, call
saveMovie
instead ofcreateMovie
api function.
The next changes (clear form value and set form values with movie data) requires us to update the form value when movie is clicked (which is defined in App
component), but currently the movie form data is inside MovieForm
component, how do we do that?
Raising State Up
For us to achieve updating form data in App, the solution is raising state up, i.e. move the states related to the form data from MovieForm
component to App
component:
// src/app.js
// existing code
// highlight-start
// the following code is just cut and paste from movie-form.js
const useMovieForm = () => {
const [name, setName] = React.useState('');
const [releaseDate, setReleaseDate] = React.useState('');
return {
setName,
setReleaseDate,
values: {
name,
releaseDate,
},
};
};
// highlight-end
function App() {
const [moviesShown, toggleShowMovies] = useToggle(false);
const { movies, isLoading, loadMoviesData } = useMovieData();
const [isEdit, setIsEdit] = React.useState(false);
// highlight-start
const { setName, setReleaseDate, values } = useMovieForm();
const selectMovie = (movie) => {
setIsEdit(true);
setName(movie.name);
setReleaseDate(movie.releaseDate);
};
// highlight-end
const resetForm = () => {
setIsEdit(false);
// highlight-start
setName('');
setReleaseDate('');
// highlight-end
};
return (
<div>
<TitleBar>
<h1>React Movie App</h1>
</TitleBar>
<div className="container">
<div>
<div className="button-container">
<Button onClick={toggleShowMovies}>{moviesShown ? 'Hide' : 'Show'} Movies</Button>
</div>
{moviesShown && (
<BusyContainer isLoading={isLoading}>
{movies.map((movie) => (
<Movie
name={movie.name}
releaseDate={movie.releaseDate}
onClick={() => selectMovie(movie)}
key={movie.id}
/>
))}
</BusyContainer>
)}
</div>
<div>
<MovieForm
onSubmitSuccess={loadMoviesData}
onReset={resetForm}
isEdit={isEdit}
values={values}
setName={setName}
setReleaseDate={setReleaseDate}
/>
</div>
</div>
</div>
);
}
Update MovieForm
to use the props from parent:
export const MovieForm = ({
isEdit,
onSubmitSuccess,
resetForm,
// highlight-start
values,
setName,
setReleaseDate,
// highlight-end
}) => {
// highlight-next-line
// const { values, setName, setReleaseDate } = useMovieFormData();
const handleSubmit = (ev) => {
ev.preventDefault();
createMovie(values).then(() => {
onSubmitSuccess();
// highlight-next-line
resetForm();
});
};
// existing code
};
Our current status:
a state to track if any movie is selected. If a movie selected, make the form as an edit form, else is a create form.enhanceMovieForm
so it will display different text based on if movie is selected, e.g. submit button is labelled “Save” instead of “Create”, the form title is “Edit Movie” instead of “Create Movie”.add a Reset button inMovieForm
to unselect Movie and clear out all its current form value.a way to update the form values when movie is selected, so that the values will be defaulted to the movie data.- when it is edit, call
saveMovie
instead ofcreateMovie
api function.
Calling different API for edit
Let’s update our MovieForm
component:
import { createMovie, saveMovie } from './api'; // [!code highlight]
export const MovieForm = ({
isEdit,
onSubmitSuccess,
resetForm,
values,
setName,
setReleaseDate,
}) => {
const handleSubmit = (ev) => {
ev.preventDefault();
// highlight-start
const req = isEdit ? saveMovie(values) : createMovie(values);
req.then(() => {
// highlight-end
onSubmitSuccess();
resetForm();
});
};
// existing code
};
If you try it now, you may realize the save movie doesn’t work. This is because saveMovie call actually require id properties in your movie data. Let’s update the required code in App
component:
const useMovieForm = () => {
const [name, setName] = React.useState('');
const [releaseDate, setReleaseDate] = React.useState('');
const [id, setId] = React.useState(undefined); // highlight-line
return {
setName,
setReleaseDate,
setId, // highlight-line
values: {
id, // highlight-line
name,
releaseDate,
},
};
};
function App() {
// existing code
const { setName, setReleaseDate, setId, values } = useMovieForm(); // [!code highlight]
const selectMovie = (movie) => {
setIsEdit(true);
setName(movie.name);
setReleaseDate(movie.releaseDate);
setId(movie.id); // [!code highlight]
};
const resetForm = () => {
setIsEdit(false);
setName('');
setReleaseDate('');
setId(undefined); // [!code highlight]
};
// existing code
}
The app should works as expected now!
Do It: Lifting State Up
- Declare
isEdit
state inApp
component. - Display different text in
MovieForm
based onisEdit
props. - Move
useMovieForm
hook fromMovieForm
toApp
component. - Declare
selectMovie
andresetForm
function inApp
which will be passed on applicable component. - Create
saveMovie
function. - In
MovieForm
component, callsaveMovie
based onisEdit
props.