GitHub Repo: pkellner/pluralsight-react-working-with-data
Stack: React 18 · Next.js 14 · TypeScript · SQLite · Prisma · NextAuth.js · Zod
Table of Contents
- Course Overview
- Understanding Core Data Patterns
- Working with Data in a Pure SPA (Client-only)
- Leveraging React Context for Data and Forms
- Using Suspense for Async Data Management
- Implementing Enterprise Features
- Updating Data with Server Actions
- Final Summary
- Reference Tables
- Architecture Diagrams
1. Course Overview
Learning Objectives
This course covers the essentials of building web applications that exchange data seamlessly between the browser and the server. The main topics are:
- Integration with REST servers
- Advanced HTML form handling
- Concurrent rendering with Suspense
- Server Components and Server Actions for transparent server-side JavaScript integration
Prerequisites
- Basic React knowledge
- Having built at least one simple React application that responds to state changes
General Architecture of the Demo App
flowchart TD
Browser["🌐 Browser\n(React Client)"]
NextServer["⚙️ Node Server\n(Next.js)"]
REST["🔌 REST API\n/api/speakers\n/api/attendees"]
SA["⚡ Server Actions\naddAttendeeAction\nspeakerDataContextActions"]
Prisma["🗄️ Prisma ORM"]
SQLite["💾 SQLite Database\nSpeakers · Sessions · Attendees"]
Browser -- "fetch() / useEffect" --> REST
Browser -- "form action / startTransition" --> SA
REST --> Prisma
SA --> Prisma
Prisma --> SQLite
NextServer -- "Server Components" --> Prisma
2. Understanding Core Data Patterns
2.1 First Render Lifecycle
On the first load of a React app, the typical flow is:
- React performs an initial render with animated placeholders (
loadingstate) useEffecttriggers afetchto the REST server- The server responds with JSON data
- React updates the state → re-render with real data
sequenceDiagram
participant Browser as 🌐 Browser
participant React as ⚛️ React App
participant Server as 🖥️ REST Server
participant DB as 💾 Database
Browser->>React: Load the page (URL request)
React->>Browser: Initial render (loading placeholders)
React->>Server: GET /api/speakers
Server->>DB: SELECT * FROM Speaker
DB-->>Server: Data rows
Server-->>React: JSON speakers[]
React->>Browser: Re-render with speakers
2.2 UI Interaction Lifecycle
When a user clicks a favorite icon:
sequenceDiagram
participant User as 👤 User
participant React as ⚛️ React
participant Server as 🖥️ Server
User->>React: Click on ❤️ icon
React->>React: setUpdating(true) → spinner visible
React->>Server: PUT /api/speakers/:id { isFavorite: true }
Note over React,Server: In transit over network...
Server-->>React: 200 OK { updated speaker }
React->>React: setUpdating(false) + setLocalSpeaker(updated)
React->>User: Icon updated ❤️ red / black
2.3 The Two Sources of Events in React
| Source | Examples | React Handling |
|---|---|---|
| UI Browser | Click, scroll, keypress | Event handlers (onClick, onChange) |
| External resource | Server response, WebSocket | useEffect + promise callbacks |
2.4 Comparison: State vs Suspense for Loading
flowchart LR
subgraph State["With local state (classic)"]
S1["Component created"] --> S2["state: loading=true, speakers=[]"]
S2 --> S3["Render: shows 'Loading...'"]
S3 --> S4["useEffect: fetch()"]
S4 --> S5["setState: loading=false, speakers=[...]"]
S5 --> S6["Re-render with data"]
end
subgraph Susp["With Suspense"]
P1["Parent creates a Promise"] --> P2["<Suspense fallback={<Loading/>}>"]
P2 --> P3["Child calls use(promise)"]
P3 --> P4{Promise resolved?}
P4 -- No --> P5["Shows fallback"]
P4 -- Yes --> P6["Render with data"]
end
3. Working with Data in a Pure SPA
3.1 Next.js Project Setup
npx create-next-app@latest my-app --typescript
cd my-app
npm install bootstrap
npm install prisma --save-dev
npx prisma init --datasource-provider sqlite
3.2 Basic Pattern: useEffect + fetch
Fundamental pattern for loading data from a REST server:
// src/app/speakers/page.tsx
"use client";
import { useState, useEffect } from "react";
type Speaker = {
id: number;
firstName: string;
lastName: string;
company: string;
twitterHandle: string;
bio: string;
speakingAt: Date;
isFavorite: boolean;
};
type SpeakerState = {
speakers: Speaker[];
loadingStatus: "loading" | "success" | "error";
error: string | undefined;
};
const initialState: SpeakerState = {
speakers: [],
loadingStatus: "loading",
error: undefined,
};
export default function SpeakerListPage() {
const [speakerState, setSpeakerState] = useState<SpeakerState>(initialState);
useEffect(() => {
async function loadSpeakers() {
try {
const response = await fetch("/api/speakers");
if (!response.ok) throw new Error("Failed to load speakers");
const data: Speaker[] = await response.json();
setSpeakerState({
speakers: data,
loadingStatus: "success",
error: undefined,
});
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : "Unknown error";
setSpeakerState((prev) => ({
...prev,
loadingStatus: "error",
error: errorMessage,
}));
}
}
loadSpeakers();
}, []); // Runs once on mount
if (speakerState.loadingStatus === "loading") return <div>Loading...</div>;
if (speakerState.loadingStatus === "error")
return <div>Error: {speakerState.error}</div>;
return (
<div className="container">
{speakerState.speakers.map((speaker) => (
<SpeakerCard key={speaker.id} speaker={speaker} />
))}
</div>
);
}
Best practice: Consolidate multiple independent but related
useStatecalls into a single state object to avoid inconsistent state combinations.
3.3 Handling Favorites with Optimistic UI
// SpeakerFavorite.tsx
"use client";
import { useState } from "react";
function SpeakerFavorite({ speaker }: { speaker: Speaker }) {
const [speakerLocal, setSpeakerLocal] = useState(speaker);
const [updating, setUpdating] = useState(false);
async function toggleFavorite() {
setUpdating(true); // Spinner visible immediately
const updatedSpeaker = {
...speakerLocal,
isFavorite: !speakerLocal.isFavorite,
};
// Optimistic update: update UI before server response
setSpeakerLocal(updatedSpeaker);
try {
const response = await fetch(`/api/speakers/${speakerLocal.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedSpeaker),
});
if (!response.ok) throw new Error("Update failed");
const confirmed: Speaker = await response.json();
setSpeakerLocal(confirmed);
} catch {
// Rollback on error
setSpeakerLocal(speakerLocal);
} finally {
setUpdating(false);
}
}
return (
<div onClick={toggleFavorite} style={{ cursor: "pointer" }}>
{updating ? (
<img src="/spinner.svg" alt="updating..." />
) : (
<img
src={speakerLocal.isFavorite ? "/heart-red.svg" : "/heart-black.svg"}
alt="favorite"
/>
)}
</div>
);
}
3.4 Prisma + SQLite Setup
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model Speaker {
id Int @id @default(autoincrement())
firstName String
lastName String
company String?
twitterHandle String?
bio String?
speakingAt DateTime?
isFavorite Boolean @default(false)
}
model Attendee {
id String @id @default(uuid())
email String @unique
firstName String?
lastName String?
createdAt DateTime @default(now())
}
3.5 REST API Route with Next.js App Router
// src/app/api/speakers/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getSpeakers, createSpeaker } from "@/lib/prisma/speaker-utils";
export async function GET() {
const speakers = await getSpeakers();
return NextResponse.json(speakers);
}
export async function POST(request: NextRequest) {
const body = await request.json();
const newSpeaker = await createSpeaker(body);
return NextResponse.json(newSpeaker, { status: 201 });
}
// src/app/api/speakers/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getSpeakerById, updateSpeaker, deleteSpeaker } from "@/lib/prisma/speaker-utils";
export async function GET(_: NextRequest, { params }: { params: { id: string } }) {
const speaker = await getSpeakerById(Number(params.id));
if (!speaker) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(speaker);
}
export async function PUT(request: NextRequest, { params }: { params: { id: string } }) {
const body = await request.json();
const updated = await updateSpeaker(Number(params.id), body);
return NextResponse.json(updated);
}
export async function DELETE(_: NextRequest, { params }: { params: { id: string } }) {
await deleteSpeaker(Number(params.id));
return new NextResponse(null, { status: 204 });
}
4. Leveraging React Context for Data and Forms
4.1 Why Combine States?
Problem: Three separate but interdependent useState calls create transient inconsistent states.
// ❌ Fragile approach: 3 separate states
const [speakers, setSpeakers] = useState<Speaker[]>([]);
const [loadingStatus, setLoadingStatus] = useState("loading");
const [error, setError] = useState<string | undefined>(undefined);
// ✅ Better approach: combined state
type SpeakerState = {
speakers: Speaker[];
loadingStatus: "loading" | "success" | "error";
error: string | undefined;
};
const [speakerState, setSpeakerState] = useState<SpeakerState>({
speakers: [],
loadingStatus: "loading",
error: undefined,
});
// Atomic update with spread operator
setSpeakerState((prev) => ({
...prev,
loadingStatus: "error",
error: "Error message",
}));
4.2 React Context Provider Architecture
flowchart TD
App["App (page.tsx)"]
Provider["SpeakerDataProvider<br/>(Context Provider)"]
SpeakerList["SpeakerList<br/>useContext(SpeakerDataContext)"]
SpeakerDetail["SpeakerDetail<br/>useContext(SpeakerDataContext)"]
SpeakerFav["SpeakerFavorite<br/>useContext(SpeakerDataContext)"]
App --> Provider
Provider --> SpeakerList
SpeakerList --> SpeakerDetail
SpeakerDetail --> SpeakerFav
style Provider fill:#4CAF50,color:#fff
4.3 Context Provider Implementation
// src/context/SpeakerDataContext.tsx
"use client";
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
type SpeakerState = {
speakers: Speaker[];
loadingStatus: "loading" | "success" | "error";
error: string | undefined;
};
type SpeakerDataContextProps = {
speakerState: SpeakerState;
updateSpeaker: (speaker: Speaker) => Promise<void>;
createSpeaker: (speaker: Omit<Speaker, "id">) => Promise<void>;
deleteSpeaker: (id: number) => Promise<void>;
};
const SpeakerDataContext = createContext<SpeakerDataContextProps | undefined>(undefined);
export function SpeakerDataProvider({ children }: { children: ReactNode }) {
const [speakerState, setSpeakerState] = useState<SpeakerState>({
speakers: [],
loadingStatus: "loading",
error: undefined,
});
useEffect(() => {
fetch("/api/speakers")
.then((r) => r.json())
.then((data) =>
setSpeakerState({ speakers: data, loadingStatus: "success", error: undefined })
)
.catch((err) =>
setSpeakerState((prev) => ({
...prev,
loadingStatus: "error",
error: err.message,
}))
);
}, []);
async function updateSpeaker(speaker: Speaker) {
const response = await fetch(`/api/speakers/${speaker.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(speaker),
});
const updated: Speaker = await response.json();
setSpeakerState((prev) => ({
...prev,
speakers: prev.speakers.map((s) => (s.id === updated.id ? updated : s)),
}));
}
async function createSpeaker(speaker: Omit<Speaker, "id">) {
const response = await fetch("/api/speakers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(speaker),
});
const created: Speaker = await response.json();
setSpeakerState((prev) => ({
...prev,
speakers: [...prev.speakers, created],
}));
}
async function deleteSpeaker(id: number) {
await fetch(`/api/speakers/${id}`, { method: "DELETE" });
setSpeakerState((prev) => ({
...prev,
speakers: prev.speakers.filter((s) => s.id !== id),
}));
}
return (
<SpeakerDataContext.Provider
value={{ speakerState, updateSpeaker, createSpeaker, deleteSpeaker }}
>
{children}
</SpeakerDataContext.Provider>
);
}
// Custom hook to consume the context
export function useSpeakerData() {
const context = useContext(SpeakerDataContext);
if (!context) {
throw new Error("useSpeakerData must be used inside SpeakerDataProvider");
}
return context;
}
4.4 Forms with the HTML <form> Element
// src/app/components/FooterSubscribe.tsx
"use client";
import { useState, useEffect } from "react";
export default function FooterSubscribe() {
const [email, setEmail] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [isButtonDisabled, setIsButtonDisabled] = useState(true);
// Validate email on every keystroke
useEffect(() => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
setIsButtonDisabled(!emailRegex.test(email));
}, [email]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setIsSubmitting(true);
try {
await fetch("/api/attendees", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, createdAt: new Date() }),
});
alert("Successfully subscribed!");
setEmail("");
} finally {
setIsSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit} className="d-flex gap-2">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Your email"
className="form-control"
/>
<button
type="submit"
disabled={isButtonDisabled || isSubmitting}
className="btn btn-primary"
>
{isSubmitting ? "Sending..." : "Subscribe"}
</button>
</form>
);
}
4.5 Multi-step Wizard
// Handling a two-step form
type Step = "STEP1" | "STEP2";
function SubscribeWizard() {
const [currentStep, setCurrentStep] = useState<Step>("STEP1");
const [email, setEmail] = useState("");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
async function handleStep1Submit(e: React.FormEvent) {
e.preventDefault();
// Save email immediately (don't lose this lead)
await fetch("/api/attendees", {
method: "POST",
body: JSON.stringify({ email }),
headers: { "Content-Type": "application/json" },
});
setCurrentStep("STEP2"); // Move to step 2
}
async function handleStep2Submit(e: React.FormEvent) {
e.preventDefault();
await fetch("/api/attendees/update-name", {
method: "PUT",
body: JSON.stringify({ email, firstName, lastName }),
headers: { "Content-Type": "application/json" },
});
alert("Registration complete!");
}
if (currentStep === "STEP1") {
return (
<form onSubmit={handleStep1Submit}>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<button type="submit">Subscribe</button>
</form>
);
}
return (
<form onSubmit={handleStep2Submit}>
<input value={firstName} onChange={(e) => setFirstName(e.target.value)} placeholder="First name" />
<input value={lastName} onChange={(e) => setLastName(e.target.value)} placeholder="Last name" />
<button type="submit">Update</button>
</form>
);
}
5. Using Suspense for Async Data Management
5.1 Suspense Principle
Suspense is a React feature that makes it easy to handle data from asynchronous sources. It reduces complexity by letting React manage the timing of requests.
flowchart LR
Parent["Parent Component<br/>Creates a Promise"] -->|"speakerPromise"| Boundary
subgraph Boundary["<Suspense fallback={<Loading/>}>"]
Child["SpeakerList<br/>const speakers = use(speakerPromise)"]
end
Boundary -->|"Promise pending"| Fallback["Shows fallback<br/><Loading/>"]
Boundary -->|"Promise resolved"| Render["Renders SpeakerList"]
5.2 State vs Suspense Comparison
| Criterion | useState + useEffect | Suspense + use() |
|---|---|---|
| Code complexity | More verbose | Simpler |
| Error handling | Manual try/catch | Error Boundary |
| Nesting | Hard to manage | Natural (nested boundaries) |
| Availability | Stable | use(): Canary (18.3) |
| Server Components | No | Yes (production-ready) |
5.3 Implementation with the use Hook
// Suspense pattern with the use hook (React 18.3 Canary)
"use client";
import { use, Suspense } from "react";
// Parent component: creates the promise
export default function SpeakersPage() {
const speakerPromise = fetchSpeakers(); // async function returning a Promise
return (
<Suspense fallback={<SpeakerListPending />}>
<SpeakerList speakerPromise={speakerPromise} />
</Suspense>
);
}
async function fetchSpeakers(): Promise<Speaker[]> {
const response = await fetch("/api/speakers");
return response.json();
}
// Child component: waits for the promise to resolve
function SpeakerList({ speakerPromise }: { speakerPromise: Promise<Speaker[]> }) {
// use() suspends rendering until the promise resolves
const speakers = use(speakerPromise);
return (
<div>
{speakers.map((speaker) => (
<SpeakerDetail key={speaker.id} speaker={speaker} />
))}
</div>
);
}
5.4 Error Handling with Error Boundary
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
export default function SpeakersPageWithErrors() {
const speakerPromise = fetchSpeakers();
return (
<ErrorBoundary fallback={<div>Error loading speakers</div>}>
<Suspense fallback={<SpeakerListPending />}>
<SpeakerList speakerPromise={speakerPromise} />
</Suspense>
</ErrorBoundary>
);
}
5.5 Nested Suspense Boundaries
// Nested Suspense: each component has its own boundary
function SpeakerList({ speakerPromise }: { speakerPromise: Promise<Speaker[]> }) {
const speakers = use(speakerPromise);
return (
<div>
{speakers.map((speaker) => (
<SpeakerDetail key={speaker.id} speaker={speaker} />
))}
</div>
);
}
function SpeakerDetail({ speaker }: { speaker: Speaker }) {
const favoriteCountPromise = fetchFavoriteCount(speaker.id);
return (
<div className="card">
<h3>{speaker.firstName} {speaker.lastName}</h3>
{/* Nested boundary for the favorite counter */}
<Suspense fallback={<span>...</span>}>
<SpeakerFavoriteCountDisplay promise={favoriteCountPromise} />
</Suspense>
</div>
);
}
function SpeakerFavoriteCountDisplay({ promise }: { promise: Promise<number> }) {
const count = use(promise); // Waits for its own promise
return <span>{count} favorites</span>;
}
5.6 Suspense with Server Components (production-ready)
// ✅ Production approach: Server Components + Suspense
// src/app/speakers/page.tsx (NO "use client" → Server Component)
import { Suspense } from "react";
import { SpeakerListPending } from "@/components/SpeakerListPending";
import { SpeakerListContainer } from "@/components/SpeakerListContainer";
export default function SpeakersPage() {
return (
<Suspense fallback={<SpeakerListPending />}>
<SpeakerListContainer />
</Suspense>
);
}
// SpeakerListContainer.tsx (async Server Component)
import { getSpeakers } from "@/lib/prisma/speaker-utils";
export async function SpeakerListContainer() {
// Direct Prisma/SQLite call, no REST needed
const speakers = await getSpeakers();
return (
<div>
{speakers.map((s) => (
<SpeakerDetail key={s.id} speaker={s} />
))}
</div>
);
}
5.7 Animated Loading with CSS (Skeleton UI)
// SpeakerListPending.tsx — gradient loading animation
function SpeakerDetailPending() {
return (
<div className="card speaker-card-pending">
<div className="bg-gradient-pending-text">
</div>
<div className="bg-gradient-pending-text mt-2" style={{ width: "60%" }}> </div>
<div className="bg-gradient-pending-text mt-1" style={{ width: "80%" }}> </div>
</div>
);
}
export function SpeakerListPending() {
return (
<div>
{[1, 2, 3, 4, 5].map((i) => (
<SpeakerDetailPending key={i} />
))}
</div>
);
}
/* site.css — CSS animation for skeleton */
.bg-gradient-pending-text {
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: loading-gradient 1.5s infinite;
border-radius: 4px;
height: 1rem;
}
@keyframes loading-gradient {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
6. Implementing Enterprise Features
6.1 Authentication with NextAuth.js
// pages/api/auth/[...nextauth].ts
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import prisma from "@/lib/prisma/prisma";
export default NextAuth({
providers: [
CredentialsProvider({
name: "Conference Demo App",
credentials: {
username: { label: "Email", type: "text", placeholder: "email@example.com" },
password: { label: "Password", type: "password", placeholder: "Not required for demo" },
},
async authorize(credentials) {
// Look up the user in SQLite via Prisma
const attendee = await prisma.attendee.findUnique({
where: { email: credentials?.username ?? "" },
});
if (!attendee) return null;
return { id: attendee.id, email: attendee.email };
},
}),
],
callbacks: {
async session({ session, token }) {
if (session.user) {
(session.user as any).id = token.sub;
}
return session;
},
},
});
6.2 Nested Contexts: Search + Speakers
flowchart TD
Page["speakers/page.tsx"]
SMP["SpeakerMenuProvider<br/>(searchText, setSearchText)"]
SDP["SpeakerDataProvider<br/>(speakers, CRUD methods)"]
Menu["SpeakerMenu<br/>useContext(SpeakerMenuContext)<br/>Search input"]
List["SpeakerList<br/>useContext(SpeakerMenuContext)<br/>useContext(SpeakerDataContext)<br/>Filters speakers"]
Page --> SDP
SDP --> SMP
SMP --> Menu
SMP --> List
style SMP fill:#2196F3,color:#fff
style SDP fill:#4CAF50,color:#fff
// src/context/SpeakerMenuContext.tsx
"use client";
import { createContext, useContext, useState, ReactNode } from "react";
type SpeakerMenuContextProps = {
searchText: string;
setSearchText: (text: string) => void;
};
const SpeakerMenuContext = createContext<SpeakerMenuContextProps | undefined>(undefined);
export function SpeakerMenuProvider({ children }: { children: ReactNode }) {
const [searchText, setSearchText] = useState("");
return (
<SpeakerMenuContext.Provider value={{ searchText, setSearchText }}>
{children}
</SpeakerMenuContext.Provider>
);
}
export function useSpeakerMenu() {
const ctx = useContext(SpeakerMenuContext);
if (!ctx) throw new Error("useSpeakerMenu used outside SpeakerMenuProvider");
return ctx;
}
6.3 CRUD Edit Modal
// Modal component to edit a speaker
"use client";
import { useState } from "react";
import { useSpeakerData } from "@/context/SpeakerDataContext";
type ModalMode = "edit" | "create" | "closed";
function SpeakerEditModal({ speaker, mode, onClose }: {
speaker?: Speaker;
mode: ModalMode;
onClose: () => void;
}) {
const { updateSpeaker, createSpeaker } = useSpeakerData();
const [formData, setFormData] = useState<Partial<Speaker>>(speaker ?? {});
const [isSaving, setIsSaving] = useState(false);
if (mode === "closed") return null;
async function handleSave() {
setIsSaving(true);
try {
if (mode === "edit" && speaker) {
await updateSpeaker({ ...speaker, ...formData } as Speaker);
} else {
await createSpeaker(formData as Omit<Speaker, "id">);
}
onClose();
} finally {
setIsSaving(false);
}
}
return (
<div className="modal show d-block" style={{ backgroundColor: "rgba(0,0,0,0.5)" }}>
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5>{mode === "edit" ? "Edit Speaker" : "Add Speaker"}</h5>
</div>
<div className="modal-body">
<input
value={formData.firstName ?? ""}
onChange={(e) => setFormData((prev) => ({ ...prev, firstName: e.target.value }))}
placeholder="First name"
className="form-control mb-2"
/>
<input
value={formData.lastName ?? ""}
onChange={(e) => setFormData((prev) => ({ ...prev, lastName: e.target.value }))}
placeholder="Last name"
className="form-control"
/>
</div>
<div className="modal-footer">
<button onClick={onClose} className="btn btn-secondary">Cancel</button>
<button onClick={handleSave} disabled={isSaving} className="btn btn-primary">
{isSaving ? (mode === "edit" ? "Saving..." : "Adding...") : "Save"}
</button>
</div>
</div>
</div>
</div>
);
}
7. Updating Data with Server Actions
7.1 What is a Server Action?
A Server Action is an async JavaScript function that runs on the Node.js server but can be called directly from a React client component, as if it were a simple local function.
flowchart LR
Client["⚛️ React Client<br/>Browser Component"]
Network["🌐 HTTP Network<br/>(abstracted by React)"]
Server["🖥️ Node Server<br/>Server Action"]
DB["💾 Database<br/>(Prisma + SQLite)"]
Client -- "Function call<br/>(serializable primitives)" --> Network
Network --> Server
Server --> DB
DB --> Server
Server -- "Serialized return" --> Network
Network --> Client
7.2 REST vs Server Actions Comparison
| Aspect | REST API | Server Actions |
|---|---|---|
| Protocol | HTTP + JSON | Abstracted by React |
| Serialization | Manual (JSON.stringify) | Automatic |
| Error handling | try/catch + HTTP codes | JavaScript try/catch |
| Boilerplate | Heavy | Minimal |
| Requires Node.js | No | Yes |
| Compatibility | Any server | Node.js only |
| Validation | Manual | Zod recommended |
7.3 Creating a Server Action in a Dedicated File
// src/app/server-action-example/page-server-action.ts
"use server"; // ← Required on the first line
import { z } from "zod";
import prisma from "@/lib/prisma/prisma";
// Zod validation schema
const AttendeeSchema = z.object({
email: z.string().email("Invalid email"),
firstName: z.string().min(2, "First name too short"),
lastName: z.string().min(3, "Last name too short"),
id: z.string().uuid().optional(),
}).strict(); // Rejects additional fields
type ActionState = {
message: string;
email: string;
firstName: string;
lastName: string;
};
export async function addAttendeeAction(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const rawData = {
email: formData.get("email") as string,
firstName: formData.get("firstName") as string,
lastName: formData.get("lastName") as string,
};
// Zod validation (runtime, server-side)
const result = AttendeeSchema.safeParse(rawData);
if (!result.success) {
const errors = result.error.issues.map((i) => i.message).join(", ");
return { ...prevState, message: `Validation error: ${errors}` };
}
try {
await prisma.attendee.create({
data: {
email: result.data.email,
firstName: result.data.firstName,
lastName: result.data.lastName,
},
});
// Return empty fields to clear the form
return {
message: `Successfully registered ${result.data.firstName}!`,
email: "",
firstName: "",
lastName: "",
};
} catch (error: unknown) {
if ((error as any)?.code === "P2002") {
// Unique constraint violation (email already exists)
return {
...prevState,
email: "",
message: "This email is already registered.",
};
}
return { ...prevState, message: "Server error, please try again." };
}
}
7.4 Form Using a Server Action
// src/app/server-action-example/page.tsx
"use client";
import { useFormState, useFormStatus } from "react-dom";
import { addAttendeeAction } from "./page-server-action";
const initialState = {
message: "",
email: "",
firstName: "",
lastName: "",
};
export default function AddAttendeePage() {
// useFormState binds the form state to the Server Action
const [state, formAction] = useFormState(addAttendeeAction, initialState);
return (
<div className="container mt-4">
<h2>New Attendee</h2>
{state.message && (
<div className="alert alert-info">{state.message}</div>
)}
<form action={formAction}>
<div className="mb-3">
<label htmlFor="firstName">First Name</label>
<input
id="firstName"
name="firstName"
defaultValue={state.firstName}
className="form-control"
required
/>
</div>
<div className="mb-3">
<label htmlFor="lastName">Last Name</label>
<input
id="lastName"
name="lastName"
defaultValue={state.lastName}
className="form-control"
required
/>
</div>
<div className="mb-3">
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
defaultValue={state.email}
className="form-control"
required
/>
</div>
<SubmitButton />
</form>
</div>
);
}
// Separate client component to access useFormStatus
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending} className="btn btn-primary">
{pending ? "Submitting..." : "Add"}
</button>
);
}
7.5 Directly Calling a Server Action from a Client Component
// Direct call (outside a form) with useTransition
"use client";
import { useState, useTransition } from "react";
import { checkEmailExistsAction } from "./page-server-action";
function EmailInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [emailExists, setEmailExists] = useState(false);
const [isPending, startTransition] = useTransition();
function handleBlur() {
// Direct Server Action call from the blur event
startTransition(async () => {
const exists = await checkEmailExistsAction(value);
setEmailExists(exists);
});
}
return (
<div>
<input
type="email"
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={handleBlur}
className="form-control"
/>
{isPending && <small className="text-muted">Checking...</small>}
{!isPending && emailExists && (
<small className="text-danger">⚠️ This email already exists.</small>
)}
</div>
);
}
7.6 Validation with Zod
import { z } from "zod";
// Schema for speakers
const SpeakerSchema = z.object({
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
company: z.string().optional(),
twitterHandle: z.string().startsWith("@").optional(),
bio: z.string().max(500, "Bio too long").optional(),
isFavorite: z.boolean().default(false),
});
// Usage in a Server Action
export async function updateSpeakerAction(prevState: any, formData: FormData) {
const raw = Object.fromEntries(formData);
const parsed = SpeakerSchema.safeParse(raw);
if (!parsed.success) {
return {
success: false,
errors: parsed.error.flatten().fieldErrors,
};
}
await prisma.speaker.update({
where: { id: Number(formData.get("id")) },
data: parsed.data,
});
return { success: true, errors: {} };
}
7.7 Complete Architecture: Server Components + Server Actions
flowchart TD
subgraph Server["🖥️ Node Server (Next.js)"]
SC["Server Component<br/>Direct Prisma/SQLite access<br/>(implicit GET)"]
SA["Server Actions<br/>addAttendeeAction<br/>updateSpeakerAction<br/>deleteAttendeeAction<br/>(implicit PUT/POST/DELETE)"]
Prisma["Prisma ORM"]
SQLite["SQLite"]
end
subgraph Client["🌐 Browser Client"]
CC["Client Components<br/>'use client'<br/>useState, useEffect, events"]
Forms["HTML Forms<br/>action={serverAction}"]
end
SC -- "Initial HTML render" --> CC
CC -- "startTransition / form action" --> SA
SA --> Prisma
SC --> Prisma
Prisma --> SQLite
style Server fill:#1a1a2e,color:#fff
style Client fill:#16213e,color:#fff
Golden Rule:
- Server Components = replacement for REST GET calls
- Server Actions = replacement for REST PUT / POST / DELETE calls
8. Final Summary
8.1 Key Course Takeaways
| Module | Key Concept | When to Use |
|---|---|---|
| M2 | useEffect + fetch | SPA apps without Server Components |
| M3 | Combined state, REST CRUD | Foundation of any data-driven app |
| M4 | React Context, forms | Sharing state between components |
| M5 | Suspense, Error Boundaries | Improving loading UX |
| M6 | Auth, modals, wizards | Enterprise features |
| M7 | Server Actions, Zod | REST replacement with Node.js |
8.2 Decision Tree: Which Approach to Choose?
flowchart TD
Q1{Do you have access\nto a Node.js server?}
Q2{Are you using\nNext.js App Router?}
Q3{Reading data\nor writing?}
R1["Pure SPA + REST API\nuseEffect + fetch"]
R2["Server Components\n+ Server Actions"]
R3["Server Components\n(direct Prisma read)"]
R4["Server Actions\n(Prisma write)"]
Q1 -- No --> R1
Q1 -- Yes --> Q2
Q2 -- No --> R1
Q2 -- Yes --> Q3
Q3 -- Read --> R3
Q3 -- Write --> R4
8.3 Best Practices
- Consolidate state: Combine related
useStatecalls into a single object to avoid inconsistent states. - Separate concerns: A component should not both fetch data AND render UI.
- Validate server-side: Always use Zod (or equivalent) in Server Actions — never trust client data.
- Serialization: Server Actions can only pass serializable primitive types (strings, numbers, booleans, simple arrays).
useTransition: Always wrap directly-called Server Actions (outside a form) instartTransitionto track thependingstate.- Server Components by default: In Next.js App Router, every component is a Server Component by default; only add
"use client"when necessary (events, hooks).
9. Reference Tables
9.1 Client-Side Data Fetching Libraries
| Library | Approach | Cache | Optimistic Updates | Size |
|---|---|---|---|---|
fetch + useEffect | Manual | No | Manual | 0 kb |
| SWR (Vercel) | Stale-While-Revalidate | Yes | Partial | ~4 kb |
| TanStack Query (React Query) | Server state management | Yes (advanced) | Yes | ~13 kb |
| Apollo Client | GraphQL | Yes | Yes | ~32 kb |
| Redux Toolkit Query | Integrated Redux | Yes | Yes | variable |
9.2 React Hooks for Data Management
| Hook | Availability | Primary Use |
|---|---|---|
useState | Stable | Local component state |
useEffect | Stable | Side effects, data fetching |
useContext | Stable | Consuming a Context |
useReducer | Stable | Complex state / actions |
useTransition | Stable (18+) | Mark non-urgent updates |
useDeferredValue | Stable (18+) | Defer a value update |
use | Canary (18.3) | Resolve a Promise in render |
useFormState | Canary / Next.js 14 | Bind Server Action to a form |
useFormStatus | Canary / Next.js 14 | Pending state of a parent form |
9.3 Loading Pattern Comparison
| Pattern | Code | Performance | Complexity | Production-ready |
|---|---|---|---|---|
useEffect + state | More verbose | Good | Low | ✅ |
Suspense + use() | Simple | Excellent | Medium | ⚠️ Canary |
| Server Components | Minimal | Excellent | Low | ✅ (Next.js) |
| SWR / TanStack Query | Declarative | Excellent | Low | ✅ |
9.4 Useful Zod Validations for Forms
import { z } from "zod";
// Common Zod validation examples
const examples = z.object({
// Email
email: z.string().email(),
// Required string with min/max length
firstName: z.string().min(2).max(50),
// Number
age: z.number().int().positive().max(120),
// Enum
role: z.enum(["admin", "user", "guest"]),
// Optional
bio: z.string().optional(),
// URL
website: z.string().url().optional(),
// UUID (for IDs)
id: z.string().uuid(),
// Date
createdAt: z.date(),
// Boolean
isFavorite: z.boolean().default(false),
});
10. Architecture Diagrams
10.1 Data Flow: Fetch, Cache and Optimistic Updates
sequenceDiagram
participant UI as 🖥️ React UI
participant Cache as 📦 Local State / Cache
participant Server as 🌐 REST / Server Action
participant DB as 💾 Database
Note over UI,Cache: Initial load
UI->>Server: GET /api/speakers
Server->>DB: SELECT
DB-->>Server: rows
Server-->>Cache: speakers[]
Cache-->>UI: Render list
Note over UI,DB: Optimistic Update
UI->>Cache: setLocalState(updated) ← immediate
UI-->>UI: Optimistic re-render
UI->>Server: PUT /api/speakers/:id
Server->>DB: UPDATE
DB-->>Server: OK / Error
alt Success
Server-->>Cache: Confirm updated
else Error
Server-->>Cache: Rollback to previous state
Cache-->>UI: Rollback re-render
end
10.2 SWR Architecture (Stale-While-Revalidate)
flowchart LR
subgraph Client["Browser"]
Request["Data request"]
SWR["SWR Cache Layer"]
UI["React Component"]
end
subgraph Server["Server"]
API["REST API / Server Action"]
end
Request --> SWR
SWR -->|"Cache available (stale)"| UI
SWR -->|"Background revalidation"| API
API -->|"Fresh data"| SWR
SWR -->|"Update if data differs"| UI
style SWR fill:#FF9800,color:#fff
10.3 TanStack Query (React Query) — Architecture
flowchart TD
subgraph QueryClient["QueryClient (Singleton)"]
QCache["Query Cache<br/>{queryKey: data, status, staleTime}"]
end
subgraph Components["React Components"]
C1["useQuery('speakers', fetchSpeakers)"]
C2["useMutation(updateSpeaker)"]
C3["useInfiniteQuery('speakers', ...)"]
end
subgraph Server["Backend"]
API["REST API / Server Action"]
end
C1 -- "Read" --> QCache
C2 -- "Write + invalidation" --> QCache
QCache -- "Fetch if stale" --> API
API -- "Fresh data" --> QCache
QCache -- "Update" --> C1
QCache -- "Update" --> C3
10.4 Complete Server Components + Server Actions Pattern (Next.js 14)
flowchart TB
URL["🌐 URL Request<br/>localhost:3000/speakers"] --> Page
subgraph NextServer["Next.js Server"]
Page["page.tsx<br/>(Server Component)"]
Container["SpeakerListContainer<br/>(async Server Component)"]
Prisma["prisma.speaker.findMany()"]
SA["Server Actions<br/>speaker-data-context-actions.ts<br/>'use server'"]
end
subgraph Browser["Browser (hydration)"]
ClientCC["Client Components<br/>'use client'<br/>SpeakerFavorite, SearchBar, Modal"]
Forms["Forms<br/>action={serverAction}"]
end
Page --> Container
Container --> Prisma
Prisma --> Container
Container --> Browser
Forms --> SA
SA --> Prisma
ClientCC --> Forms
style NextServer fill:#0a0a0a,color:#fff
style Browser fill:#1a237e,color:#fff
Search Terms
data · react · typescript · frontend · development · server · architecture · suspense · action · actions · comparison · components · context · forms · loading · next.js · pattern · app · fetch · form · handling · lifecycle · management · nested