Get Cooking with Next.js: A Step-by-Step Guide to Crafting a Recipe Finder App (Part 2)
In Part 1 of this tutorial, our focus is on setting up the app, understanding how to combine parallel routes and intercepting routes, and learning how to fetch data on the server and on the client. And now, in Part 2, we will focus on creating the recipe collection section, the List
component, an infinite scrolling function, a scroll-to-top button, the recipe details page, and the Hero section.
If you missed the Part 1 of this tutorial you can visit this article, Get Cooking with Next.js: A Step-by-Step Guide to Crafting a Recipe Finder App (Part 1)
You can check the app by visiting this link, recipefinder-nextjs.vercel.app.
Recipe Collection Section
In this section, we display all the recipes data from TheMealDB API. The problem is the API doesn’t provide an endpoint to retrieve all recipes at once. So, to collect all the recipes, we will fetch recipes data from every area provided by the API.
components/recipeCollection.js
import useSWR from 'swr'
import {fetchWithAreas} from "../lib/fetcher";
import { HeadingSkeleton, ListSkeleton } from '../components/skeleton';
import List from './recipeList';
import { useEffect, useState,} from 'react';
const Recipes= () => {
const { data, isLoading } = useSWR('filter.php?a', fetchWithAreas)
const [recipeList, setRecipeList]=useState([])
useEffect(()=>{
if(!isLoading)
{
setRecipeList(data)
}
},[isLoading])
if(isLoading)
{
return (
<>
<HeadingSkeleton/>
<ListSkeleton totalSkeleton={12} />
</>
);
}
const totalRecipes= recipeList.length
return (
<div className='mx-6 sm:mx-8 flex flex-col gap-6'>
<div className='flex gap-2'>
<span className='font-jost font-semibold text-gray-800 text-base md:text-lg '>Recipe Collection</span>
</div>
{totalRecipes>0 &&
<List recipeList={recipeList} totalRecipes={totalRecipes}/>
}</div>
)
}
export default Recipes
- To retrieve recipes data based on the area, we need to fetch it from the
‘filter.php?a’
endpoint using theuseSWR
hook. - The endpoint will be the
key
of SWR, and thefetchWithAreas
function accepts thiskey
, returningdata
andisLoading
based on the status of the request.
lib/fetcher.js
const baseURL='https://www.themealdb.com/api/json/v1/1/'
export const fetcher = async(parameter) =>{
try{
const response = await fetch(`${baseURL}/${parameter}`)
const data = await response.json()
return data
}
catch (error){
throw error
}
}
export const fetchWithAreas = async(url) =>{
try{
return fetcher(`list.php?a=list`)
.then(areas => {
const promises = areas.meals.map(area => {
return fetcher(`${url}=${area.strArea}`)
.then(recipeListResponse => recipeListResponse.meals);
});
return Promise.all(promises);
})
.then(recipeLists => {
return recipeLists.flat();
});
}
catch (error){
throw error
}
}
- The
fetchWithAreas
function is an asynchronous function that fetches the areas list data from thelist.php?a=list
endpoint using thefetcher
function, then uses this data to fetch recipes based on the area. - After successfully fetching the areas list data, we map through the area list data inside the
meals
property and use thefetcher
function again to retrieve recipes data based on the area. - Here, we create a
promises
variable to store the new array returned by themap
function. - The
map
function iterates over each area in theareas.meals
array and fetches data for that specific area usingfetcher(url={area.strArea})
. The result of each fetching process is also an array,recipeListResponse.meals
. - So essentially, the
promises
array contains arrays, where each inner array corresponds to the fetched data for a specific area. - However, the
promises
array still contains promise objects that haven’t resolved yet. We need to usePromise.all(promises)
to execute all these promises in parallel. This ensures that all the fetch requests for different areas happen concurrently, improving performance.
The fetch() method is promise-based, it returns a Promise object that represents the response to the request, whether it is successful or not.
If you want to learn more about the
fetch()
method you can visit this article, “What is AJAX, XMLHttpRequest, fetch(), and Promise?”
Here is the difference between the
fetcher
and thefetchWithAreas functions
:In the
fetcher
function, we useasync
andawait
to fetch data. Theawait
keyword pauses execution until the promise resolves (or rejects).
Using
await
means we execute requests sequentially (one after the other). This means that the second promise won’t start until the first one completes. It’s less efficient if we have multiple promises that can be executed concurrently.
In the
fetchWithAreas
function, we useasync
andPromise.all
to fetch data. We usedPromise.all
overawait
, because we fetch data from multiple endpoints. WithPromise.all
we can execute the requests concurrently, we don’t need to execute the requests sequentially. This significantly improves performance by reducing the overall execution time.
Promise.all
waits for all of promises to resolve (or any to reject) before continuing.
If any promise rejects (encounters an error), the entire
Promise.all
operation rejects with the error from the first rejected promise. It’s important to handle errors appropriately when usingPromise.all
.Since our
fetcher
function already includes atry
/catch
block to handle errors during fetching, we don’t need to use the block anymore when fetching recipes data based on area.
- Once all promises are resolved, we get an array of arrays (each containing recipes for a specific area). That’s why we need to flatten the array into a single array of recipes data.
- But when the data is still loading (when
isLoading
is true), theHeadingSkeleton
andListSkeleton
components will be displayed to represent the process. You can pass a prop to determine how many list skeletons will be shown.
const { data, isLoading } = useSWR('filter.php?a', fetchWithAreas)
const [recipeList, setRecipeList]=useState([])
useEffect(()=>{
if(!isLoading)
{
setRecipeList(data)
}
},[isLoading])
if(isLoading)
{
return (
<>
<HeadingSkeleton/>
<ListSkeleton totalSkeleton={12} />
</>
);
}
const totalRecipes= recipeList.length
- When the recipes data become available, we update the
recipeList
state with the fetched recipe data. - After that, we calculate the total number of recipes based on the length of the
recipeList
array. - If the
totalRecipes
are greater than zero, we pass therecipeList
and thetotalRecipes
as props to theList
Component. Then theList
Component will be rendered.
After we’re done with the Recipes
component, we import the component into our main page.js
src/app/page.js
'use client'
import CategoriesList from "./components/categoriesList";
import Recipes from "./components/recipeCollection";
export default function Home() {
return (
<main className="w-full h-full flex flex-col gap-6 md:gap-8">
<CategoriesList/>
<Recipes/>
</main>
);
}
List Component
components/recipeList.js
This List
component is a reusable component that used to display all of the recipe data fetched from the API. The component receives two props, recipeList
(an array of recipe data) and totalRecipes
(the total count of recipes).
'use client'
import Link from 'next/link';
import Loading from '../../../public/loading.gif'
import Image from "next/image";
import React, { useState, useEffect, useRef } from 'react';
import { useInView } from "react-intersection-observer";
const itemsPerLoad=24
const List= ({recipeList, totalRecipes}) => {
const { ref, inView} = useInView();
const [recipes, setRecipes] = useState([])
const [hasMoreRecipes, setHasMoreRecipes] = useState(true);
const timeoutId=useRef(null)
useEffect(() => {
if(inView){
if(totalRecipes<itemsPerLoad) {
setRecipes(recipeList)
setHasMoreRecipes(false)
}
else{
if (timeoutId.current) {
// if the user continues to scroll
// a new timeout may be set before the previous one completes.
// this ensures that only the most recent timeout
// (the last user action) triggers the recipe loading
clearTimeout(timeoutId);
}
timeoutId.current = setTimeout(() => {
const startIndex = recipes.length;
const nextRecipes = recipeList.slice(startIndex, startIndex + itemsPerLoad);
setRecipes((prevRecipes) => [...prevRecipes, ...nextRecipes]);
}, 1000);
if(recipes.length===totalRecipes){
setHasMoreRecipes(false)
}
}
}
},[inView]);
return (
<>
<div className='grid xs:grid-cols-2 md:grid-cols-3 xl-1:grid-cols-4 content-center gap-x-4 md:gap-x-6 lg:gap-x-9 gap-y-6 md:gap-y-9 w-full justify-items-stretch'>
{recipes.map((recipe, key) => (
<div key={key}>
<Link href={`/recipes/${recipe.idMeal}`} key={key}>
<div className='w-full flex flex-col justify-center items-center gap-4 sm:gap-6 text-center h-fit cursor-pointer'>
<div className="relative h-44 md:h-52 w-full lg:h-56 border border-gray-50 bg-white rounded-md shadow">
<Image
src={recipe.strMealThumb}
alt={recipe.strMeal}
fill={true}
sizes="25vw"
style={{
objectFit: 'cover',
padding:'7px',}}
/>
</div>
<div className='h-1/4 font-medium font-catamaran text-sm md:text-base text-gray-700 capitalize'>{recipe.strMeal}</div>
</div>
</Link>
</div>
))}
</div>
<div className='pt-8 pb-3 flex flex-col gap-4'>
{hasMoreRecipes&&
(<div className="flex justify-center" ref={ref}>
<Image src={Loading} alt="loading" height={70} sizes={70} />
</div>)}
<p className='text-center text-zinc-500 font-jost font-medium'>
-------- Showing {recipes.length} Recipes -------
</p>
</div>
</>
)
}
export default List
Since we used the
List
component to display all of the recipe data, we need to implement infinite scrolling for this component. Showing all the data at once would be overwhelming, so it better to set an initial batch of recipes first then the rest of recipes will load dynamically as users scroll down.
Infinite Scrolling
This is how we make this infinite scrolling scenario happen:
- Use the
useInView
hook to detect when an element enters or leaves the viewport. - Extract the
ref
andinView
values from theuseInView
hook. - Define how much data will be loaded, set
itemsPerLoad
to 24. - Create
recipes
state to keep the list of recipes to be displayed. - Create a
hasMoreRecipes
state to track whether there are more recipes to load. - Create
timeoutId
ref to store the timeout ID for the delay time to load the next data.
With ref we can keep information that we don’t want to trigger new renders, unlike the states.
- Assign the
ref
to the DOM element we want to monitor. IfhasMoreRecipes
istrue
, the DOM element will be rendered.
{hasMoreRecipes&& (<div className="flex justify-center" ref={ref}>
<Image src={Loading} alt="loading" height={70} sizes={70} />
</div>)}
- When the DOM element enters the viewport, the
inView
status will betrue
, and will befalse
when it leaves. - The
useEffect
hook runs everytime theinView
changes. - If
totalRecipes
is less than theitemsPerLoad
, it sets the entirerecipeList
asrecipes
and disables further loading by setsetHasMoreRecipes(false)
. - Otherwise, we clear any existing timeout, then sets a new timeout to load the next batch of recipes after 1000 milliseconds.
timeoutId.current = setTimeout(() => {
const startIndex = recipes.length;
const nextRecipes = recipeList.slice(startIndex, startIndex + itemsPerLoad);
setRecipes((prevRecipes) => [...prevRecipes, ...nextRecipes]);
}, 1000);
if(recipes.length===totalRecipes){
setHasMoreRecipes(false)
}
- Since TheMealDB API doesn’t provide pagination, so all we can do is retrieve all of the data, then
slice()
it to get the next batch of recipes (nextRecipes
) starting fromstartIndex
. - Updates
recipes
with the next batch,setRecipes((prevRecipes) => […prevRecipes, …nextRecipes])
. We append the new recipes to the existing list by using the spread operator. - Next, we check if the total number of loaded recipes (
recipes.length
) is equal to the total number of recipes available (totalRecipes
). - If all recipes have been loaded, we disable further loading by calling
setHasMoreRecipes(false)
.
Recipe Details Page
recipes/[id]/page.js
'use client'
import BackButton from '@/app/components/backButton'
import { DetailSkeleton } from '@/app/components/skeleton'
import { fetcher } from '@/app/lib/fetcher'
import Image from "next/image";
import { useEffect, useState } from 'react'
import useSWR from 'swr'
const Details = ({params}) => {
const {id} = params
const {data, isLoading} = useSWR(`lookup.php?i=${id}`, fetcher)
const [ingredients, setIngredients] = useState([])
useEffect(() => {
if(document.body.style.overflow==='hidden')
{
//If this details page is rendered after users click the recipe
// from the modal component, then we need to set the overflow style
// of document.body to auto, allowing users to scroll the page.
document.body.style.overflow='auto'
}
}, []);
useEffect(()=>{
if(!isLoading)
{
const ingredientsValue=[]
for(let i = 1; i<=20;i++)
{
const ingredientName=details[`strIngredient${i}`]
const ingredientMeasure = details[`strMeasure${i}`];
if(ingredientName)
{
ingredientsValue.push([ingredientName, ingredientMeasure]);
}
}
setIngredients(ingredientsValue)
console.log(details)
}
}, [isLoading])
if (isLoading) {
return (
<div className="flex justify-center w-full">
<DetailSkeleton/>
</div>
)
}
const [details]=data.meals
return (
<>
<BackButton/>
<div className='w-full h-full px-6 md:px-8 flex justify-center items-center '>
<div className=' w-[95%] lg:w-full flex flex-col gap-8 justify-center items-center'>
<h4 className=' font-jost lg:w-fit text-center px-3 py-2 bg-white text-lg sm:text-xl md:text-2xl lg:text-3xl shadow rounded-xl capitalize text-red-700 font-bold'>{details.strMeal}</h4>
<div className='flex flex-col lg:flex-row justify-evenly items-center lg:items-start gap-8'>
<div className="relative h-86 w-full md:h-100 lg:w-120 lg:h-120 border border-gray-50 bg-white rounded-lg shadow">
<Image
src={details.strMealThumb}
alt={details.strMeal}
fill={true}
sizes="30vw"
style={{
objectFit: 'cover',
padding:'10px',}}
/>
</div>
<div className='flex flex-col gap-8 w-full lg:w-[90%]'>
<div className='w-full p-4 shadow bg-white rounded-lg'>
<h4 className=' font-catamaran font-medium text-zinc-500 text-lg md:text-xl lg:text-2xl border-b pb-2'>Ingredients:</h4>
<ul className='p-4 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2 text-gray-700 text-base lg:text-lg font-jost'>
{ingredients.map((ingredient, index)=>(
<li key={index} >
{ingredient[1]} {ingredient[0]}
</li>
))}
</ul>
</div>
<div className='w-full p-4 shadow bg-white rounded-lg'>
<h4 className=' font-catamaran font-medium text-zinc-500 text-lg md:text-xl lg:text-2xl border-b pb-2'>Instructions:</h4>
<ul className='p-4 flex flex-col gap-2 text-gray-700 text-base lg:text-lg font-jost'>
{details.strInstructions.split('.') // Split instructions by newline
.map((step, index) => {
// Remove leading digits and tabs
const normalizedStep = step.trim()
if (normalizedStep.trim()!== '') { // Check if the step value is not empty and not only whitespace characters
return (
<li key={index}>
{normalizedStep}
</li>
);
} else {
return null; // Return null if the step value is empty or only whitespace characters
}
})}
</ul>
</div>
</div>
</div>
</div>
</div>
</>
)}
export default Details
- By using a Dynamic Segment, we have passed
params
object inside theDetails
component. - Since the name of our folder is
[id]
, then the property name we need to extract from theparams
object is alsoid
. Ifparams
contains a property namedid
, its value will be assigned to theid
variable. - The
id
variable will be used as a parameter to fetch details recipe from the`lookup.php?i=${id}`
endpoint using theuseSWR
hook. - The endpoint will be the
key
of SWR, and thefetcher
function accepts thiskey
, returningdata
andisLoading
based on the status of the request. - If
data
is still loading (isLoading
is true), theDetailSkeleton
component will be displayed to represent the process. - When the data becomes available, we use destructuring assignment to extract the first element from the
data.meals
array and assign it to a variable nameddetails
const [details]=data.meals
When you log the
details
data in the console, you will see this is how the ingredients and measures properties look like:
- To retrieve the ingredients and measures values, we need to loop through them (up to 20) using the
for
loop.
const [ingredients, setIngredients] = useState([])
...
useEffect(()=>{
if(!isLoading)
{
const ingredientsValue=[]
for(let i = 1; i<=20;i++)
{
const ingredientName=details[`strIngredient${i}`]
const ingredientMeasure = details[`strMeasure${i}`];
if(ingredientName)
{
ingredientsValue.push([ingredientName, ingredientMeasure]);
}
}
setIngredients(ingredientsValue)
console.log(details)
}
}, [isLoading])
- Here, we dynamically extract the ingredient name from the
details
object. For example, wheni
is 1, it retrievesdetails[strIngredient1]
. We also extract its measurement using the same approach. - If the ingredient exists, it pushes an array containing the ingredient name and its measurement into the
ingredientsValue
array.
This is what it’s called a 2-dimensional array, the inner array contains two elements. The first element is the ingredients name, and the second element is its measurement.
- When the looping is done, we update the
ingredients
state with theingredientsValue
.
This is how we display the ingredients and its measures on the page:
<ul className='p-4 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2 text-gray-700 text-base lg:text-lg font-jost'>
{ingredients.map((ingredient, index)=>(
<li key={index} >
{ingredient[1]} {ingredient[0]}
</li>
))}
</ul>
And this is how we display the instructions on the page:
<ul className='p-4 flex flex-col gap-2 text-gray-700 text-base lg:text-lg font-jost'>
{details.strInstructions.split('.') // Split instructions by newline
.map((step, index) => {
//removes spaces, tabs, and newline characters
const normalizedStep = step.trim()
if (normalizedStep.trim()!== '') { // Check if the step value is not empty
//and not only whitespace characters
return (
<li key={index}>
{normalizedStep}
</li>
);
} else {
return null; // Return null if the step value is empty
// or only whitespace characters
}
})}
</ul>
The instruction value doesn’t have the same format. Some recipes use numbers, while others use ‘Step-n,’ and some do not. So, instead of using numbers or bullets to split the instructions, it’s better to split them by newline.
recipes/page.js
Based on the route pattern we have created for this recipe details page, the URL will be recipes/recipeParameter
, but when users manually change the URL to recipes
, Next.js will displays the not-found
page. So, to prevent this, we create a new page.js
file for the recipes route to ensuring that users return to the root ("/"
) route.
"use client"
import {useRouter} from 'next/navigation'
const RecipePage = () => {
const router = useRouter()
router.push('/')
}
export default RecipePage
- Inside the
RecipePage
, we used one of theuseRouter
hook methods,router.push('/')
. This method is used to navigate to the root URL when the component is rendered.
Scroll-To-Top Button
components/scrollToTop.js
The scroll-to-top button allows users to quickly return to the top of the page.
'use client'
import React, { useState, useEffect } from 'react';
import {ArrowUp} from '../../../public/icons/svg';
const ScrollToTopButton = () => {
const [showButton, setShowButton] = useState(false);
useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, []);
const handleScroll = () => {
if (window.scrollY > 1000) {
setShowButton(true);
} else {
setShowButton(false);
}
};
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
};
return (
showButton && (<button title='Back to the top'
onClick={scrollToTop}
className="fixed bottom-4 right-2 sm:right-4 z-50 bg-white shadow rounded-lg"> <ArrowUp className="stroke-red-600 w-7 h-7 sm:w-9 sm:h-9"/>
</button>)
);
};
export default ScrollToTopButton;
However, there is a condition that determines when the button will appear.
- First, we need to define a boolean state that determines whether to show the scroll-to-top button.
const [showButton, setShowButton] = useState(false);
- Then we need to create a function to check the scroll position.
const handleScroll = () => {
if (window.scrollY > 1000) {
setShowButton(true);
} else {
setShowButton(false);
}
};
- If the scroll position exceeds 1000 pixels, set
showButton
to true; otherwise, set it to false.
The
scrollY
property returns the pixels a document has scrolled from the upper left corner of the window.
- When the
showButton
state value becomes true, the scroll-to-top button will be displayed. We add an event handler,scrollToTop
to the button, allowing smooth scrolling back to the top of the page.
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
};
return (
showButton && (<button title='Back to the top'
onClick={scrollToTop}
className="fixed bottom-4 right-2 sm:right-4 z-50 bg-white shadow rounded-lg"> <ArrowUp className="stroke-red-600 w-7 h-7 sm:w-9 sm:h-9"/>
</button>)
);
};
- And last, we need to add an event listener to the window for the scroll event inside
useEffect
hook.
useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => {
//Cleanup function
window.removeEventListener('scroll', handleScroll);
};
}, []);
The cleanup function is executed when the component unmounts. It removes the previously added event listener. This ensures that no memory leaks occur and that the event listener is properly cleaned up when the component is no longer needed.
The ScrollToTopButton
component will not show up in Modal
component, because we set the modal’s document.body.style.overflow
to ’hidden’
. We can only see the button on our main page, category recipes page, search page, and recipe details page.
Alright, we’re done with the ScrollToTopButton
component, now we need to import it into our root layout, so we can use the scroll-to-top button when the scroll position exceeds 1000 pixels.
src/app/layout.js
...
import ScrollToTopButton from "./components/scrollToTop";
export default function RootLayout({ children, modal
}) {
return (
<html lang="...">
<body className="...">
...
{modal}
{children}
<ScrollToTopButton/>
...
</body>
</html>
);
}
Hero Section
components/hero.js
import {useState, useEffect} from "react";
import {Salad } from "../../../public/icons/svg";
import { HeroSkeleton } from "./skeleton";
const Hero= () => {
const [showSkeleton, setShowSkeleton] = useState(true);
useEffect(() => {
setTimeout(() => {
setShowSkeleton(false);
}, 75);
}, []);
return (
<>
{showSkeleton ?
(<HeroSkeleton/>):(
<div className="w-full px-6 sm:px-8 ">
<div className="relative w-full h-40 xs:h-48 xs-1:h-36 sm-2:h-40 md:h-44 md-2:h-48 lg:h-56 xl:h-60 bg-yellow-200/90 rounded-2xl shadow p-4 flex flex-col text-center xs-1:text-left xs-1:flex-row xs-1:justify-evenly items-center">
<div className='flex flex-col gap-1 xs-1:gap-2 xl:gap-4'>
<h1 className='font-jost text-xxs xs:text-sm xs-2:text-base sm-2:text-lg md:text-xl lg:text-2xl text-amber-800'>Let’s Embark on Your Culinary Journey</h1>
<span className='font-jost font-bold text-red-600 text-base xs:text-lg xs-1:text-xl sm-1:text-2xl sm-2:text-3xl md:text-4xl md:text-4xl-1 lg:text-5xl xl:text-5xl-1'>Discover Your Favorite <br/> Recipes Here</span>
</div>
<Salad className="absolute bottom-0 xs-1:relative w-20 h-20 xs:w-24 xs:h-24 sm-1:h-28 sm-1:w-28 sm-2:h-32 sm-2:w-32 md:h-40 md:w-40 lg:h-48 lg:w-48 xl:h-60 xl:w-60 sm:mb-2"/>
</div>
</div>
)} </>
)
}
export default Hero
This Hero
component is a functional component that represents the hero section of our app. Since no data fetching occurs here, we display the HeroSkeleton
for a short time.
Well, I think using the skeleton component even there is no data fetching process happened here can give a better user experience. Essentially, the other sections, categories and recipe collection sections, will require time for data fetching process, and in the process, we will display skeletons to represent it. This is to ensure the consistency of the UI when users load the main page.
After we’re done with the Hero
component, we import the component into our main page.js
src/app/page.js
'use client'
import CategoriesList from "./components/categoriesList";
import Recipes from "./components/recipeCollection";
import Hero from "./components/hero";
export default function Home() {
return (
<main className="w-full h-full flex flex-col gap-6 md:gap-8">
<Hero/>
<CategoriesList/>
<Recipes/>
</main>
);
}
Finally, we have finished this long journey tutorial!
I know this tutorial is not perfect, and there are still many things to explore about Next.js. You might even get bored along the way, but I really hope this tutorial can be helpful to you.
You can check the full code of this RecipeFinder app in this GitHub repository: ucilasmana/RecipeFinder
Have a good day!