Case Study: Recipe Browser

circle-info

Follow along with code examples herearrow-up-right!

Setup

Overview

This case study application displays recipes from the https://dummyjson.com/recipesarrow-up-right 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.

A recipe browser application.

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/.

Explore the Solution

The completed solution is in src-solution/. Use the exercises below to investigate how the code works before building it from scratch.

Trace the Flow

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.

Scenario 1: The page loads and recipes are rendered on the screen

Sequence Diagram

Sequence Flow:

  1. main.js: On page load, getRecipes() is called.

  2. fetch-helpers.js: getRecipes() calls fetch(...), checks response.ok, extracts JSON data, and returns a promise resolving to recipes.

  3. main.js: .then((recipes) => ...) receives the resolved value and calls hideError() and renderRecipes(recipes).

  4. dom-helpers.js: renderRecipes() clears the old list, updates the count, creates recipe <li> cards, and appends them to #recipes-list.

Scenario 2: A user clicks a recipe card and the recipe details appear

chevron-rightAnswerhashtag
  1. main.js: The click handler on #recipes-list runs. event.target.closest('li') finds the clicked card and gives us li.

  2. main.js: getRecipeById(li.dataset.recipeId) is called with the card’s stored id.

  3. fetch-helpers.js: getRecipeById() calls fetch(...), checks response.ok, extracts JSON data, and returns a promise resolving to recipe.

  4. main.js: .then((recipe) => ...) receives the recipe updates the DOM with hideError() and renderRecipeDetails(recipe).

  5. dom-helpers.js: renderRecipeDetails() shows #recipe-details, clears old content, builds the new details elements, and appends them.

Scenario 3: A user submits the search form and matching recipes are listed

chevron-rightAnswerhashtag
  1. main.js: The form submit handler runs, calls event.preventDefault(), and reads searchTerm plus isQuick (.checked).

  2. main.js: await getRecipeBySearchTerm(searchTerm) is called with the search term.

  3. fetch-helpers.js: getRecipeBySearchTerm() calls fetch(...), checks response.ok, extracts JSON data, and returns a promise resolving to a { data, error } object.

  4. main.js: After await, if there is no error and results exist, it optionally filters recipes to <= 20 minutes when isQuick is true.

  5. main.js: It then calls hideError() and renderRecipes(recipes).

  6. dom-helpers.js: renderRecipes() clears old cards, updates the count, and appends the new search results.

Scenario 4: Search fails and an error message is shown

chevron-rightAnswerhashtag
  1. main.js: The submit handler calls await getRecipeBySearchTerm(searchTerm).

  2. fetch-helpers.js: The fetch fails (network failure or !response.ok), so the catch block returns { data: null, error }.

  3. main.js: The resolved value is { data: null, error }, so if (error) is true and renderError(...) is called. renderRecipes(...) is not called.

  4. dom-helpers.js: renderError() removes hidden and sets error text, so the error message appears.

  5. The message stays visible because there is no timeout-based hide. It is only cleared later when a success branch explicitly calls hideError().

Guided Reading Questions

Open each file and answer the questions.

index.html

  1. What does type="module" on the <script> tag enable?

  2. Find the fallback recipe card in the ul. What will happen to it once JavaScript loads successfully?

  3. Which elements start with class="hidden"? Why would we hide them by default?

  4. What data- attribute is on the fallback <li>? What value does it have?

chevron-rightAnswershashtag
  1. It enables ES modules in the browser, so we can use import / export in JavaScript files.

  2. It gets removed when renderRecipes runs, because recipesList.innerHTML = '' clears the list before new cards are appended.

  3. #recipe-details and #error-message start hidden so details and errors only appear when relevant user actions or failures occur.

  4. The fallback card has data-recipe-id="1".

src-solution/fetch-helpers.js

  1. What does getRecipes resolve to if the fetch succeeds? What does it resolve to if the fetch fails?

  2. What kind of errors does checking response.ok handle that .catch() does not handle on its own?

  3. The API returns an object like { recipes: [...], total: 50, ... }. In getRecipes, where is just the recipe array extracted, and what would break if we returned the full object instead?

  4. getRecipeById and getRecipes follow the same pattern. What is the one structural difference between them? Why doesn't getRecipeById need a second .then()?

  5. Compare and contrast getRecipeBySearchTerm with the other fetch helpers. What are the benefits/tradeoffs of using async/await + try/catch and returning { data, error }? Which style of handling promises do you prefer?

chevron-rightAnswershashtag
  1. It resolves to data.recipes (an array) on success, and resolves to null on failure.

  2. It handles HTTP failure responses (like 404/500) that do not reject fetch by default. .catch() alone only handles rejected Promises (network/throw errors).

  3. It is extracted in the second .then((data) => { return data.recipes; }). If we returned the full object, code expecting an array (like renderRecipes(recipes) and recipes.length / forEach) would break.

  4. getRecipes has an extra .then to extract data.recipes; getRecipeById does not because that endpoint already returns a single recipe object directly.

  5. async/await can 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!

