π
Prep
Take a template and populate it with data; then update the data in response to user interaction
πͺ Reacting to user input
Learning Objectives
As users interact with web applications, they trigger events by doing things like clicking buttons, submitting forms, or typing text. We need to respond to these events. Let’s explore a common example: searching.
<label>
Search <input type="search" id="q" name="q" placeholder="Search term" /> π
</label>
When a user types text into a search box, we want to capture their input and use it to filter and redisplay search results. This means the state of the application changes as the user types. We need to react to this change by updating the UI.
We’ll explore these ideas today. Code along with the examples in this lesson.
Youtube: Step-through-prep workshop π
𧩠Break down the problem
Learning Objectives
We already have a website for displaying film listings.
Let’s think through building this film search interface step-by-step. Write down your sequence of steps to build this interface.
Given a view of film cards and search box
When a user types in the search box
Then the view should update to show only matching films.
- π Display search box and initial list of films
- π¦»π½ Listen for user typing in search box
- ποΈ Capture latest string when user types
- π¬ Filter films list based on search text
- πΊ Update UI with filtered list
The key aspects we need to handle are capturing input and updating UI.
ππΏ Capturing Input
We need to listen for the input
event on the search box to react as the user types. When the event fires, we can read the updated string value from the search box input element.
π¬ Filtering Data
Once we have the latest search text, we can filter the list of films. We can use JavaScript array methods like .filter()
to return films that match our search string.
π Updating UI
With the latest filtered list of films in hand, we re-render these films to display the updated search results. We can clear the current film list and map over the filtered films to add updated DOM elements.
Thinking through these aspects separately helps frame the overall task. Next we can focus on each piece:
- ππΏ Listening for input
- π¬ Filtering data
- π Re-rendering UI with the films example.
π‘ Tip
π Why clear out the list and make new elements?
We could go through the existing elements, and change them. We could add a hidden
CSS class to ones we want to hide, and remove a hidden
CSS class from those we want to show.
But we prefer to clear out the list and make new elements. We do not want to change existing ones.
π§π½ββοΈ Do the simplest thing
It is simpler because we have fewer things to think about. With either approach, we need to solve the problem “which films do we want to show”. By clearing out elements, we then only have to solve the problem “how do I display a film?”. We don’t also need to think about “how do I hide a film?” or “how do I show a film that was hidden?”.
π± A place for everything
In our pattern we only deal with how we turn data into a card in one place. If we need to worry about changing how a card is displayed, that would have to happen somewhere else.
By making new cards, we avoid thinking about how cards change.
We can focus.
π Identifying state
Learning Objectives
π State: data which may change over time.
We store each piece of state in a variable. When we render in the UI, our code will look at the state in those variables. When the state changes, we render our UI again based on the new state.
“What the state used to be” or “How the state changed” isn’t something we pay attention to when we render. We always render based only on the current state.
We want to have as few pieces of state as possible. We want them to be fundamental.
Some guidelines for identifying the state for a problem:
βοΈ If something can change it should be state.
In our film example, the search term can change, so it needs some state associated with it.
β But if something can be derived it should not be state.
In our film example, we would not store “is the search term empty” and “what is the search term” as separate pieces of state. We can work this answer out ourselves. This answer can be derived. We can answer the question “is the search term empty” by looking at the search term. We don’t need two variables: we can use one.
ποΈ If two things always change together, they should be one piece of state.
If a website allows log-in, we would not have one state for “is a user logged in” and one state for “what user is logged in”. We would have one piece of state: The currently logged in user. We would set that state to null
if there is no logged in user. We can answer the question “is a user logged in” by checking if the currently logged in user is null
. We don’t need a separate piece of state.
State in our example
In our film example, we need two pieces of state:
- Our list of all films
- The search term
When we introduce filtering films based on the search term we will not introduce other new state. We will not store a filtered list of films in state. Our filtered list of films can be derived from our existing state.
π§Ό Refactoring to state+render
Learning Objectives
We are going to introduce a common pattern in writing UIs, which is to define and use a function called render
.
Up until now, our film website has been static: it never changes. By introducing a search input, our website is becoming dynamic: it can change. This means that we may need to re-run the code which creates our UI elements.
So before we add the new functionality to our website, we are going to
render
:
const films = [
// You have this array from before.
];
function createFilmCard(filmData) {
// You should have an implementation of this function from before.
}
function render() {
const filmCards = films.map(createFilmCard);
document.body.append(...filmCards);
}
We’re missing one thing: We’re never calling our render
function! Call your render
function after you define it:
const films = [
// You have this array from last week.
];
function createFilmCard(filmData) {
// You should have an implementation of this function from last week.
}
function render() {
const filmCards = films.map(createFilmCard);
document.body.append(...filmCards);
}
render();
Your application should now work exactly the same as it did before. Because we moved our code into a function, this means we can call that function again if we need to, for instance when someone searches for something.
We saw this same pattern when we made the character limit component. We called the same function on page load, and when someone typed something.
Storing our state somewhere
Up until now, we had a variable called films
, and we created some cards based on that variable.
Let’s move this films
variable inside an object called state
, to make it clear to us what the state is in our application.
const state = {
films: [
{
title: "Killing of Flower Moon",
director: "Martin Scorsese",
times: ["15:35"],
certificate: "15",
duration: 112,
},
{
title: "Typist Artist Pirate King",
director: "Carol Morley",
times: ["15:00", "20:00"],
certificate: "12A",
duration: 108,
},
],
};
Each time we need to store more information we should think: Is this a piece of state, or is this something we’re deriving from existing state? Whenever something in our state changes, we will tell our UI just to show “whatever is in the state” by calling the render
function. In this way, we simplify our UI code by making it a function of the state.
π‘ Tip
state
. It was already state when it was called films
. But naming this variable state
can help us to think about it more clearly.Make sure to update any references to the films
variable you may have had before to instead reference state.films
.
This is another refactoring: we didn’t change what our application does, we just moved a variable.
π Introducing new state
We are introducing a new feature: being able to search for films. We have identified that this introduces one new element of state: the search term someone has asked for.
Let’s add it to our state object:
const state = {
films: [
{
title: "Killing of Flower Moon",
director: "Martin Scorsese",
times: ["15:35"],
certificate: "15",
duration: 112,
},
{
title: "Typist Artist Pirate King",
director: "Carol Morley",
times: ["15:00", "20:00"],
certificate: "12A",
duration: 108,
},
],
searchTerm: "",
};
We needed to pick an initial value for this state. We picked the empty string, because when someone first loads the page, they haven’t searched for anything. When someone types in the search box, we will change the value of this state, and re-render the page.
We could pick any initial value. This actually allows us to finish implementing our render function before we even introduce a search box into the page. In real life, our searchTerm
state will be empty at first, but we can use different values to help us with development. We can make the page look like someone searched for “Pirate”, even before we introduce a search box in the UI.
This is because we have split up our problem into three parts:
- π©πΎβπ¬ Identify what state we have.
- βπΏ Define how to render the page based on that state.
- π± Change state (perhaps in response to some user action).
Let’s try making our render function work for the search term “Pirate”. Change the initial value of the searchTerm
field of the state
object to “Pirate”:
const state = {
films: [
{
title: "Killing of Flower Moon",
director: "Martin Scorsese",
times: ["15:35"],
certificate: "15",
duration: 112,
},
{
title: "Typist Artist Pirate King",
director: "Carol Morley",
times: ["15:00", "20:00"],
certificate: "12A",
duration: 108,
},
],
searchTerm: "Pirate",
};
We expect, if someone is searching for “Pirate”, to only show films whose title contains the word Pirate.
π± Rendering based on state
Learning Objectives
For now, we have set the initial value of the searchTerm
state to “Pirate”. This means that our render function should only create cards for films which contain the word “Pirate” in their title. But right now, our render function creates cards for all of the films.
In our render function, we must filter our list down to the films that match our search term. This does not require us to introduce new state. We can derive a filtered list from our existing state.
Filter function
We can use the higher order array function .filter()
to return a new array of films that include the search term:
const filteredFilms = state.films.filter((film) =>
film.title.includes(state.searchTerm)
);
We can change our render function to always do this. If searchTerm
is empty, our filter function will return all the films:
function render() {
const filteredFilms = state.films.filter((film) =>
film.title.includes(state.searchTerm)
);
const filmCards = filteredFilms.map(createFilmCard);
document.body.append(...filmCards);
}
- At this point in our codealong, when we open our page, what will we see?
- If we change the initial value of
state.searchTerm
back to the empty string and open the page again, what will we see?
If we open our page, we should now only see cards for films containing “Pirate” in their title.
If we change the initial value of state.searchTerm
back to the empty string and open the page again, we should see cards for all of the films.
We have now solved two of our three problems:
- Identify what state we have.
- Define how to render the page based on that state.
- Change state (perhaps in response to some user action).
Making our search more user friendly
π‘ Things to consider
One of the nice things about breaking down the problem like this is that it allows us to change rendering without needing to interact with the page.
If we want to improve our search functionality (e.g. to make it work if you searched for PIRATES in all-caps), we can set the initial value of state.searchTerm
to "PIRATES"
and make changes to our render
function. Then every time we open the page, it will be like we searched for “PIRATES”.
This can be a lot quicker than having to refresh the page and type in “PIRATES” in the search box every time we make a change want to see if our search works.
Exercise: Make search more user friendly
π¦»π» Capturing the user event
Learning Objectives
We’ve introduced our state, and our render works for different values of that state. But users of our website can’t change the searchTerm
state themselves. We need to introduce a way for them to change the searchTerm
state via the UI.
To listen for the search input event, we can add an
addEventListener
by passing the event name and a handling function.
const searchBox = document.getElementById("search");
searchBox.addEventListener("input", handleSearchInput);
function handleSearchInput(event) {
// React to input event
}
When the “input” event fires, our handler function will run. Inside the handler we can access the updated input value: const searchTerm = event.target.value;
So our key steps are:
- Add an input event listener to the search box
- In the handler, get
value
of input element - Set the new state based on this value.
- Call our
render
function again.
β οΈ One thing at a time!
console.log
the search term.We will make sure this works before we try to change the UI. Why? If we try to add the event listener and something doesn’t work, we will only have a little bit of code to debug.
If we tried to solve the whole problem (updating the UI) and something didn’t work, we would have a lot of code to debug, which is harder!
We’ve now demonstrated that we can capture search text on every keystroke:
const searchBox = document.getElementById("search");
searchBox.addEventListener("input", handleSearchInput);
function handleSearchInput(event) {
const searchTerm = event.target.value;
console.log(searchTerm);
}
π Re-rendering
Learning Objectives
Now that we’ve shown we can log the search text, we can set the new value of the searchTerm
state, and re-render the page.
We should have a page like this:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Film View</title>
</head>
<body>
<label>Search <input type="text" id="search" /></label>
<template id="film-card">
<section>
<h3>Film title</h3>
<p data-director>Director</p>
<time>Duration</time>
<data data-certificate>Certificate</data>
</section>
</template>
<script>
const state = {
films: [
{
title: "Killing of Flower Moon",
director: "Martin Scorsese",
times: ["15:35"],
certificate: "15",
duration: 112,
},
{
title: "Typist Artist Pirate King",
director: "Carol Morley",
times: ["15:00", "20:00"],
certificate: "12A",
duration: 108,
},
],
searchTerm: "",
};
const template = document.getElementById("film-card");
const createFilmCard = (film) => {
const card = template.content.cloneNode(true);
// Now we are querying our cloned fragment, not the entire page.
card.querySelector("h3").textContent = film.title;
card.querySelector("[data-director]").textContent = `Director: ${film.director}`;
card.querySelector("time").textContent = `${film.duration} minutes`;
card.querySelector("[data-certificate]").textContent = `Certificate: ${film.certificate}`;
// Return the card, rather than directly appending it to the page
return card;
};
function render() {
const filteredFilms = state.films.filter((film) =>
film.title.includes(state.searchTerm)
);
const filmCards = filteredFilms.map(createFilmCard);
document.body.append(...filmCards);
}
const searchBox = document.getElementById("search");
searchBox.addEventListener("input", handleSearchInput);
function handleSearchInput(event) {
const searchTerm = event.target.value;
console.log(searchTerm);
}
render();
</script>
</body>
</html>
We want to change our search input handler to update state.searchTerm
and call render()
again.
Implement this and try searching. What happens? Play computer to work out why what’s happening isn’t what we expected.
π Actually re-rendering
Learning Objectives
We have seen that when we search, we’re only adding new elements, and not removing existing elements from the page.
We previously identified our strategy of clearing old elements before adding new ones. But we are not doing this.
We can clear out the existing children of an element by setting its textContent
propery to the empty string:
document.body.textContent = "";
Add this to your render
function before you add new elements. Try using your page. Try searching for a particular film.
Oh no, our search box is gone!
exercise
We removed our search box from the page because we removed everything from the entire document body.
This was not our intention - we only wanted to remove any films we had previously rendered.
A way to solve this is to introduce a container element which our render
function will re-fill every time it’s called.
We should identify which elements in our page should be re-drawn every time we render, and which should always be present.
Introduce a new container, and keep any “always present” UI elements outside of it. Update your render
function to clear and append to the container, not the whole body.
Remember to use semantic HTML. Your container should be an appropriate tag for the contents it will have.