{% hint style="info" %} Follow along with code examples here! {% endhint %}
- Setup
- Overview
- Explore the Solution
- Building from Scratch
- Concepts Checklist
npm i
npm run devThis case study application displays recipes from the https://dummyjson.com/recipes API, allowing users to click on a recipe card to see more details about it. They can also search for recipes using the provided form.
This case study application demonstrates DOM manipulation, fetching with .then()/.catch(), fetching with async/await, ES Modules, event delegation, dataset, and form handling. The completed solution is in src-solution/.
The completed solution is in src-solution/. Use the exercises below to investigate how the code works before building it from scratch.
For each user experience, trace the path through the code across files to explain how it works. In order of execution, write down the sequence of function calls:
- where it was called
- a brief description of what it does
- what was returned.
Assume there are no errors unless specified. A "sequence diagram" may be drawn to better illustrate the flow.
An example is provided for the first scenario.
Sequence Flow:
main.js: On page load,getRecipes()is called.fetch-helpers.js:getRecipes()callsfetch(...), checksresponse.ok, extracts JSON data, and returns a promise resolving torecipes.main.js:.then((recipes) => ...)receives the resolved value and callshideError()andrenderRecipes(recipes).dom-helpers.js:renderRecipes()clears the old list, updates the count, creates recipe<li>cards, and appends them to#recipes-list.
Answer
main.js: The click handler on#recipes-listruns.event.target.closest('li')finds the clicked card and gives usli.main.js:getRecipeById(li.dataset.recipeId)is called with the card’s stored id.fetch-helpers.js:getRecipeById()callsfetch(...), checksresponse.ok, extracts JSON data, and returns a promise resolving torecipe.main.js:.then((recipe) => ...)receives the recipe updates the DOM withhideError()andrenderRecipeDetails(recipe).dom-helpers.js:renderRecipeDetails()shows#recipe-details, clears old content, builds the new details elements, and appends them.
Answer
main.js: The form submit handler runs, callsevent.preventDefault(), and readssearchTermplusisQuick(.checked).main.js:await getRecipeBySearchTerm(searchTerm)is called with the search term.fetch-helpers.js:getRecipeBySearchTerm()callsfetch(...), checksresponse.ok, extracts JSON data, and returns a promise resolving to a{ data, error }object.main.js: Afterawait, if there is no error and results exist, it optionally filters recipes to <= 20 minutes whenisQuickis true.main.js: It then callshideError()andrenderRecipes(recipes).dom-helpers.js:renderRecipes()clears old cards, updates the count, and appends the new search results.
Answer
main.js: The submit handler callsawait getRecipeBySearchTerm(searchTerm).fetch-helpers.js: The fetch fails (network failure or!response.ok), so thecatchblock returns{ data: null, error }.main.js: The resolved value is{ data: null, error }, soif (error)is true andrenderError(...)is called.renderRecipes(...)is not called.dom-helpers.js:renderError()removeshiddenand sets error text, so the error message appears.- The message stays visible because there is no timeout-based hide. It is only cleared later when a success branch explicitly calls
hideError().
Open each file and answer the questions.
- What does
type="module"on the<script>tag enable? - Find the fallback recipe card in the
ul. What will happen to it once JavaScript loads successfully? - Which elements start with
class="hidden"? Why would we hide them by default? - What
data-attribute is on the fallback<li>? What value does it have?
Answers
- It enables ES modules in the browser, so we can use
import/exportin JavaScript files. - It gets removed when
renderRecipesruns, becauserecipesList.innerHTML = ''clears the list before new cards are appended. #recipe-detailsand#error-messagestart hidden so details and errors only appear when relevant user actions or failures occur.- The fallback card has
data-recipe-id="1".
- What does
getRecipesresolve to if the fetch succeeds? What does it resolve to if the fetch fails? - What kind of errors does checking
response.okhandle that.catch()does not handle on its own? - The API returns an object like
{ recipes: [...], total: 50, ... }. IngetRecipes, where is just the recipe array extracted, and what would break if we returned the full object instead? getRecipeByIdandgetRecipesfollow the same pattern. What is the one structural difference between them? Why doesn'tgetRecipeByIdneed a second.then()?- Compare and contrast
getRecipeBySearchTermwith the other fetch helpers. What are the benefits/tradeoffs of usingasync/await+try/catchand returning{ data, error }? Which style of handling promises do you prefer?
Answers
- It resolves to
data.recipes(an array) on success, and resolves tonullon failure. - It handles HTTP failure responses (like 404/500) that do not reject
fetchby default..catch()alone only handles rejected Promises (network/throw errors). - It is extracted in the second
.then((data) => { return data.recipes; }). If we returned the full object, code expecting an array (likerenderRecipes(recipes)andrecipes.length/forEach) would break. getRecipeshas an extra.thento extractdata.recipes;getRecipeByIddoes not because that endpoint already returns a single recipe object directly.async/awaitcan be easier to read and debug for sequential logic, and{ data, error }gives a consistent result shape. Tradeoff: it introduces a different return contract from the other helpers (null), so callers must handle two patterns. It is best to stick to one pattern so choose your preference!
- Why does
renderRecipesclear#recipes-listbefore rendering? - Why does
renderRecipeDetailsremove thehiddenclass? - What is the difference between
renderErrorandhideError? - What does
li.dataset.recipeId = recipe.idadd to the DOM, what is that value used for later, and why store it on each card?
Answers
- To remove old/fallback content before rendering new results and avoid duplicate cards.
- The details section is hidden by default, so removing
hiddenmakes the selected recipe details visible. renderError(msg)shows the error element and sets text.hideError()clears text and hides it.- It adds a
data-recipe-idattribute on eachli. Later, the click handler readsli.dataset.recipeIdto callgetRecipeById(...)with the id of the clicked item. Storing it on each card keeps the card tied to its API ID for event-driven fetching.
- What are the three actions that can trigger a fetch in this file?
- Where is event delegation used, and why?
- Where is the search form handled, and why is the handler
async? - Where does the quick filter (
Under 20 Minutes) apply? - In which branches is
hideError()called?
Answers
- Initial page load (
getRecipes), clicking a recipe card (getRecipeById), and submitting the search form (getRecipeBySearchTerm). - On
#recipes-listclick. One parent listener handles clicks on dynamically rendered cards, including clicks on child elements viaclosest('li'). - In the
submithandler for#search-form; it isasyncbecause it awaitsgetRecipeBySearchTerm(...). - In the search success branch, after fetch resolves: if
isQuickis true, results are filtered by total prep + cook time <= 20. - In three success branches: after successful initial load, after successful recipe-details fetch, and after successful search before rendering results.
So, how could you build this application from scratch?
The process for creating an interactive and data-driven user interface typically follows this order:
- Create the HTML with
idandclassattributes so we can target elements. Leave empty containers for content generated with JavaScript/DOM manipulation. - Create fetch helper functions and test with console logs.
- Create rendering helper functions. Data in -> DOM out.
- Connect the data source to rendering logic. This can look like:
- Page load -> fetch -> render
- User click -> fetch -> render
- Form submit -> extract form data -> fetch -> render
For each feature below, you'll see this pattern repeating itself!
We've taken care of the HTML for you. Walk through the provided files before writing JavaScript. Pay attention to the empty containers and the elements with ids that we use in our JavaScript.
index.html- Take note of:- The hardcoded fallback recipe card in the
ul(what users see before JS loads or if fetch fails). - The
#recipe-detailssection withclass="hidden"(hidden by default, shown on click). - The
#error-messageparagraph withclass="hidden". - The search form (
#search-form) withsearchTermtext input andisQuickcheckbox.
- The hardcoded fallback recipe card in the
styles.css- Take note of the class.hidden { display: none !important; }.src/main.js- Starter file is empty.
These next 4 steps walk through implementing the first feature: fetching and rendering a list of recipes.
Skills:
fetch,.then(),.catch(),response.ok, named exports, returning from.then()
Create the file and write getRecipes:
export const getRecipes = () => {
return fetch('https://dummyjson.com/recipes?limit=9')
.then((response) => {
if (!response.ok) {
throw Error(`Fetch failed. ${response.status} ${response.statusText}`);
}
return response.json();
})
.then((data) => {
return data.recipes;
})
.catch((error) => {
console.error(error.message);
return null;
});
};Key Details:
fetch()returns a Promise.- Check
response.okbecause 404/500 do not automatically reject fetch. response.json()returns a Promise and must be returned.- We extract
data.recipesfrom the API response object. - On failure, return
nullso callers can handle errors.
Skills: named imports with
.jsextension,.then()on returned Promise
import { getRecipes } from './fetch-helpers.js';
getRecipes().then((recipes) => {
console.log(recipes);
});You should see an array of recipe objects in the console.
Skills:
document.createElement,append,dataset,innerHTML = '', named exports
export const renderRecipes = (recipes) => {
const recipesList = document.querySelector('#recipes-list');
const recipeCount = document.querySelector('#recipe-count');
recipesList.innerHTML = '';
recipeCount.textContent = recipes.length;
recipes.forEach((recipe) => {
const li = document.createElement('li');
li.dataset.recipeId = recipe.id;
const img = document.createElement('img');
img.src = recipe.image;
img.alt = recipe.name;
const h3 = document.createElement('h3');
h3.textContent = recipe.name;
const info = document.createElement('p');
info.textContent = `${recipe.cuisine} · ${recipe.difficulty}`;
li.append(img, h3, info);
recipesList.append(li);
});
};Skills: module imports, null checking
import { getRecipes } from './fetch-helpers.js';
import { renderRecipes } from './dom-helpers.js';
getRecipes().then((recipes) => {
if (recipes === null) {
console.log('Failed to load recipes.');
return;
}
renderRecipes(recipes);
});We've now completely implemented the first feature: fetching and rendering all recipes!
This last step adds useful feedback for the user when errors occur.
Skills: error state rendering, explicit UI state management
Add to dom-helpers.js:
const errorMessage = document.querySelector('#error-message');
export const renderError = (msg) => {
errorMessage.classList.remove('hidden');
errorMessage.textContent = msg;
};
export const hideError = () => {
errorMessage.textContent = '';
errorMessage.classList.add('hidden');
};Then use these in main.js:
- Call
renderError(...)when fetch or search fails. - Call
hideError()in success branches (after successful page-load fetch, recipe-details fetch, and successful search) so errors are dismissed manually when the app recovers.
For example:
import { getRecipes } from './fetch-helpers.js';
import { renderRecipes } from './dom-helpers.js';
getRecipes().then((recipes) => {
if (recipes === null) {
renderError('Failed to load recipes.');
return;
}
hideError();
renderRecipes(recipes);
});We've taken care of the HTML for you. Walk through the provided files before writing JavaScript. Pay attention to the empty containers and the elements with ids that we use in our JavaScript.
index.html- Take note of:- The
#recipe-detailssection withclass="hidden"(hidden by default, shown on click). - The
data-recipe-idattribute on the fallback list item
- The
styles.css- Take note of the class.hidden { display: none !important; }.
These next three steps walk through implementing the second feature: fetching recipe details when we click on a specific recipe.
Skills: template literals, shared fetch pattern
export const getRecipeById = (id) => {
return fetch(`https://dummyjson.com/recipes/${id}`)
.then((response) => {
if (!response.ok) {
throw Error(`Fetch failed. ${response.status} ${response.statusText}`);
}
return response.json();
})
.catch((error) => {
console.error(error.message);
return null;
});
};Skills: event delegation,
closest(),dataset, second fetch + render
import { getRecipes, getRecipeById } from './fetch-helpers.js';
import { renderRecipes, renderRecipeDetails } from './dom-helpers.js';
// ... existing getRecipes code ...
const recipesList = document.querySelector('#recipes-list');
recipesList.addEventListener('click', (event) => {
const li = event.target.closest('li');
if (!li) return;
getRecipeById(li.dataset.recipeId).then((recipe) => {
if (recipe === null) {
console.log('Failed to load recipe details.');
return;
}
renderRecipeDetails(recipe);
});
});Skills: showing hidden content, nested list rendering
export const renderRecipeDetails = (recipe) => {
const detailsSection = document.querySelector('#recipe-details');
detailsSection.classList.remove('hidden');
detailsSection.innerHTML = '';
const h2 = document.createElement('h2');
h2.textContent = recipe.name;
const img = document.createElement('img');
img.src = recipe.image;
img.alt = recipe.name;
const info = document.createElement('p');
info.textContent = `${recipe.cuisine} · ${recipe.difficulty} · ${recipe.cookTimeMinutes + recipe.prepTimeMinutes} min · ${recipe.rating}/5`;
const ingredientsH3 = document.createElement('h3');
ingredientsH3.textContent = 'Ingredients';
const ingredientsList = document.createElement('ul');
recipe.ingredients.forEach((ingredient) => {
const li = document.createElement('li');
li.textContent = ingredient;
ingredientsList.append(li);
});
detailsSection.append(h2, img, info, ingredientsH3, ingredientsList);
};This completes the second feature: clicking on a list item to fetch its details.
We've taken care of the HTML for you. Walk through the provided files before writing JavaScript. Pay attention to the empty containers and the elements with ids that we use in our JavaScript.
index.html- Take note of:- The search form (
#search-form) withsearchTermtext input andisQuickcheckbox.
- The search form (
These next two steps walk through implementing the final feature: searching for recipes.
Skills:
async/await,try/catch, standardized{ data, error }return object
Add this new helper in fetch-helpers.js:
export const getRecipeBySearchTerm = async (searchTerm) => {
try {
const response = await fetch(`https://dummyjson.com/recipes/search?q=${searchTerm}`);
if (!response.ok) {
throw Error(`Fetch failed. ${response.status} ${response.statusText}`);
}
const data = await response.json();
return { data: data.recipes, error: null };
} catch (error) {
console.error(error.message);
return { data: null, error };
}
};Why this differs from the other fetch helpers:
- It demonstrates the
async/await+try/catchstyle. - It returns an object with
{ data, error }so callers can consistently inspect both success and failure fields (dataanderror).
Skills: form submit handling,
.checkedfor checkboxes, async event handlers, conditional filtering
import { getRecipeBySearchTerm } from './fetch-helpers.js';
import { renderRecipes, renderError, hideError } from './dom-helpers.js';
const form = document.querySelector('#search-form');
form.addEventListener('submit', async (event) => {
event.preventDefault();
const searchTerm = form.elements.searchTerm.value;
const isQuick = form.elements.isQuick.checked;
// Notice that we can use object destructuring to get the object properties
const { data, error } = await getRecipeBySearchTerm(searchTerm);
if (error) {
// We can now display the actual error message to the user
renderError(`Failed to find recipes. Try again later. Error: ${error.message}`);
} else if (data.length === 0) {
renderError('Could not find recipes matching that search term.');
} else {
let recipes = data;
if (isQuick) {
recipes = data.filter((recipe) => (recipe.prepTimeMinutes + recipe.cookTimeMinutes) <= 20);
}
hideError();
renderRecipes(recipes);
}
});By the end of this walkthrough, you have demonstrated:
- Vite dev server and
<script type="module"> - ES Modules:
exportandimportwith.jsextension - Separation of concerns:
fetch-helpers.js,dom-helpers.js,main.js -
fetch()with.then()and.catch() -
fetch()withasync/awaitandtry/catch - Checking
response.okand throwing errors - Returning from
.then()to chain promises - Returning
nullon error (for list/details helpers) - Returning
{ data, error }for standardized search error handling - Handling form submit with
event.preventDefault() - Reading checkbox state with
.checked -
document.createElement+ modify +appendpattern -
innerHTML = ''to clear containers before re-rendering -
datasetto store IDs on elements - Event delegation with a listener on a parent element
-
event.target.closest('li')to find clicked cards - Manual error rendering/hiding with
renderError()andhideError()