src-solution/dom-helpers.js

  1. Why does renderRecipes clear #recipes-list before rendering?

  2. Why does renderRecipeDetails remove the hidden class?

  3. What is the difference between renderError and hideError?

  4. What does li.dataset.recipeId = recipe.id add to the DOM, what is that value used for later, and why store it on each card?

chevron-rightAnswershashtag
  1. To remove old/fallback content before rendering new results and avoid duplicate cards.

  2. The details section is hidden by default, so removing hidden makes the selected recipe details visible.

  3. renderError(msg) shows the error element and sets text. hideError() clears text and hides it.

  4. It adds a data-recipe-id attribute on each li. Later, the click handler reads li.dataset.recipeId to call getRecipeById(...) with the id of the clicked item. Storing it on each card keeps the card tied to its API ID for event-driven fetching.

src-solution/main.js

  1. What are the three actions that can trigger a fetch in this file?

  2. Where is event delegation used, and why?

  3. Where is the search form handled, and why is the handler async?

  4. Where does the quick filter (Under 20 Minutes) apply?

  5. In which branches is hideError() called?

chevron-rightAnswershashtag
  1. Initial page load (getRecipes), clicking a recipe card (getRecipeById), and submitting the search form (getRecipeBySearchTerm).

  2. On #recipes-list click. One parent listener handles clicks on dynamically rendered cards, including clicks on child elements via closest('li').

  3. In the submit handler for #search-form; it is async because it awaits getRecipeBySearchTerm(...).

  4. In the search success branch, after fetch resolves: if isQuick is true, results are filtered by total prep + cook time <= 20.

  5. In three success branches: after successful initial load, after successful recipe-details fetch, and after successful search before rendering results.

Building from Scratch

So, how could you build this application from scratch?

The process for creating an interactive and data-driven user interface typically follows this order:

  1. Create the HTML with id and class attributes so we can target elements. Leave empty containers for content generated with JavaScript/DOM manipulation.

  2. Create fetch helper functions and test with console logs.

  3. Create rendering helper functions. Data in -> DOM out.

  4. 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!

Feature 1: Fetch and Render Recipes

Step 0: Tour the HTML {.unlisted .unnumbered}

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-details section with class="hidden" (hidden by default, shown on click).

    • The #error-message paragraph with class="hidden".

    • The search form (#search-form) with searchTerm text input and isQuick checkbox.

  • styles.css - Take note of the class .hidden { display: none !important; }.

  • src/main.js - Starter file is empty.

Step 1: Create src/fetch-helpers.js - fetch a list of recipes {.unlisted .unnumbered}

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:

Key Details:

  • fetch() returns a Promise.

  • Check response.ok because 404/500 do not automatically reject fetch.

  • response.json() returns a Promise and must be returned.

  • We extract data.recipes from the API response object.

  • On failure, return null so callers can handle errors.

Step 2: Import and test in main.js {.unlisted .unnumbered}

Skills: named imports with .js extension, .then() on returned Promise

You should see an array of recipe objects in the console.

Step 3: Create src/dom-helpers.js - render the recipe list {.unlisted .unnumbered}

Skills: document.createElement, append, dataset, innerHTML = '', named exports

Step 4: Wire up rendering in main.js {.unlisted .unnumbered}

Skills: module imports, null checking

We've now completely implemented the first feature: fetching and rendering all recipes!

Step 6: Add Error Rendering {.unlisted .unnumbered}

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:

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:

Feature 2: Event Delegation to Render Recipe Details

Step 0: Tour the HTML {.unlisted .unnumbered}

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-details section with class="hidden" (hidden by default, shown on click).

    • The data-recipe-id attribute on the fallback list item

  • styles.css - Take note of the class .hidden { display: none !important; }.

Step 1: Add getRecipeById to fetch-helpers.js {.unlisted .unnumbered}

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

Step 2: Add the click handler with event delegation {.unlisted .unnumbered}

Skills: event delegation, closest(), dataset, second fetch + render

Step 3: Add renderRecipeDetails to dom-helpers.js {.unlisted .unnumbered}

Skills: showing hidden content, nested list rendering

This completes the second feature: clicking on a list item to fetch its details.

Step 0: Tour the HTML {.unlisted .unnumbered}

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) with searchTerm text input and isQuick checkbox.

Step 1: Add getRecipeBySearchTerm with async/await {.unlisted .unnumbered}

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:

Why this differs from the other fetch helpers:

  • It demonstrates the async/await + try/catch style.

  • It returns an object with { data, error } so callers can consistently inspect both success and failure fields (data and error).

Step 2: Add the search form handler in main.js {.unlisted .unnumbered}

Skills: form submit handling, .checked for checkboxes, async event handlers, conditional filtering

Concepts Checklist

By the end of this walkthrough, you have demonstrated:

Last updated