React Storefront & Chat UI
ShopKart now has a backend, but nobody can shop with curl. In this lesson you build the storefront: a product grid that renders the catalog and a ChatWidget that will become the home of your AI assistant. The widget only talks to a mock reply for now - in lesson 4 you point it at the real /api/chat endpoint, and the UI you write here will not change.
We use React 18 with functional components and hooks (useState, useEffect, useRef) and the browser fetch API. No Redux, no extra state library - just the built-ins you already know.
The component tree
Keep the structure flat and obvious. App owns nothing but layout; the two children each own their own state and data fetching.
Enable CORS on the backend first
The React dev server runs on http://localhost:5173 and Spring Boot on 8080. Different ports mean different origins, so the browser blocks the request unless the backend opts in. Add a tiny CORS config on the Spring side (this is the one backend file we add in this lesson).
package com.shopkart.config;
import org.springframework.context.annotation.*;
import org.springframework.web.servlet.config.annotation.*;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:5173")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("*");
}
}
In production you would replace that hard-coded origin with your real storefront domain, ideally read from configuration rather than baked into code.
The product grid
ProductGrid fetches the catalog once on mount and renders a card per product. It handles the three states every data component needs: loading, error, and loaded.
import { useState, useEffect } from 'react';
const API = 'http://localhost:8080/api';
function ProductCard({ product }) {
const inStock = product.stock > 0;
return (
<div className="card">
<h3>{product.name}</h3>
<p className="brand">{product.brand} - {product.category}</p>
<p className="price">
{new Intl.NumberFormat('en-IN', {
style: 'currency', currency: 'INR', maximumFractionDigits: 0,
}).format(product.price)}
</p>
<p className="rating">{product.rating} stars</p>
<button disabled={!inStock}>
{inStock ? 'Add to cart' : 'Out of stock'}
</button>
</div>
);
}
export default function ProductGrid() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch(`${API}/products`)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => setProducts(data))
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);
if (loading) return <p>Loading products...</p>;
if (error) return <p>Could not load products: {error}</p>;
return (
<div className="grid">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
);
}
Two details worth calling out. Intl.NumberFormat with the en-IN locale formats prices as proper Indian rupees, complete with the rupee symbol and the lakh-style grouping. And the key={p.id} on each card lets React track items efficiently across re-renders - always use a stable id, never the array index.
The ChatWidget
This is the component that becomes your AI assistant. It owns a list of messages, a controlled input, and a sendMessage handler. Each message is a small object: { role, content } where role is 'user' or 'assistant'.
import { useState, useRef, useEffect } from 'react';
const API = 'http://localhost:8080/api';
export default function ChatWidget() {
const [messages, setMessages] = useState([
{ role: 'assistant', content: 'Hi! I am the ShopKart assistant. What are you shopping for today?' },
]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const endRef = useRef(null);
// Auto-scroll to the newest message
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
async function sendMessage(e) {
e.preventDefault();
const text = input.trim();
if (!text || sending) return;
// Optimistically show the user's message
setMessages((prev) => [...prev, { role: 'user', content: text }]);
setInput('');
setSending(true);
try {
// For now this is a placeholder. Lesson 4 points it at the real agent.
const reply = await mockReply(text);
setMessages((prev) => [...prev, { role: 'assistant', content: reply }]);
} catch (err) {
setMessages((prev) => [
...prev,
{ role: 'assistant', content: 'Sorry, something went wrong. Please try again.' },
]);
} finally {
setSending(false);
}
}
return (
<div className="chat">
<div className="messages">
{messages.map((m, i) => (
<div key={i} className={`bubble ${m.role}`}>
{m.content}
</div>
))}
{sending && <div className="bubble assistant typing">Thinking...</div>}
<div ref={endRef} />
</div>
<form className="composer" onSubmit={sendMessage}>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask about products, stock, or place an order..."
disabled={sending}
/>
<button type="submit" disabled={sending || !input.trim()}>
Send
</button>
</form>
</div>
);
}
// Temporary stub - replaced by a real fetch to /api/chat in lesson 4.
async function mockReply(text) {
await new Promise((r) => setTimeout(r, 600));
return `You said: "${text}". Soon I will actually search ShopKart for you!`;
}
Several React patterns are doing real work here:
- Functional updates (
setMessages(prev => [...prev, ...])) avoid stale-state bugs when two updates happen close together. - The
sendingflag disables the input and shows a "Thinking..." bubble, so the user knows the assistant is working. useRefplusscrollIntoViewkeeps the newest message in view without re-rendering the whole list.- Optimistic UI: the user's message appears instantly, before any network round-trip, which makes the chat feel snappy.
Wiring it together in App
App just lays out the storefront and drops the widget beside it.
import ProductGrid from './ProductGrid';
import ChatWidget from './ChatWidget';
export default function App() {
return (
<div className="app">
<header>
<h1>ShopKart</h1>
<p>Everything you need, delivered.</p>
</header>
<main className="layout">
<section className="catalog">
<h2>Featured products</h2>
<ProductGrid />
</section>
<aside className="assistant">
<h2>Shopping assistant</h2>
<ChatWidget />
</aside>
</main>
</div>
);
}
A word on the message shape
Notice the message object is { role, content } - the exact same shape the LLM APIs use for conversation history. That is not a coincidence. When you connect to the backend in lesson 4, you will send this array almost as-is, and the backend will hand it to Spring AI. Designing your frontend state to mirror the model's message format now saves you a translation layer later.
Build the chat UI against a mock before the backend exists. A stubbed mockReply lets you nail the layout, the loading state, the auto-scroll, and the empty-input handling without burning LLM tokens on every click. Frontend and backend can then progress in parallel, and swapping the mock for a real fetch is a one-function change.
Key takeaways
- Enable CORS on Spring Boot for the React dev origin, or every browser request is blocked.
ProductGridfetches once inuseEffectand handles loading, error, and loaded states; format rupees withIntl.NumberFormat('en-IN').ChatWidgetholds messages as{ role, content }objects, uses functional state updates, asendingflag, anduseRefscroll-to-bottom.- Optimistic UI shows the user's message instantly for a responsive feel.
- The
{ role, content }shape mirrors the LLM message format, so wiring the real agent in next lesson is trivial.
Next up: Wiring the LLM in with Spring AI - add a ChatClient bean and an /api/chat endpoint, then connect this widget to a real conversation.