Recipe Idea Generator with React 19
Build an AI-powered recipe app using React 19, Context API, and Gemini.
Introduction
30 Second Summary
You open the fridge, see a random collection of ingredients, and draw a complete blank on what to cook. It happens to everyone.
In this project, you will build a Recipe Idea Generator that takes whatever ingredients you type in and calls Google's Gemini AI to suggest recipes you can actually make.
What You'll Build
Type "chicken, rice, garlic" into your app, hit a button, and watch three recipe cards appear with AI-generated suggestions you can save to a favorites list.
By the end of this project, you'll have:
- A working recipe generator where you type ingredients and get AI-powered suggestions back in seconds from Gemini.
- A favorites system powered by Context API that lets you save recipes with a heart button and browse them on a separate view.
- Automatic loading and error states handled by React 19's useActionState hook. No manual boolean tracking needed.
- Secret Mission: Add a dark/light theme toggle powered by a second Context provider.
Are there any prerequisites?
This project assumes you already have a Vite + React project open in VS Code with Node.js installed. No prior experience with Tailwind CSS is needed.
You should also be comfortable with JavaScript fundamentals like functions, arrays, and objects.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Install the Gemini SDK and Get Your API Key
Install Dependencies and Get Your API Key
Your Recipe Idea Generator needs to talk to Google's Gemini AI to get recipe suggestions. This requires two things: the official SDK to make API calls, and a free API key to authenticate your requests.
In this step, you'll install the SDK, grab your key from Google AI Studio, and set up a clean starting point for the app.
In this step, get ready to:
- Install the @google/genai SDK and Tailwind CSS.
- Create a free Gemini API key and store it securely.
- Configure Tailwind CSS and clean the default Vite scaffold.
Install the Google GenAI SDK and Tailwind CSS
Your app needs two packages: @google/genai to talk to Gemini, and tailwindcss with its Vite plugin to style your components using utility classes.
- Install all three packages by running this command in your VS Code terminal:
npm install @google/genai tailwindcss @tailwindcss/vite
What does this command do?
- @google/genai is Google's official SDK for calling Gemini models from JavaScript.
- tailwindcss is the core Tailwind CSS framework that generates styles from utility classes you add to your HTML/JSX.
- @tailwindcss/vite is the official Vite plugin that integrates Tailwind into your dev server and build process.
- Confirm the install succeeded by checking your terminal output. You should see a line showing packages were added (e.g. added 3 packages).
Seeing an error during install?
- Make sure your terminal is open in the project root folder (the one containing package.json).
- Check your internet connection and try running the command again.
- help me fix an npm install error
Get a free Gemini API key
To call the Gemini API, you need an API key that identifies your requests. Google offers a free tier with no charges for input and output tokens, so this costs nothing.
- Open Google AI Studio in your browser by going to aistudio.google.com.
- Sign in with your Google account.
- Click Get API Key in the left sidebar.
- Copy your API key from the API keys page.
What if I already have a key?
Google AI Studio automatically creates a default project and API key for new users after accepting the Terms of Service. If you already have a key from a previous project, you can reuse it here.
Now store your API key in a file that Vite can read but that stays out of version control.
- Create a new file called .env in the root of your project (the same folder as package.json).
- Paste the following into .env, replacing your-api-key-here with the key you copied:
VITE_GEMINI_API_KEY=your-api-key-here
Why the VITE_ prefix?
Vite only exposes environment variables prefixed with VITE_ to your browser code via import.meta.env. Without the prefix, your app would not be able to read the key at runtime.
- Open your .gitignore file (in the project root).
- Add .env on a new line at the bottom of the file.
- Save .gitignore.
Why add .env to .gitignore?
The default Vite .gitignore does not include bare .env files. Adding it prevents your API key from being committed to version control if you ever push this project to GitHub.
- Confirm your .env file exists by running:
ls -a
- You should see .env in the list alongside .gitignore, package.json, and your other project files.
Configure Tailwind CSS and clean the scaffold
Tailwind needs to be registered as a Vite plugin so it can scan your components for class names. You also need to replace the default Vite demo content with a clean starting point for the recipe app.
- Open vite.config.js in the project root and replace everything with the following code:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// Register both the React and Tailwind CSS plugins
export default defineConfig({
plugins: [react(), tailwindcss()],
})
What does this code do?
- import tailwindcss from '@tailwindcss/vite' loads the Tailwind Vite plugin you just installed.
- Adding tailwindcss() to the plugins array tells Vite to scan your JSX for Tailwind class names and generate the corresponding CSS automatically.
- Save vite.config.js.
- Open src/index.css and replace all of its contents with this single line:
@import "tailwindcss";
What is Tailwind CSS?
Tailwind CSS is a utility-first CSS framework. Instead of writing custom CSS rules in a separate file, you add pre-built classes directly to your HTML/JSX elements.
For example, className="text-3xl font-bold" makes text large and bold. Tailwind scans your files, finds the classes you used, and generates only the CSS you need.
- Save src/index.css.
- Delete src/App.css entirely (right-click it in the VS Code sidebar and select Delete). You will not need it because Tailwind handles all styling through class names.
- Open src/App.jsx and replace everything with the following code:
export default function App() {
return (
<div className="min-h-screen bg-gray-50 p-8">
<h1 className="text-3xl font-bold text-gray-900">
Recipe Idea Generator
</h1>
</div>
)
}
What do these Tailwind classes do?
- min-h-screen makes the container at least as tall as the browser window.
- bg-gray-50 sets a light gray background.
- p-8 adds padding on all sides (2rem).
- text-3xl font-bold text-gray-900 makes the heading large, bold, and near-black.
- Save src/App.jsx.
Before you run the dev server, what do you expect to see in the browser? The heading should look different from the default Vite scaffold now.
- Start the dev server by running:
npm run dev
Open http://localhost:5173 in your browser. You should see a light gray background filling the page with a large, bold "Recipe Idea Generator" heading in near-black text.
Don't see the styled heading?
- Make sure you saved all three files: vite.config.js, src/index.css, and src/App.jsx.
- If the background is plain white, Tailwind is not loading. Double-check that tailwindcss() is in your vite.config.js plugins array and that src/index.css contains @import "tailwindcss";.
- Check your terminal for error messages. If you see Cannot find module '@tailwindcss/vite', the install may not have completed. Run npm install tailwindcss @tailwindcss/vite again.
- help me troubleshoot Tailwind CSS not loading in my Vite project
✔️ Awesome, I've got everything!
Great. Make sure you have saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is what vite.config.js should look like:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
})
Here is src/index.css:
@import "tailwindcss";
Here is src/App.jsx:
export default function App() {
return (
<div className="min-h-screen bg-gray-50 p-8">
<h1 className="text-3xl font-bold text-gray-900">
Recipe Idea Generator
</h1>
</div>
)
}
And src/App.css should be deleted. Your .env file should contain:
VITE_GEMINI_API_KEY=your-api-key-here
Your environment is ready and the app is running with a clean slate. Next up, you'll build the form and recipe card components that make up the UI.
Build the Form and Recipe Card Components
Your development environment is ready and the Gemini SDK is installed. Now it's time to build the visual pieces of your app.
Before connecting to the AI, you need UI components that can display recipe data. Building with hardcoded data first lets you verify your component structure works before adding complexity.
In this step, get ready to:
- Create an IngredientForm component with a textarea and submit button.
- Create a RecipeCard component that displays a recipe's title, description, and ingredients.
- Wire both components into App with hardcoded recipe data.
Create the IngredientForm component
Your app needs a form where users type their available ingredients. In React, you build UI pieces as reusable components. Each component lives in its own file and handles one job.
- Create a components folder inside src by running this command in a new terminal:
mkdir src/components
Open a second terminal
Your dev server is using your first terminal. Open a new terminal panel in VS Code with Ctrl+` (backtick) on both macOS and Windows to run commands alongside it.
- Create a new file called IngredientForm.jsx inside src/components/ and paste the following code:
export default function IngredientForm({ onSubmit }) {
function handleSubmit(e) {
e.preventDefault()
const ingredients = e.target.elements.ingredients.value
onSubmit(ingredients)
}
return (
<form onSubmit={handleSubmit} className="ingredient-form">
<label htmlFor="ingredients">What ingredients do you have?</label>
<textarea
id="ingredients"
name="ingredients"
placeholder="e.g. chicken, rice, garlic, soy sauce"
rows={3}
/>
<button type="submit">Get Recipe Ideas</button>
</form>
)
}
What does this code do?
- The component accepts an onSubmit prop. Props are how parent components pass data and functions down to children.
- e.preventDefault() stops the browser from refreshing the page when the form is submitted.
- e.target.elements.ingredients.value grabs whatever text the user typed in the textarea by its name attribute.
- The JSX returned from the function describes the form's structure. It looks like HTML but it's actually JavaScript.
- Save IngredientForm.jsx.
- Confirm the file exists by running:
ls src/components
You should see IngredientForm.jsx listed in the output.
Don't see the file?
Make sure you created the file inside src/components/ and not directly inside src/. Check that the filename matches exactly, including the capital letters.
Help me find where my component file went.
Create the RecipeCard component
Each recipe suggestion from the AI will arrive as an object with a title, description, and list of ingredients. You need a component that knows how to display one recipe.
- Create a new file called RecipeCard.jsx inside src/components/ and paste the following code:
export default function RecipeCard({ recipe }) {
return (
<div className="recipe-card">
<h3>{recipe.title}</h3>
<p className="recipe-description">{recipe.description}</p>
<ul className="recipe-ingredients">
{recipe.ingredients.map((ingredient, index) => (
<li key={index}>{ingredient}</li>
))}
</ul>
</div>
)
}
What does this code do?
- The component receives a recipe prop that contains title, description, and an ingredients array.
- recipe.ingredients.map() loops over the array and renders each ingredient as a list item. This is how you render lists in React.
- The key={index} prop helps React track which items change when the list updates.
- Save RecipeCard.jsx.
- Confirm both component files exist by running:
ls src/components
You should see both IngredientForm.jsx and RecipeCard.jsx in the output.
Only see one file?
Double-check you saved RecipeCard.jsx inside the same src/components/ folder as IngredientForm.jsx. Both files must be in the same directory.
Help me find my missing component file.
Wire both components together in App
Your components exist but nothing uses them yet. You need to import them into App.jsx and add state so the form can trigger recipe cards to appear.
- Open src/App.jsx in the editor.
- Replace the entire file contents with the following code:
import { useState } from 'react'
import IngredientForm from './components/IngredientForm.jsx'
import RecipeCard from './components/RecipeCard.jsx'
import './App.css'
export default function App() {
// Hold the list of recipes in state
const [recipes, setRecipes] = useState([])
// When the form submits, populate recipes with hardcoded data
function handleSubmit(ingredients) {
setRecipes([
{ id: 1, title: 'Garlic Chicken Rice', description: 'A simple one-pot meal.', ingredients: ['chicken', 'rice', 'garlic'] },
{ id: 2, title: 'Chicken Fried Rice', description: 'Classic fried rice dish.', ingredients: ['chicken', 'rice', 'soy sauce'] },
{ id: 3, title: 'Garlic Butter Chicken', description: 'Rich and savory chicken.', ingredients: ['chicken', 'garlic', 'butter'] },
])
}
return (
<div className="app">
<h1>Recipe Idea Generator</h1>
<IngredientForm onSubmit={handleSubmit} />
{recipes.length > 0 && (
<div className="recipe-grid">
{recipes.map(recipe => (
<RecipeCard key={recipe.id} recipe={recipe} />
))}
</div>
)}
</div>
)
}
What does this code do?
- useState([]) creates a recipes variable that starts as an empty array. When you call setRecipes, React re-renders the component with the new data.
- handleSubmit is passed to IngredientForm as the onSubmit prop. When the user submits the form, this function fires and fills state with three hardcoded recipes.
- recipes.length > 0 && is conditional rendering. The recipe cards only appear after the form has been submitted.
- recipes.map() loops over the array and creates one RecipeCard per recipe. The key prop uses each recipe's unique id.
- Save App.jsx.
Before you check the browser, what do you expect to see when you type some ingredients and click the submit button?
- Switch to your browser at localhost:5173. The page should auto-refresh.
- Type anything into the textarea (e.g. chicken, rice, garlic).
- Click Get Recipe Ideas.
Three recipe cards should appear below the form showing "Garlic Chicken Rice", "Chicken Fried Rice", and "Garlic Butter Chicken" with their descriptions and ingredient lists.
Don't see recipe cards after clicking submit?
- Check your terminal for errors. A common mistake is a typo in the import paths. Make sure ./components/IngredientForm.jsx and ./components/RecipeCard.jsx match your actual filenames exactly.
- If the page is blank, open your browser's DevTools console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows) and look for error messages.
- If the form shows but cards don't appear, verify your handleSubmit function calls setRecipes with the array of recipe objects.
Help me debug why my recipe cards aren't showing up.
✔️ Awesome, I've got everything!
Great. Make sure you've saved all three files: IngredientForm.jsx, RecipeCard.jsx, and App.jsx.
ⓧ I'd like to double check the full code
Here's src/components/IngredientForm.jsx:
export default function IngredientForm({ onSubmit }) {
function handleSubmit(e) {
e.preventDefault()
const ingredients = e.target.elements.ingredients.value
onSubmit(ingredients)
}
return (
<form onSubmit={handleSubmit} className="ingredient-form">
<label htmlFor="ingredients">What ingredients do you have?</label>
<textarea
id="ingredients"
name="ingredients"
placeholder="e.g. chicken, rice, garlic, soy sauce"
rows={3}
/>
<button type="submit">Get Recipe Ideas</button>
</form>
)
}
Here's src/components/RecipeCard.jsx:
export default function RecipeCard({ recipe }) {
return (
<div className="recipe-card">
<h3>{recipe.title}</h3>
<p className="recipe-description">{recipe.description}</p>
<ul className="recipe-ingredients">
{recipe.ingredients.map((ingredient, index) => (
<li key={index}>{ingredient}</li>
))}
</ul>
</div>
)
}
Here's src/App.jsx:
import { useState } from 'react'
import IngredientForm from './components/IngredientForm.jsx'
import RecipeCard from './components/RecipeCard.jsx'
import './App.css'
export default function App() {
const [recipes, setRecipes] = useState([])
function handleSubmit(ingredients) {
setRecipes([
{ id: 1, title: 'Garlic Chicken Rice', description: 'A simple one-pot meal.', ingredients: ['chicken', 'rice', 'garlic'] },
{ id: 2, title: 'Chicken Fried Rice', description: 'Classic fried rice dish.', ingredients: ['chicken', 'rice', 'soy sauce'] },
{ id: 3, title: 'Garlic Butter Chicken', description: 'Rich and savory chicken.', ingredients: ['chicken', 'garlic', 'butter'] },
])
}
return (
<div className="app">
<h1>Recipe Idea Generator</h1>
<IngredientForm onSubmit={handleSubmit} />
{recipes.length > 0 && (
<div className="recipe-grid">
{recipes.map(recipe => (
<RecipeCard key={recipe.id} recipe={recipe} />
))}
</div>
)}
</div>
)
}
Your UI skeleton is working. You have a form that triggers state updates, and components that render recipe data from that state. Next up, you'll replace those hardcoded recipes with real AI-generated suggestions from Gemini.
Connect to Gemini AI with a Custom Hook
Your app works, but those hardcoded recipes are useless to a real user. The whole point is to generate suggestions tailored to whatever ingredients someone types in.
By extracting the API logic into a custom hook, you keep your components clean and focused on rendering. The hook becomes a reusable data-fetching module that any component could call.
In this step, get ready to:
- Create a custom hook that calls the Gemini API and parses the JSON response.
- Update App to use real AI-generated recipes instead of hardcoded data.
- Test the app with real ingredients and observe the AI response.
Create the useGemini custom hook
A custom hook is a JavaScript function whose name starts with use that encapsulates reusable logic. You'll put all the Gemini API communication here so your components never touch networking code directly.
- Create a new folder called src/hooks inside your src directory.
- Inside that folder, create a new file called useGemini.js.
- Add the following code to src/hooks/useGemini.js:
import { GoogleGenAI } from '@google/genai'
// Initialize the Gemini client with your API key
const ai = new GoogleGenAI({ apiKey: import.meta.env.VITE_GEMINI_API_KEY })
// Call Gemini and parse the recipe suggestions
export async function generateRecipes(ingredients) {
const response = await ai.models.generateContent({
model: 'gemini-3.6-flash',
contents: `Given these ingredients: ${ingredients}, suggest 3 simple recipes. Return a JSON array where each object has: "id" (unique number), "title" (string), "description" (brief 1-2 sentence description), and "ingredients" (array of strings needed). Return ONLY valid JSON, no markdown or extra text.`,
})
// Extract the text and find the JSON array
const text = response.text
const jsonMatch = text.match(/\[[\s\S]*\]/)
if (!jsonMatch) {
throw new Error('Failed to parse recipes from AI response')
}
return JSON.parse(jsonMatch[0])
}
What does this code do?
- GoogleGenAI is the official SDK class that handles authentication and API calls to Gemini.
- import.meta.env.VITE_GEMINI_API_KEY reads your API key from the .env file. Vite exposes any variable prefixed with VITE_ to your client code.
- The prompt asks Gemini to return exactly 3 recipes as a JSON array with a specific shape (id, title, description, ingredients).
- The regex /\[[\s\S]*\]/ extracts the JSON array from Gemini's response, even if the AI adds extra text around it.
- Save src/hooks/useGemini.js.
- Confirm the file appears in VS Code's Explorer sidebar under src/hooks/.
Don't see the hooks folder?
- Make sure you created the hooks folder inside src, not at the project root.
- Check that the filename is exactly useGemini.js (capital G, .js extension).
Why isn't my useGemini hook file showing up in the right place?
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
import { GoogleGenAI } from '@google/genai'
const ai = new GoogleGenAI({ apiKey: import.meta.env.VITE_GEMINI_API_KEY })
export async function generateRecipes(ingredients) {
const response = await ai.models.generateContent({
model: 'gemini-3.6-flash',
contents: `Given these ingredients: ${ingredients}, suggest 3 simple recipes. Return a JSON array where each object has: "id" (unique number), "title" (string), "description" (brief 1-2 sentence description), and "ingredients" (array of strings needed). Return ONLY valid JSON, no markdown or extra text.`,
})
const text = response.text
const jsonMatch = text.match(/\[[\s\S]*\]/)
if (!jsonMatch) {
throw new Error('Failed to parse recipes from AI response')
}
return JSON.parse(jsonMatch[0])
}
Use the hook in App
Now you need to swap out the hardcoded recipes for a real API call. This means importing your new hook and making handleSubmit asynchronous.
- In src/App.jsx, find the import block at the top of the file.
- Add this new import line below the RecipeCard import:
import { generateRecipes } from './hooks/useGemini.js'
- Now find the handleSubmit function. It currently looks like this:
function handleSubmit(ingredients) {
setRecipes([
{ id: 1, title: 'Garlic Chicken Rice', description: 'A simple one-pot meal.', ingredients: ['chicken', 'rice', 'garlic'] },
{ id: 2, title: 'Chicken Fried Rice', description: 'Classic fried rice dish.', ingredients: ['chicken', 'rice', 'soy sauce'] },
{ id: 3, title: 'Garlic Butter Chicken', description: 'Rich and savory chicken.', ingredients: ['chicken', 'garlic', 'butter'] },
])
}
- Replace the entire handleSubmit function with this async version:
async function handleSubmit(ingredients) {
const result = await generateRecipes(ingredients)
setRecipes(result)
}
What changed?
- async makes the function capable of using await, which pauses until the Gemini API responds.
- generateRecipes(ingredients) calls your custom hook, which sends the ingredients to Gemini and returns parsed recipe objects.
- setRecipes(result) updates state with the AI-generated recipes, triggering React to re-render the cards.
- Save src/App.jsx.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before testing.
ⓧ I'd like to double check the full code
import { useState } from 'react'
import IngredientForm from './components/IngredientForm.jsx'
import RecipeCard from './components/RecipeCard.jsx'
import { generateRecipes } from './hooks/useGemini.js'
import './App.css'
export default function App() {
const [recipes, setRecipes] = useState([])
async function handleSubmit(ingredients) {
const result = await generateRecipes(ingredients)
setRecipes(result)
}
return (
<div className="app">
<h1>Recipe Idea Generator</h1>
<IngredientForm onSubmit={handleSubmit} />
{recipes.length > 0 && (
<div className="recipe-grid">
{recipes.map(recipe => (
<RecipeCard key={recipe.id} recipe={recipe} />
))}
</div>
)}
</div>
)
}
Test with real ingredients
Both files are saved and your dev server is still running. Time to see if the AI responds.
- Switch back to your browser at localhost:5173.
- Type chicken, rice, garlic in the textarea.
Before you click the button, what do you think will happen? Will the recipes appear instantly, or will there be a delay?
- Click Get Recipe Ideas.
After 2-3 seconds, three recipe cards appear with AI-generated suggestions based on your ingredients. The recipes are different every time you submit.
Notice anything off about the experience?
During those 2-3 seconds, the UI completely froze. No loading spinner, no "Generating..." text, no feedback at all.
If the API fails (wrong key, network issue), you get nothing. No error message appears. You'll fix both of these problems in Step 5 with React 19's useActionState hook.
🙋♀️ Not seeing any recipes appear?
- Open your browser's DevTools console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows). Look for error messages there.
- If you see a 400 or 403 error, double check that your .env file has VITE_GEMINI_API_KEY= followed by your actual key with no spaces or quotes around the value.
- If you changed .env while the dev server was running, you need to restart it. Press Ctrl+C in the terminal, then run npm run dev again. Vite only reads .env at startup.
Help me debug why my Gemini API call isn't returning recipes in my React app.
Your app now generates real recipe suggestions from Gemini AI. Next up, you'll add a favorites system so users can save the recipes they like using React's Context API.
Share Favorites State with Context API
Your recipe cards work, but right now there's no way to save a recipe you like. You want a heart button on each card that saves it to a favorites list.
The challenge is that the favorites list needs to be accessible from two different places: the RecipeCard component (to toggle the heart) and a separate Favorites view (to display saved recipes). Passing this state through every layer of props is called "prop drilling" and gets messy fast. The Context API solves this by letting any component access shared state directly.
In this step, get ready to:
- Create a FavoritesContext with a provider and custom hook.
- Add a heart toggle button to each recipe card.
- Build a Favorites view with navigation that switches between views.
Create the FavoritesContext
A Context in React is a way to share data across your component tree without passing props manually at every level. You'll create a provider that holds the favorites state and a custom hook that gives any component easy access to it.
- Create a new folder called src/context inside your project.
- Create a new file called FavoritesContext.jsx inside that folder.
- Add the FavoritesProvider component by pasting this code:
import { createContext, useContext, useState } from 'react'
// Create a Context object to hold favorites data
const FavoritesContext = createContext(null)
export function FavoritesProvider({ children }) {
// State array to store all favorited recipes
const [favorites, setFavorites] = useState([])
// Add a recipe object to the favorites array
function addFavorite(recipe) {
setFavorites(prev => [...prev, recipe])
}
// Remove a recipe by filtering out its id
function removeFavorite(recipeId) {
setFavorites(prev => prev.filter(r => r.id !== recipeId))
}
// Check if a recipe id exists in favorites
function isFavorite(recipeId) {
return favorites.some(r => r.id === recipeId)
}
// Make all values available to any child component
return (
<FavoritesContext.Provider value={{ favorites, addFavorite, removeFavorite, isFavorite }}>
{children}
</FavoritesContext.Provider>
)
}
What does this code do?
createContext(null) creates a shared data container. The FavoritesProvider component wraps your app and holds the favorites array in state.
It exposes three helper functions: addFavorite appends a recipe, removeFavorite filters one out by ID, and isFavorite checks whether a given ID is already saved.
- Now add the useFavorites custom hook below the FavoritesProvider function in the same file:
// Custom hook so components can access favorites easily
export function useFavorites() {
const context = useContext(FavoritesContext)
if (!context) {
throw new Error('useFavorites must be used within a FavoritesProvider')
}
return context
}
Why a custom hook?
Instead of making every component import both useContext and FavoritesContext, the useFavorites hook wraps that boilerplate into a single import. It also throws a helpful error if a component tries to use it without a provider above it in the tree.
- Save src/context/FavoritesContext.jsx.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before continuing.
ⓧ I'd like to double check the full code
Your file should have two sections that you built above, in this order:
- The imports, FavoritesContext creation, and the full FavoritesProvider function with addFavorite, removeFavorite, isFavorite, and the Provider return.
- The useFavorites custom hook function at the bottom.
Scroll up and confirm both chunks are present in order.
Wire the provider and add the favorite toggle
The context file exists but nothing uses it yet. You need to wrap your app in the provider so every component can access favorites, then add the heart button to RecipeCard.
- Open src/main.jsx and replace its entire contents with this code:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
// Import the provider to share favorites across the app
import { FavoritesProvider } from './context/FavoritesContext.jsx'
import './index.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
{/* Wrap App so all child components can access favorites */}
<FavoritesProvider>
<App />
</FavoritesProvider>
</StrictMode>
)
What does wrapping in a provider do?
By placing <FavoritesProvider> around <App />, every component inside App (and its children) can call useFavorites() to read or modify the favorites array. Without this wrapper, the context would return null and throw an error.
- Save src/main.jsx.
- Refresh your browser at localhost:5173. The app loads the same as before, which confirms your Provider is wired correctly without breaking anything.
App crashes or blank screen?
Check that the import path in main.jsx matches your file exactly: './context/FavoritesContext.jsx'. Make sure FavoritesProvider is a named export (not default) in your context file.
help me debug my FavoritesProvider crash
Now update RecipeCard to use the context and render a heart button.
- Open src/components/RecipeCard.jsx and replace its contents with the following logic section:
import { useFavorites } from '../context/FavoritesContext.jsx'
export default function RecipeCard({ recipe }) {
// Access favorite functions from context
const { addFavorite, removeFavorite, isFavorite } = useFavorites()
const favorited = isFavorite(recipe.id)
// Toggle recipe in/out of favorites on click
function handleToggleFavorite() {
if (favorited) {
removeFavorite(recipe.id)
} else {
addFavorite(recipe)
}
}
- Directly below the handleToggleFavorite function, add the return statement with the heart button:
return (
<div className="recipe-card">
<div className="recipe-header">
<h3>{recipe.title}</h3>
{/* Filled heart when favorited, empty when not */}
<button onClick={handleToggleFavorite} className={`favorite-btn ${favorited ? 'favorited' : ''}`} aria-label={favorited ? 'Remove from favorites' : 'Add to favorites'}>
{favorited ? '\u2665' : '\u2661'}
</button>
</div>
<p className="recipe-description">{recipe.description}</p>
<ul className="recipe-ingredients">
{recipe.ingredients.map((ingredient, index) => (
<li key={index}>{ingredient}</li>
))}
</ul>
</div>
)
}
What changed in RecipeCard?
The component now calls useFavorites() to get the add, remove, and check functions from context. The handleToggleFavorite function decides whether to add or remove based on the current state.
The heart button renders \u2665 (filled heart) when favorited and \u2661 (empty heart) when not. The aria-label changes dynamically for accessibility.
- Save src/components/RecipeCard.jsx.
- Return to your browser, type some ingredients, and click Get Recipe Ideas.
- Each recipe card now shows a heart icon in the top-right corner. Click a heart to fill it in. Click it again to empty it.
Hearts not appearing?
Make sure your RecipeCard.jsx import path uses '../context/FavoritesContext.jsx' (two dots to go up one directory). Verify you have the recipe-header div wrapping both the h3 and the button.
help me fix my heart toggle button
✔️ Awesome, I've got everything!
Great. Confirm you've saved RecipeCard.jsx and the hearts toggle on click.
ⓧ I'd like to double check the full code
Your src/components/RecipeCard.jsx should contain both chunks you added above, in order:
- The import, useFavorites() destructuring, favorited check, and the handleToggleFavorite function.
- The return statement with recipe-header div, heart button, description, and ingredients list.
Scroll up and confirm both chunks are present in order.
Add the Favorites view with navigation
The heart toggle works, but saved favorites vanish once you generate new recipes. You need a dedicated Favorites view and navigation buttons to switch between generating recipes and viewing saved ones.
- Open src/App.jsx and replace its entire contents with this first section (imports, state, and the header):
import { useState } from 'react'
import IngredientForm from './components/IngredientForm.jsx'
import RecipeCard from './components/RecipeCard.jsx'
// Access the favorites array for the nav badge count
import { useFavorites } from './context/FavoritesContext.jsx'
import { generateRecipes } from './hooks/useGemini.js'
import './App.css'
export default function App() {
const [recipes, setRecipes] = useState([])
// Boolean controls which view is shown
const [showFavorites, setShowFavorites] = useState(false)
const { favorites } = useFavorites()
async function handleSubmit(ingredients) {
const result = await generateRecipes(ingredients)
setRecipes(result)
}
return (
<div className="app">
<header className="app-header">
<h1>Recipe Idea Generator</h1>
<nav className="app-nav">
<button onClick={() => setShowFavorites(false)} className={!showFavorites ? 'active' : ''}>Generate</button>
<button onClick={() => setShowFavorites(true)} className={showFavorites ? 'active' : ''}>Favorites ({favorites.length})</button>
</nav>
</header>
- Directly below the </header> line, add the main content area with conditional rendering:
{/* Switch between Favorites and Generate views */}
<main className="app-main">
{showFavorites ? (
<section>
<h2>Your Favorites</h2>
{favorites.length === 0 ? (
<p className="empty-state">No favorites yet. Generate some recipes and save the ones you like!</p>
) : (
<div className="recipe-grid">
{favorites.map(recipe => <RecipeCard key={recipe.id} recipe={recipe} />)}
</div>
)}
</section>
) : (
<section>
<IngredientForm onSubmit={handleSubmit} />
{recipes.length > 0 && (
<div className="recipe-grid">
{recipes.map(recipe => <RecipeCard key={recipe.id} recipe={recipe} />)}
</div>
)}
</section>
)}
</main>
</div>
)
}
How does the conditional rendering work?
The showFavorites boolean drives a ternary expression. When true, React renders the favorites section. When false, it renders the generator section with the form and recipe cards.
The nav buttons simply flip this boolean with setShowFavorites. The favorites.length badge in the nav updates in real time as you add or remove favorites.
💡 Why not React Router?
With only two views (Generate and Favorites), a boolean toggle with conditional rendering keeps things simple. React Router adds complexity that pays off when you have many pages with deep linking and URL parameters. For a two-view app, a state-driven switch is the right tool.
- Save src/App.jsx.
Before you test, predict: if you click a heart on the Generate view and then switch to Favorites, will the recipe appear there?
- Refresh your browser.
- Type some ingredients and click Get Recipe Ideas.
- Click the heart icon on one of the recipe cards to fill it.
- Click the Favorites (1) button in the navigation.
- Your saved recipe appears in the Favorites view.
- Click the filled heart again to remove it. The card disappears and the badge shows Favorites (0).
Favorites view showing empty even after clicking a heart?
Make sure src/main.jsx wraps <App /> in <FavoritesProvider>. Without the provider at the top level, RecipeCard and App use separate contexts that don't share state.
Also check that App.jsx imports useFavorites from './context/FavoritesContext.jsx' (one dot, since App is inside src/).
help me debug why favorites aren't sharing between views
✔️ Awesome, I've got everything!
Great. Confirm you've saved App.jsx and the Favorites view shows your saved recipes.
ⓧ I'd like to double check the full code
Your src/App.jsx should contain both sections you added above, in order:
- The imports (including useFavorites), the App function with recipes, showFavorites, and favorites state, the handleSubmit function, and the header with nav buttons.
- The <main> section with the ternary that renders either the Favorites section or the Generate section, followed by closing </div>, ), and }.
Scroll up and confirm both sections are present in order.
Your favorites system works across views. Next up, you'll tackle the UX gap you noticed earlier: the 2-3 second freeze when the app calls the API with no loading indicator. React 19's useActionState hook handles this elegantly.
Add Loading and Error States with useActionState
You built a working recipe generator, but there is an uncomfortable gap in the experience. When you click "Get Recipe Ideas", the button does nothing visible for 2-3 seconds while the API responds. If something goes wrong, you see nothing at all.
React 19 introduced a hook called useActionState specifically for this pattern. It wraps an async form action and gives you automatic pending state tracking and error handling with zero manual boolean management.
In this step, get ready to:
- Refactor your custom hook to use useActionState for automatic pending and error state.
- Update IngredientForm to use React 19's form action pattern.
- Style the app and verify all four UI states: loading, error, empty, and success.
Refactor useGemini to use useActionState
Right now your hook exports a plain async function. You need to replace it with a hook that returns a submit action, a pending flag, and any error.
What is useActionState?
useActionState takes an async action function and an initial state. It returns three values: the current state, a submit action you can pass directly to a form's action prop, and an isPending boolean that is true while the action runs.
The action function receives the previous state and a FormData object automatically. No manual event.preventDefault() or loading booleans needed.
- Open src/hooks/useGemini.js and replace its entire contents with the following.
- Start with the imports and the fetchRecipes helper by pasting this code:
import { useActionState } from 'react'
import { GoogleGenAI } from '@google/genai'
// Initialize the Gemini client once, outside any component
const ai = new GoogleGenAI({ apiKey: import.meta.env.VITE_GEMINI_API_KEY })
// Pure async function that calls the Gemini API
async function fetchRecipes(ingredients) {
const response = await ai.models.generateContent({
model: 'gemini-3.6-flash',
contents: `Given these ingredients: ${ingredients}, suggest 3 simple recipes. Return a JSON array where each object has: "id" (unique number), "title" (string), "description" (brief 1-2 sentence description), and "ingredients" (array of strings needed). Return ONLY valid JSON, no markdown or extra text.`,
})
const text = response.text
const jsonMatch = text.match(/\[[\s\S]*\]/)
if (!jsonMatch) {
throw new Error('Failed to parse recipes from AI response')
}
return JSON.parse(jsonMatch[0])
}
What changed here?
The fetchRecipes function is identical to your old generateRecipes but renamed and no longer exported. The new import of useActionState from 'react' is what enables the hook pattern below.
- Directly below the fetchRecipes function, add the useGemini hook export:
// Custom hook that wraps fetchRecipes in useActionState
export function useGemini() {
const [result, submitAction, isPending] = useActionState(
async (previousState, formData) => {
// Extract ingredients from the form's FormData
const ingredients = formData.get('ingredients')
if (!ingredients.trim()) {
return { recipes: [], error: 'Please enter at least one ingredient' }
}
try {
const recipes = await fetchRecipes(ingredients)
return { recipes, error: null }
} catch (err) {
return {
recipes: [],
error: err.message || 'Something went wrong. Please try again.',
}
}
},
{ recipes: [], error: null }
)
return {
recipes: result.recipes,
error: result.error,
isPending,
submitAction,
}
}
What does this hook do?
- useActionState accepts an action function and an initial state ({ recipes: [], error: null }).
- The action function receives previousState and formData automatically when the form submits.
- It returns result (the latest state), submitAction (a function to pass to the form), and isPending (true while the API call is in flight).
- Error handling is built in. If fetchRecipes throws, the catch block returns an error message instead of crashing.
- Save src/hooks/useGemini.js.
✔️ Awesome, I've got everything!
Great. Double check you have both the fetchRecipes function and the useGemini export in the file.
ⓧ I'd like to double check the full code
Here is the complete src/hooks/useGemini.js file. Scroll up and confirm your file has these two sections in order: the fetchRecipes function at the top, and the useGemini hook below it.
Update IngredientForm and App to use the action pattern
Your hook now returns a submitAction function designed to be passed directly to a form's action prop. This is React 19's form action pattern. It replaces the old onSubmit handler entirely.
- Open src/components/IngredientForm.jsx and replace its entire contents with this:
export default function IngredientForm({ submitAction, isPending, error }) {
return (
<form action={submitAction} className="ingredient-form">
<label htmlFor="ingredients">What ingredients do you have?</label>
<textarea
id="ingredients"
name="ingredients"
placeholder="e.g. chicken, rice, garlic, soy sauce"
rows={3}
disabled={isPending}
/>
<button type="submit" disabled={isPending}>
{isPending ? 'Generating...' : 'Get Recipe Ideas'}
</button>
{error && <p className="error-message">{error}</p>}
</form>
)
}
What changed in this component?
- action={submitAction} replaces onSubmit. React 19 calls your action function with FormData automatically.
- disabled={isPending} locks the textarea and button while the API responds.
- The button text flips between "Get Recipe Ideas" and "Generating..." based on isPending.
- error && <p>... renders an error message only when one exists.
- Save src/components/IngredientForm.jsx.
Now App.jsx still imports the old generateRecipes function and manages its own recipes state. You need to swap it to the new useGemini hook.
- In src/App.jsx, find this import line:
import { generateRecipes } from './hooks/useGemini.js'
- Replace it with:
import { useGemini } from './hooks/useGemini.js'
- Next, find these lines inside the App function:
const [recipes, setRecipes] = useState([])
const [showFavorites, setShowFavorites] = useState(false)
const { favorites } = useFavorites()
async function handleSubmit(ingredients) {
const result = await generateRecipes(ingredients)
setRecipes(result)
}
- Replace them with:
const [showFavorites, setShowFavorites] = useState(false)
const { favorites } = useFavorites()
const { recipes, error, isPending, submitAction } = useGemini()
- Finally, find the IngredientForm line in the JSX:
<IngredientForm onSubmit={handleSubmit} />
- Replace it with:
<IngredientForm submitAction={submitAction} isPending={isPending} error={error} />
- Save src/App.jsx.
Before you check the browser, what do you think will happen when you click "Get Recipe Ideas" now?
- Switch to your browser and try submitting some ingredients.
The button text now changes to "Generating..." and the form disables itself while the API call is in flight. When recipes come back, they appear below.
Seeing an error in the terminal?
- Check that you removed the old const [recipes, setRecipes] = useState([]) line and the handleSubmit function from App.jsx.
- Make sure the import says { useGemini } with curly braces (it is a named export, not a default).
- Confirm IngredientForm.jsx destructures { submitAction, isPending, error } and not the old { onSubmit }.
Still stuck? Help me debug why my useActionState refactor isn't working.
Style the app and verify all four UI states
The loading and error logic works, but the app has no visual styling yet. Adding styles will make the disabled state, error message, and card layout clearly visible.
- Open src/App.css (currently empty) and paste in the global reset and layout styles:
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background-color: #f5f5f5;
color: #1a1a1a;
line-height: 1.6;
}
.app {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
}
.app-header {
text-align: center;
margin-bottom: 2rem;
}
.app-header h1 {
font-size: 2rem;
margin-bottom: 1rem;
}
What do these styles do?
The reset removes default browser margins and sets box-sizing: border-box so padding does not add to element widths. The .app class centers content at a max 800px width.
- Below that, add the navigation button styles:
.app-nav {
display: flex;
gap: 0.5rem;
justify-content: center;
}
.app-nav button {
padding: 0.5rem 1.5rem;
border: 2px solid #4f46e5;
background: white;
color: #4f46e5;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
}
.app-nav button.active {
background: #4f46e5;
color: white;
}
.app-main h2 {
margin-bottom: 1rem;
}
- Save App.css and refresh your browser.
- You should see the heading centered, the Generate/Favorites buttons styled in indigo, and the active button filled.
- Add the form styles below your existing CSS:
.ingredient-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 2rem;
}
.ingredient-form label {
font-weight: 600;
font-size: 1.1rem;
}
.ingredient-form textarea {
padding: 0.75rem;
border: 2px solid #ddd;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
resize: vertical;
}
.ingredient-form textarea:focus {
outline: none;
border-color: #4f46e5;
}
- Continue adding the button and error message styles:
.ingredient-form button {
padding: 0.75rem 1.5rem;
background: #4f46e5;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
.ingredient-form button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
color: #dc2626;
font-size: 0.9rem;
}
Why style the disabled state?
The button:disabled rule makes the button visually dim and changes the cursor to not-allowed. This tells the user the form is working, not broken. The .error-message class renders errors in red so they stand out immediately.
- Save App.css and refresh.
- Submit some ingredients. You should see the button turn dimmer and show "Generating..." while the API responds.
- Add the recipe card styles:
.recipe-grid {
display: grid;
gap: 1rem;
}
.recipe-card {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.recipe-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.recipe-header h3 {
font-size: 1.2rem;
}
- Finally, add the favorite button, description, ingredients list, and empty state styles:
.favorite-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #999;
}
.favorite-btn.favorited {
color: #e11d48;
}
.recipe-description {
color: #555;
margin-bottom: 0.75rem;
}
.recipe-ingredients {
padding-left: 1.25rem;
}
.recipe-ingredients li {
margin-bottom: 0.25rem;
}
.empty-state {
text-align: center;
color: #888;
padding: 2rem;
font-style: italic;
}
- Save App.css and refresh your browser.
- You should see clean white recipe cards with rounded corners, a visible heart button, and an italicized empty-state message in the Favorites view.
Styles not showing up?
- Make sure you are editing src/App.css (not src/index.css).
- Confirm App.jsx still has import './App.css' at the top.
- Check that you have no typos in class names. The JSX uses className="ingredient-form" with a hyphen.
Why aren't my CSS styles applying to my React app?
Now let's confirm all four UI states work. What do you think will happen if you submit the form with the textarea completely empty?
- Clear the textarea and click **Get Recipe Ideas** with nothing typed.
You should see a red error message: "Please enter at least one ingredient". That is the validation check inside your useActionState action function catching an empty input.
- Type some real ingredients (like "eggs, cheese, spinach") and click **Get Recipe Ideas**.
- Confirm you see the button say "Generating...", then recipe cards appear below.
The four UI states your app now handles
- Empty: Initial load. No recipes, no error. Just the form.
- Loading: After clicking submit. Button says "Generating...", form is disabled.
- Success: API returned recipes. Cards render in the grid below the form.
- Error: Something went wrong. A red message appears below the button.
✔️ Awesome, I've got everything!
Great. Make sure all three files (useGemini.js, IngredientForm.jsx, App.jsx) are saved.
ⓧ I'd like to double check the full code
Here are the three files that changed in this step. Confirm yours match.
import { useActionState } from 'react'
import { GoogleGenAI } from '@google/genai'
const ai = new GoogleGenAI({ apiKey: import.meta.env.VITE_GEMINI_API_KEY })
async function fetchRecipes(ingredients) {
const response = await ai.models.generateContent({
model: 'gemini-3.6-flash',
contents: `Given these ingredients: ${ingredients}, suggest 3 simple recipes. Return a JSON array where each object has: "id" (unique number), "title" (string), "description" (brief 1-2 sentence description), and "ingredients" (array of strings needed). Return ONLY valid JSON, no markdown or extra text.`,
})
const text = response.text
const jsonMatch = text.match(/\[[\s\S]*\]/)
if (!jsonMatch) {
throw new Error('Failed to parse recipes from AI response')
}
return JSON.parse(jsonMatch[0])
}
export function useGemini() {
const [result, submitAction, isPending] = useActionState(
async (previousState, formData) => {
const ingredients = formData.get('ingredients')
if (!ingredients.trim()) {
return { recipes: [], error: 'Please enter at least one ingredient' }
}
try {
const recipes = await fetchRecipes(ingredients)
return { recipes, error: null }
} catch (err) {
return {
recipes: [],
error: err.message || 'Something went wrong. Please try again.',
}
}
},
{ recipes: [], error: null }
)
return {
recipes: result.recipes,
error: result.error,
isPending,
submitAction,
}
}
export default function IngredientForm({ submitAction, isPending, error }) {
return (
<form action={submitAction} className="ingredient-form">
<label htmlFor="ingredients">What ingredients do you have?</label>
<textarea
id="ingredients"
name="ingredients"
placeholder="e.g. chicken, rice, garlic, soy sauce"
rows={3}
disabled={isPending}
/>
<button type="submit" disabled={isPending}>
{isPending ? 'Generating...' : 'Get Recipe Ideas'}
</button>
{error && <p className="error-message">{error}</p>}
</form>
)
}
import { useState } from 'react'
import IngredientForm from './components/IngredientForm.jsx'
import RecipeCard from './components/RecipeCard.jsx'
import { useFavorites } from './context/FavoritesContext.jsx'
import { useGemini } from './hooks/useGemini.js'
import './App.css'
export default function App() {
const [showFavorites, setShowFavorites] = useState(false)
const { favorites } = useFavorites()
const { recipes, error, isPending, submitAction } = useGemini()
return (
<div className="app">
<header className="app-header">
<h1>Recipe Idea Generator</h1>
<nav className="app-nav">
<button onClick={() => setShowFavorites(false)} className={!showFavorites ? 'active' : ''}>Generate</button>
<button onClick={() => setShowFavorites(true)} className={showFavorites ? 'active' : ''}>Favorites ({favorites.length})</button>
</nav>
</header>
<main className="app-main">
{showFavorites ? (
<section>
<h2>Your Favorites</h2>
{favorites.length === 0 ? (
<p className="empty-state">No favorites yet. Generate some recipes and save the ones you like!</p>
) : (
<div className="recipe-grid">
{favorites.map(recipe => <RecipeCard key={recipe.id} recipe={recipe} />)}
</div>
)}
</section>
) : (
<section>
<IngredientForm submitAction={submitAction} isPending={isPending} error={error} />
{recipes.length > 0 && (
<div className="recipe-grid">
{recipes.map(recipe => <RecipeCard key={recipe.id} recipe={recipe} />)}
</div>
)}
</section>
)}
</main>
</div>
)
}
Your app now handles every state gracefully. Loading, errors, empty inputs, and successful responses all have clear visual feedback powered by a single useActionState hook. No manual loading booleans, no try/catch in the component, no event.preventDefault().
Secret mission
Add Dark/Light Theme with a Second Context Provider
You already built one Context provider for favorites. Can you build a second one for theming? Add a dark/light mode toggle that uses useMemo to optimize the context value and CSS variables to restyle the entire app with one click.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally with a free-tier API key, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Gemini API key (free tier, no charges for input or output tokens).
- Local Vite dev server (running at localhost:5173).
- Project source files on disk (components, hooks, context, styles).
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with different ingredients and recipes.
- Your Gemini API key remains active on the free tier indefinitely. There is nothing to pay.
- Your dev server keeps running until you close the terminal or press Ctrl+C.
- Your project files stay on disk ready to edit.
Pause - I'll come back to this later
Shut down running processes to free up system resources. Your code and API key stay intact for when you return.
- Switch to the terminal running your dev server and press Ctrl+C to stop it.
- Your source files and .env file remain on disk. When you are ready to continue, run npm run dev from your project folder to start the server again.
- Your Gemini API key stays active. No action needed there.
Delete - I don't want to use this again
Remove all project resources and revoke your API key so nothing is left behind.
- Stop the dev server by pressing Ctrl+C in the terminal if it is still running.
- Revoke your Gemini API key by opening the Google Cloud API Credentials page at console.cloud.google.com/apis/credentials.
- Find the API key you used for this project and click the Actions icon (three dots).
- Select Delete API key and confirm in the Delete credential modal.
- Delete the project files you created during this project. Run the following command from the parent folder that contains your project directory:
macOS
rm -rf .env src/components src/hooks src/context src/App.css src/App.jsx
Windows
Remove-Item -Recurse -Force .env, src\components, src\hooks, src\context, src\App.css, src\App.jsx
- Uninstall the Gemini SDK from your project by running:
npm uninstall @google/genai
- Your Vite + React scaffold (src/main.jsx, src/index.css, package.json) remains intact for future projects.
Nice Work!
Nice Work!
Nice work! You just built a fully interactive AI-powered recipe app with React 19 and Google's Gemini AI.
You've learned how to:
- Built a multi-component React app where IngredientForm and RecipeCard communicate through props and share state through the Context API.
- Created a custom hook (useGemini) that encapsulates AI data fetching and returns recipes, errors, and loading state to any component that needs it.
- Used React 19's useActionState to handle async form submission with automatic pending states and error handling, replacing manual useState loading booleans.
- Implemented a second Context provider for dark/light theming with useMemo optimization, proving how multiple providers compose together (Secret Mission).
Ready to quiz yourself?