🔥 0
0
Lesson 3 of 8 25 min +200 XP

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.

ShopKart React component tree App.jsx layout only ProductGrid.jsx useEffect fetch GET /api/products ProductCard ProductCard ChatWidget.jsx useState messages POST /api/chat MessageList bubbles Composer input + send

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 sending flag disables the input and shows a "Thinking..." bubble, so the user knows the assistant is working.
  • useRef plus scrollIntoView keeps 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.

On the job

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.
  • ProductGrid fetches once in useEffect and handles loading, error, and loaded states; format rupees with Intl.NumberFormat('en-IN').
  • ChatWidget holds messages as { role, content } objects, uses functional state updates, a sending flag, and useRef scroll-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.

The Spring Boot E-Commerce API