Back to Course |
Livewire VS Vue VS React: Simple Project

React.js Version

This example will show you how we built the system using React.js. For this, we will have three components:

  1. Product List - List of Products with a button to add to the Cart.
  2. Product Filters - Sidebar with filters for Products.
  3. Cart Display - Simply display the number of Products in our Cart.

Bonus: we will call the API Endpoint that adds products to the cart with no page reload.


Install Breeze

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.


Dashboard: "Draft" Overview

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.


Product List Component

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>
)
}

Adding Products to Cart

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);
});

Product Filters Component

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 present
const [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 change
useEffect(() => {
setSelected({
prices: initialSelected?.prices || [],
categories: initialSelected?.categories || [],
manufacturers: initialSelected?.manufacturers || [],
});
}, [initialSelected]);

Finally, we can handle any changes to our filters:

// 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'],
});
});
};

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.


Cart Component

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?