Remember Last Searches
Task: Remember the last five search terms which hit the API, and provide a button to move quickly between searches. When the buttons are clicked, stories for the search term should be fetched again.
Optional Hints:
- Don't use a new state for this feature. Instead, reuse the
url
state andsetUrl
state updater function to fetch stories from the API. Adapt them to multipleurls
as state, and to set multipleurls
withsetUrls
. The last URL fromurls
can be used to fetch the data, and the last five URLs fromurls
can be used to display the buttons.
Let's get to it. First, we will refactor all url
to urls
state and all setUrl
to setUrls
state updater functions. Instead of initializing the state with an url
as a string, make it an array with the initial url
as its only entry:
src/App.jsx
const App = () => {
...
const [urls, setUrls] = React.useState([
`${API_ENDPOINT}${searchTerm}`,
]);
...
};
Second, instead of using the current url
state for data fetching, use the last url
entry from the urls
array. If another url
is added to the list of urls
, it is used to fetch data instead:
src/App.jsx
const App = () => {
...
const handleFetchStories = React.useCallback(async () => {
dispatchStories({ type: 'STORIES_FETCH_INIT' });
try {
const lastUrl = urls[urls.length - 1];
const result = await axios.get(lastUrl);
dispatchStories({
type: 'STORIES_FETCH_SUCCESS',
payload: result.data.hits,
});
} catch {
dispatchStories({ type: 'STORIES_FETCH_FAILURE' });
}
}, [urls]);
...
};
And third, instead of storing the url
string as state with the state updater function, concatenate the new url
using the concat method with the previous urls
in an array for the new state:
src/App.jsx
const App = () => {
...
const handleSearchSubmit = (event) => {
const url = `${API_ENDPOINT}${searchTerm}`;
setUrls(urls.concat(url));
event.preventDefault();
};
...
};
With each search, another URL is stored in our state of urls
. Next, render a button for each of the last five URLs. We'll include a new universal handler for these buttons, and each passes a specific url
with a more specific inline handler:
src/App.jsx
const getLastSearches = (urls) => urls.slice(-5);
...
const App = () => {
...
const handleLastSearch = (url) => {
// do something
};
const lastSearches = getLastSearches(urls);
return (
<div>
<h1>My Hacker Stories</h1>
<SearchForm ... />
{lastSearches.map((url) => (
<button
key={url}
type="button"
onClick={() => handleLastSearch(url)}
>
{url}
</button>
))}
...
</div>
);
};
Next, instead of showing the whole URL of the last search in the button as button text, show only the search term by replacing the API's endpoint with an empty string:
src/App.jsx
const extractSearchTerm = (url) => url.replace(API_ENDPOINT, '');
const getLastSearches = (urls) =>
urls.slice(-5).map((url) => extractSearchTerm(url));
...
const App = () => {
...
const lastSearches = getLastSearches(urls);
return (
<div>
...
{lastSearches.map((searchTerm) => (
<button
key={searchTerm}
type="button"
onClick={() => handleLastSearch(searchTerm)}
>
{searchTerm}
</button>
))}
...
</div>
);
};
The getLastSearches
function now returns search terms instead of URLs. The actual searchTerm
is passed to the inline handler instead of the url
. By mapping over the list of urls
in getLastSearches
, we can extract the search term for each url
within the array's map method. Making it more concise, it can also look like this:
src/App.jsx
const getLastSearches = (urls) =>
urls.slice(-5).map(extractSearchTerm);
Now we'll provide functionality for the new handler used by every button, since clicking one of these buttons should trigger another search. Since we use the urls
state for fetching data, and since we know the last URL is always used for data fetching, concatenate a new url
to the list of urls
to trigger another search request:
src/App.jsx
const App = () => {
...
const handleLastSearch = (searchTerm) => {
const url = `${API_ENDPOINT}${searchTerm}`;
setUrls(urls.concat(url));
};
...
};
If you compare this new handler's implementation logic to the handleSearchSubmit
, you may see some common functionality. Extract this common functionality to a new handler and a new extracted utility function:
src/App.jsx
const getUrl = (searchTerm) => `${API_ENDPOINT}${searchTerm}`;
...
const App = () => {
...
const handleSearch = (searchTerm) => {
const url = getUrl(searchTerm);
setUrls(urls.concat(url));
};
const handleSearchSubmit = (event) => {
handleSearch(searchTerm);
event.preventDefault();
};
const handleLastSearch = (searchTerm) => {
handleSearch(searchTerm);
};
...
};
The new utility function can be used somewhere else in the App component. If you extract functionality that can be used by two parties, always check to see if it can be used by a third-party:
src/App.jsx
const App = () => {
...
// important: still wraps the returned value in []
const [urls, setUrls] = React.useState([getUrl(searchTerm)]);
...
};
The functionality should work, but it complains or breaks if the same search term is used more than once, because searchTerm
is used for each button element as key
attribute. Make the key more specific by concatenating it with the index
of the mapped array.
src/App.jsx
const App = () => {
...
return (
<div>
...
{lastSearches.map((searchTerm, index) => (
<button
key={searchTerm + index}
type="button"
onClick={() => handleLastSearch(searchTerm)}
>
{searchTerm}
</button>
))}
...
</div>
);
};
It's not the perfect solution, because the index
isn't a stable key (especially when adding items to the list); however, it doesn't break in this scenario. The feature works now, but you can add further UX improvements by following the tasks below.
More Tasks:
- (1) Do not show the current search as a button, only the five preceding searches. Hint: Adapt the
getLastSearches
function. - (2) Don't show duplicated searches. Searching twice for "React" shouldn't create two different buttons. Hint: Adapt the
getLastSearches
function. - (3) Set the SearchForm component's input field value with the last search term if one of the buttons is clicked.
The source of the five rendered buttons is the getLastSearches
function. There, we take the array of urls
and return the last five entries from it. Now we'll change this utility function to return the last six entries instead of five by removing the last one, in order to not show the current search as a button. Afterward, only the five previous searches are displayed as buttons:
src/App.jsx
const getLastSearches = (urls) =>
urls
.slice(-6)
.slice(0, -1)
.map(extractSearchTerm);
If the same search is executed two or more times in a row, duplicate buttons appear, which is likely not your desired behavior. It would be acceptable to group identical searches into one button if they followed each other. We will solve this problem in the utility function as well. Before separating the array into the five previous searches, group the identical searches:
src/App.jsx
const getLastSearches = (urls) =>
urls
.reduce((result, url, index) => {
const searchTerm = extractSearchTerm(url);
if (index === 0) {
return result.concat(searchTerm);
}
const previousSearchTerm = result[result.length - 1];
if (searchTerm === previousSearchTerm) {
return result;
} else {
return result.concat(searchTerm);
}
}, [])
.slice(-6)
.slice(0, -1);
The reduce function starts with an empty array as its result
. The first iteration concatenates the searchTerm
we extracted from the first url
into the result
. Every extracted searchTerm
is compared to the one before it. If the previous search term is different from the current, concatenate the searchTerm
to the result. If the search terms are identical, return the result without adding anything.
The SearchForm component's input field should be set with the new searchTerm
if one of the last search buttons is clicked. We can solve this using the state updater function for the specific value used in the SearchForm component.
src/App.jsx
const App = () => {
...
const handleLastSearch = (searchTerm) => {
setSearchTerm(searchTerm);
handleSearch(searchTerm);
};
...
};
Lastly, extract the feature's new rendered content from this section as a standalone component, to keep the App component lightweight:
src/App.jsx
const App = () => {
...
const lastSearches = getLastSearches(urls);
return (
<div>
...
<SearchForm ... />
<LastSearches
lastSearches={lastSearches}
onLastSearch={handleLastSearch}
/>
<hr />
...
</div>
);
};
const LastSearches = ({ lastSearches, onLastSearch }) => (
<>
{lastSearches.map((searchTerm, index) => (
<button
key={searchTerm + index}
type="button"
onClick={() => onLastSearch(searchTerm)}
>
{searchTerm}
</button>
))}
</>
);
This feature wasn't an easy one. Lots of fundamental React but also JavaScript knowledge was needed to accomplish it. If you had no problems implementing it yourself or in following the instructions, you are very well set. If you had one or the other issue, don't worry too much about it. Maybe you even figured out another way to solve this task and it may have turned out simpler than the one I showed here.
Exercises:
- Compare your source code against the author's source code.
- Recap all the source code changes from this section.
- Read more about grouping in JavaScript.
- Optional: Leave feedback for this section.