This example will show you how we built the system using React.js. For this, we will have three components:
Bonus: we will call the API Endpoint that adds products to the cart with no page reload.
Before we code anything in React, we need to install a few packages:
First, we have to install Breeze:
composer require laravel/breeze --dev
And then we need to run it's install:
php artisan breeze:install react
In this install, we select the React Inertia
stack.
Once installed, we'll create our first Controller:
app/Http/Controllers/DashboardController.php
use App\Models\Cart;use App\Models\Category;use App\Models\Manufacturer;use App\Models\Product;use App\Services\PriceService;use Illuminate\Http\Request;use Inertia\Inertia; class DashboardController extends Controller{ public function __invoke(Request $request, PriceService $priceService) { $selected = $request->input('selected', [ 'prices' => [], 'categories' => [], 'manufacturers' => [] ]); $prices = $priceService->getPrices( [], $selected['categories'] ?? [], $selected['manufacturers'] ?? [] ); $categories = Category::withCount(['products' => function ($query) use ($selected) { $query->withFilters( $selected['prices'] ?? [], [], $selected['manufacturers'] ?? [] ); }]) ->get(); $manufacturers = Manufacturer::withCount(['products' => function ($query) use ($selected) { $query->withFilters( $selected['prices'] ?? [], $selected['categories'] ?? [], [] ); }]) ->get(); $products = Product::withFilters( $selected['prices'] ?? [], $selected['categories'] ?? [], $selected['manufacturers'] ?? [] )->get(); return Inertia::render('Dashboard', [ 'prices' => $prices, 'categories' => $categories, 'manufacturers' => $manufacturers, 'selected' => $selected, 'products' => $products, 'cart' => Cart::count(), 'cartProducts' => Cart::pluck('product_id')->unique()->toArray(), ]); }}
This Controller will load all the information we display on the page and pass it to the Inertia renderer.
And, of course, we need to add the route to the Controller instead of the default View:
routes/web.php
use App\Http\Controllers\DashboardController; // ... Route::get('/dashboard', function () { return view('dashboard');}); Route::get('/dashboard', DashboardController::class)->middleware(['auth', 'verified'])->name('dashboard');
Now, we can modify the Dashboard.jsx
file that comes with Breeze. We add three not-yet-existing React components inside:
<ProductsList>
<ProductFilters>
<CartCount>
resources/js/Pages/Dashboard.jsx
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';import ProductFilters from '@/Components/Products/ProductFilters.jsx'import ProductsList from '@/Components/Products/ProductsList.jsx'import CartCount from '@/Components/Cart/CartCount.jsx'import {Head} from '@inertiajs/react'; export default function Dashboard({ prices, categories, manufacturers, selected, products, cart, cartProducts }) { return ( <AuthenticatedLayout header={ <div className="flex flex-grow justify-between items-center"> <h2 className="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200"> Dashboard </h2> <CartCount cart={cart}></CartCount> </div> } > <Head title="Dashboard"/> <div className="py-12"> <div className="mx-auto max-w-7xl sm:px-6 lg:px-8"> <div className="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800"> <div className="flex p-6"> <div className="w-1/3"> <ProductFilters prices={prices} categories={categories} manufacturers={manufacturers} selected={selected} ></ProductFilters> </div> <div className="w-2/3"> <ProductsList products={products} cartProducts={cartProducts} ></ProductsList> </div> </div> </div> </div> </div> </AuthenticatedLayout> );}
Loading the page now would throw a lot of errors. So let's build the components.
Since this is Inertia based system, we have to create our React file without the data retrieval:
resources/js/Components/Products/ProductsList.jsx
import {Link} from "@inertiajs/react"; export default function ProductsList({ products, cartProducts }) { function AddToCartButton({id, inCart}) { if (inCart) { return ( <Link href={'/products/cart/add-or-remove/' + id} method="post" as="button" type="button" className="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"> Remove from Cart </Link> ) } return ( <Link href={'/products/cart/add-or-remove/' + id} method="post" as="button" type="button" className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"> Add to Cart </Link> ) } return ( <div className="flex flex-wrap"> {products.map((product, index) => ( <div className="w-1/3 p-3" key={'product' + index}> <div className="rounded-md"> <a href="#"> <img src="http://placehold.it/700x400" alt=""></img> </a> <div className="mt-3"> <a href="#" className="text-2xl text-indigo-500 hover:underline">{product.name}</a> </div> <h5 className="mt-3">$ {product.price}</h5> <p className="mt-3">{product.description}</p> <div className="mt-4 border-t pt-6"> <AddToCartButton inCart={cartProducts.includes(product.id)} id={product.id}></AddToCartButton> </div> </div> </div> ))} </div> );}
Let's break it down.
This code tells us that we will expect two properties on our component:
export default function ProductsList({ products, cartProducts }) {
Then, we will iterate over our products
property and render a list:
return ( <div className="flex flex-wrap"> {products.map((product, index) => ( <div className="w-1/3 p-3" key={'product' + index}> <div className="rounded-md"> <a href="#"> <img src="http://placehold.it/700x400" alt=""></img> </a> <div className="mt-3"> <a href="#" className="text-2xl text-indigo-500 hover:underline">{product.name}</a> </div> <h5 className="mt-3">$ {product.price}</h5> <p className="mt-3">{product.description}</p> <div className="mt-4 border-t pt-6"> <AddToCartButton inCart={cartProducts.includes(product.id)} id={product.id}></AddToCartButton> </div> </div> </div> ))} </div>);
Last, we have a method to render an add or remove button for our products:
function AddToCartButton({id, inCart}) { if (inCart) { return ( <Link href={'/products/cart/add-or-remove/' + id} method="post" as="button" type="button" className="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"> Remove from Cart </Link> ) } return ( <Link href={'/products/cart/add-or-remove/' + id} method="post" as="button" type="button" className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"> Add to Cart </Link> )}
We will use the same logic as in Vue lesson - separate API Controller.
app/Http/Controllers/Api/AddOrRemoveProductController.php
use App\Http\Controllers\Controller;use App\Models\Cart; class AddOrRemoveProductController extends Controller{ public function __invoke(int $productID) { if (Cart::where('product_id', $productID)->exists()) { Cart::where('product_id', $productID)->delete(); } else { Cart::create(['product_id' => $productID]); } return redirect()->back(); }}
Then register the route:
routes/web.php
use App\Http\Controllers\Api\AddOrRemoveProductController; // ... Route::middleware('auth')->group(function () { Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); Route::post('products/cart/add-or-remove/{productID}', AddOrRemoveProductController::class);});
Next, we can work on our Filters display:
resources/js/Components/Products/ProductFilters.jsx
import { useEffect, useState } from 'react';import { router } from '@inertiajs/react'; export default function ProductFilters({ prices, categories, manufacturers, selected: initialSelected }) { // Initialize selected state, ensuring all types are present const [selected, setSelected] = useState({ prices: initialSelected?.prices || [], categories: initialSelected?.categories || [], manufacturers: initialSelected?.manufacturers || [], }); // Effect to synchronize local state with initial props when they change useEffect(() => { setSelected({ prices: initialSelected?.prices || [], categories: initialSelected?.categories || [], manufacturers: initialSelected?.manufacturers || [], }); }, [initialSelected]); // Function to handle checkbox changes const filterProducts = (e, type) => { const value = e.target.value; const checked = e.target.checked; setSelected(prevSelected => { // Ensure prevSelected[type] is defined const currentSelection = prevSelected[type] || []; const updatedItems = checked ? [...currentSelection, value] // Add the value if checked : currentSelection.filter(item => item !== value); // Remove value if unchecked const newSelected = { ...prevSelected, [type]: updatedItems, // Update the specific type }; // Make the API call after state update const updatedData = { 'selected[prices]': newSelected.prices, 'selected[categories]': newSelected.categories, 'selected[manufacturers]': newSelected.manufacturers, }; router.visit('/dashboard', { data: updatedData, preserveScroll: true, except: ['cart', 'cartProducts'], }); }); }; return ( <div className="col-lg-3 mb-4"> <h1 className="mt-4 text-4xl">Filters</h1> <h3 className="mt-2 mb-1 text-3xl">Price</h3> {prices.map((price, index) => ( <div key={index + '_prices'}> <input type="checkbox" id={'price' + index} value={index} defaultChecked={selected?.prices?.includes(index.toString())} name="selected[prices]" onChange={(e) => filterProducts(e, 'prices')} /> <label htmlFor={'price' + index}> {price.name} ({price.products_count}) </label> </div> ))} <h3 className="mt-2 mb-1 text-3xl">Categories</h3> {categories.map((category, index) => ( <div key={category.id + '_categories'}> <input type="checkbox" id={'category' + index} value={category.id} defaultChecked={selected?.categories?.includes(category.id.toString())} name="selected[categories]" onChange={(e) => filterProducts(e, 'categories')} /> <label htmlFor={'category' + index}> {category.name} ({category.products_count}) </label> </div> ))} <h3 className="mt-2 mb-1 text-3xl">Manufacturers</h3> {manufacturers.map((manufacturer, index) => ( <div key={manufacturer.id + '_manufacturers'}> <input type="checkbox" id={'manufacturer' + index} value={manufacturer.id} defaultChecked={selected?.manufacturers?.includes(manufacturer.id.toString())} name="selected[manufacturers][]" onChange={(e) => filterProducts(e, 'manufacturers')} /> <label htmlFor={'manufacturer' + index}> {manufacturer.name} ({manufacturer.products_count}) </label> </div> ))} </div> )}
Once again, in this code, we declare the expected properties:
export default function ProductFilters({ prices, categories, manufacturers, selected }) {
Then we have to set local state variables, as we will later watch for changes:
// Initialize selected state, ensuring all types are presentconst [selected, setSelected] = useState({ prices: initialSelected?.prices || [], categories: initialSelected?.categories || [], manufacturers: initialSelected?.manufacturers || [],});
We also need to be sure that we have them synchronized with our local state:
// Effect to synchronize local state with initial props when they changeuseEffect(() => { setSelected({ prices: initialSelected?.prices || [], categories: initialSelected?.categories || [], manufacturers: initialSelected?.manufacturers || [], });}, [initialSelected]);
Finally, we can handle any changes to our filters:
// Function to handle checkbox changesconst filterProducts = (e, type) => { const value = e.target.value; const checked = e.target.checked; setSelected(prevSelected => { // Ensure prevSelected[type] is defined const currentSelection = prevSelected[type] || []; const updatedItems = checked ? [...currentSelection, value] // Add the value if checked : currentSelection.filter(item => item !== value); // Remove value if unchecked const newSelected = { ...prevSelected, [type]: updatedItems, // Update the specific type }; // Make the API call after state update const updatedData = { 'selected[prices]': newSelected.prices, 'selected[categories]': newSelected.categories, 'selected[manufacturers]': newSelected.manufacturers, }; router.visit('/dashboard', { data: updatedData, preserveScroll: true, except: ['cart', 'cartProducts'], }); });};
And, of course, on our display - we do simple loops:
return ( <div className="col-lg-3 mb-4"> <h1 className="mt-4 text-4xl">Filters</h1> <h3 className="mt-2 mb-1 text-3xl">Price</h3> {prices.map((price, index) => ( <div key={index + '_prices'}> <input type="checkbox" id={'price' + index} value={index} defaultChecked={selected?.prices?.includes(index.toString())} name="selected[prices]" onChange={(e) => filterProducts(e, 'prices')} /> <label htmlFor={'price' + index}> {price.name} ({price.products_count}) </label> </div> ))} <h3 className="mt-2 mb-1 text-3xl">Categories</h3> {categories.map((category, index) => ( <div key={category.id + '_categories'}> <input type="checkbox" id={'category' + index} value={category.id} defaultChecked={selected?.categories?.includes(category.id.toString())} name="selected[categories]" onChange={(e) => filterProducts(e, 'categories')} /> <label htmlFor={'category' + index}> {category.name} ({category.products_count}) </label> </div> ))} <h3 className="mt-2 mb-1 text-3xl">Manufacturers</h3> {manufacturers.map((manufacturer, index) => ( <div key={manufacturer.id + '_manufacturers'}> <input type="checkbox" id={'manufacturer' + index} value={manufacturer.id} defaultChecked={selected?.manufacturers?.includes(manufacturer.id.toString())} name="selected[manufacturers][]" onChange={(e) => filterProducts(e, 'manufacturers')} /> <label htmlFor={'manufacturer' + index}> {manufacturer.name} ({manufacturer.products_count}) </label> </div> ))} </div>)
This code part seems pretty complex, but in reality, there are a lot of things to keep track of.
This component will once again be pretty simple:
resources/js/Components/Cart/CartCount.jsx
export default function CartCount({cart}) { return ( <div className="px-4 py-3 leading-normal text-blue-700 bg-blue-100 rounded-lg text-right w-1/3"> <i className="fa fa-shopping-cart"></i> Cart ({cart}) </div> );}
In this component, we simply expect a cart
property and display it on our button.
That's it. Our application should now load after running npm run build
.
You can find the code for this lesson in the branch on GitHub
The next (final) lesson will be with my takeaways and opinion from this three-stack experiment. Which one is better or more convenient to work with: Livewire, Vue or React?