Let’s create a React component that displays items from your API. Make sure your API is running first!

Step 1: Create the Component

Create web/src/components/ItemList.tsx:

"use client";

import { useEffect, useState } from "react";

interface Item {
  id: number;
  name: string;
  completed: boolean;
}

const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";

export function ItemList() {
  const [items, setItems] = useState<Item[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchItems = async () => {
      try {
        const response = await fetch(`${API_URL}/api/items`);
        if (!response.ok) {
          throw new Error("Failed to fetch items");
        }
        const data = await response.json();
        setItems(data);
      } catch (err) {
        setError(err instanceof Error ? err.message : "Unknown error");
      } finally {
        setLoading(false);
      }
    };

    fetchItems();
  }, []);

  if (loading) return <div>Loading items...</div>;
  if (error) return <div style={{ color: "red" }}>Error: {error}</div>;

  return (
    <div>
      <h2>Items</h2>
      {items.length === 0 ? (
        <p>No items yet.</p>
      ) : (
        <ul>
          {items.map((item) => (
            <li key={item.id}>
              <input type="checkbox" checked={item.completed} readOnly />
              <span>{item.name}</span>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Step 2: Use the Component

Update web/src/app/page.tsx:

import { ItemList } from "@/components/ItemList";

export default function Home() {
  return (
    <main>
      <h1>Welcome to my app</h1>
      <ItemList />
    </main>
  );
}

Step 3: Test It

Make sure both services are running:

Terminal 1 (API):

cd api && cargo run

Terminal 2 (Frontend):

cd web && npm run dev

Visit http://localhost:3000 and you should see your items list!

Step 4: Add a Form to Create Items

Create web/src/components/AddItemForm.tsx:

"use client";

import { useState } from "react";

interface Item {
  id: number;
  name: string;
  completed: boolean;
}

interface AddItemFormProps {
  onItemAdded: (item: Item) => void;
}

const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";

export function AddItemForm({ onItemAdded }: AddItemFormProps) {
  const [name, setName] = useState("");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setError(null);

    try {
      const response = await fetch(`${API_URL}/api/items`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ name }),
      });

      if (!response.ok) {
        throw new Error("Failed to create item");
      }

      const newItem = await response.json();
      setName("");
      onItemAdded(newItem);
    } catch (err) {
      setError(err instanceof Error ? err.message : "Unknown error");
    } finally {
      setLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit} style={{ marginBottom: "20px" }}>
      <input
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="What needs to be done?"
        disabled={loading}
      />
      <button type="submit" disabled={loading || !name}>
        {loading ? "Adding..." : "Add"}
      </button>
      {error && <div style={{ color: "red" }}>{error}</div>}
    </form>
  );
}

Step 5: Combine Form + List

Update web/src/components/ItemList.tsx:

"use client";

import { useEffect, useState } from "react";
import { AddItemForm } from "./AddItemForm";

interface Item {
  id: number;
  name: string;
  completed: boolean;
}

const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";

export function ItemList() {
  const [items, setItems] = useState<Item[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchItems = async () => {
      try {
        const response = await fetch(`${API_URL}/api/items`);
        if (!response.ok) throw new Error("Failed to fetch");
        const data = await response.json();
        setItems(data);
      } catch (err) {
        setError(err instanceof Error ? err.message : "Unknown error");
      } finally {
        setLoading(false);
      }
    };

    fetchItems();
  }, []);

  const handleItemAdded = (newItem: Item) => {
    setItems([...items, newItem]);
  };

  if (loading) return <div>Loading...</div>;
  if (error) return <div style={{ color: "red" }}>Error: {error}</div>;

  return (
    <div>
      <h2>Items</h2>
      <AddItemForm onItemAdded={handleItemAdded} />
      
      {items.length === 0 ? (
        <p>No items yet.</p>
      ) : (
        <ul>
          {items.map((item) => (
            <li key={item.id}>
              <input type="checkbox" checked={item.completed} readOnly />
              <span>{item.name}</span>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Now you can add items from the form! 🎉

Step 6: With Tailwind CSS (Optional)

If you scaffolded with --tailwind:

export function ItemList() {
  // ... state and logic ...

  return (
    <div className="max-w-md mx-auto p-6">
      <h2 className="text-2xl font-bold mb-4">Items</h2>
      <AddItemForm onItemAdded={handleItemAdded} />
      
      {items.length === 0 ? (
        <p className="text-gray-500 mt-4">No items yet.</p>
      ) : (
        <ul className="space-y-2 mt-4">
          {items.map((item) => (
            <li
              key={item.id}
              className="flex items-center gap-3 p-3 border rounded hover:bg-gray-50"
            >
              <input type="checkbox" checked={item.completed} className="w-5 h-5" />
              <span className={item.completed ? "line-through text-gray-500" : ""}>
                {item.name}
              </span>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Update the form too:

return (
  <form onSubmit={handleSubmit} className="flex gap-2 mb-4">
    <input
      type="text"
      value={name}
      onChange={(e) => setName(e.target.value)}
      placeholder="What needs to be done?"
      disabled={loading}
      className="flex-1 px-4 py-2 border rounded"
    />
    <button
      type="submit"
      disabled={loading || !name}
      className="px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
    >
      {loading ? "Adding..." : "Add"}
    </button>
    {error && <div className="text-red-500">{error}</div>}
  </form>
);

Understanding the Code

"use client"

Marks this as a Client Component because it uses:

  • useState and useEffect (React hooks)
  • Event handlers (form submission)
  • Browser APIs (fetch)

useEffect Hook

Runs once on mount ([] dependency array) to fetch items from your API.

State Management

  • items — The list of items
  • loading — Show loading state while fetching
  • error — Show error message if fetch fails

Error Handling

Wraps fetch in try-catch to handle network errors gracefully.

Common Issues

“Items don’t appear”

  • Check your API is running on port 3001
  • Check NEXT_PUBLIC_API_URL in .env.local
  • Check browser console (F12) for errors

“CORS error”

“Form doesn’t work”

  • Make sure onItemAdded handler is called
  • Check that your API POST endpoint returns the created item

Next Steps