Intermediate

Working with Data in React

Core data patterns, Context for data and forms, Suspense, enterprise features and Server Actions.

GitHub Repo: pkellner/pluralsight-react-working-with-data
Stack: React 18 · Next.js 14 · TypeScript · SQLite · Prisma · NextAuth.js · Zod


Table of Contents

  1. Course Overview
  2. Understanding Core Data Patterns
  3. Working with Data in a Pure SPA (Client-only)
  4. Leveraging React Context for Data and Forms
  5. Using Suspense for Async Data Management
  6. Implementing Enterprise Features
  7. Updating Data with Server Actions
  8. Final Summary
  9. Reference Tables
  10. 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:

  1. React performs an initial render with animated placeholders (loading state)
  2. useEffect triggers a fetch to the REST server
  3. The server responds with JSON data
  4. 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

SourceExamplesReact Handling
UI BrowserClick, scroll, keypressEvent handlers (onClick, onChange)
External resourceServer response, WebSocketuseEffect + 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 useState calls 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

CriterionuseState + useEffectSuspense + use()
Code complexityMore verboseSimpler
Error handlingManual try/catchError Boundary
NestingHard to manageNatural (nested boundaries)
AvailabilityStableuse(): Canary (18.3)
Server ComponentsNoYes (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">
        &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
      </div>
      <div className="bg-gradient-pending-text mt-2" style={{ width: "60%" }}>&nbsp;</div>
      <div className="bg-gradient-pending-text mt-1" style={{ width: "80%" }}>&nbsp;</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

AspectREST APIServer Actions
ProtocolHTTP + JSONAbstracted by React
SerializationManual (JSON.stringify)Automatic
Error handlingtry/catch + HTTP codesJavaScript try/catch
BoilerplateHeavyMinimal
Requires Node.jsNoYes
CompatibilityAny serverNode.js only
ValidationManualZod 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

ModuleKey ConceptWhen to Use
M2useEffect + fetchSPA apps without Server Components
M3Combined state, REST CRUDFoundation of any data-driven app
M4React Context, formsSharing state between components
M5Suspense, Error BoundariesImproving loading UX
M6Auth, modals, wizardsEnterprise features
M7Server Actions, ZodREST 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

  1. Consolidate state: Combine related useState calls into a single object to avoid inconsistent states.
  2. Separate concerns: A component should not both fetch data AND render UI.
  3. Validate server-side: Always use Zod (or equivalent) in Server Actions — never trust client data.
  4. Serialization: Server Actions can only pass serializable primitive types (strings, numbers, booleans, simple arrays).
  5. useTransition: Always wrap directly-called Server Actions (outside a form) in startTransition to track the pending state.
  6. 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

LibraryApproachCacheOptimistic UpdatesSize
fetch + useEffectManualNoManual0 kb
SWR (Vercel)Stale-While-RevalidateYesPartial~4 kb
TanStack Query (React Query)Server state managementYes (advanced)Yes~13 kb
Apollo ClientGraphQLYesYes~32 kb
Redux Toolkit QueryIntegrated ReduxYesYesvariable

9.2 React Hooks for Data Management

HookAvailabilityPrimary Use
useStateStableLocal component state
useEffectStableSide effects, data fetching
useContextStableConsuming a Context
useReducerStableComplex state / actions
useTransitionStable (18+)Mark non-urgent updates
useDeferredValueStable (18+)Defer a value update
useCanary (18.3)Resolve a Promise in render
useFormStateCanary / Next.js 14Bind Server Action to a form
useFormStatusCanary / Next.js 14Pending state of a parent form

9.3 Loading Pattern Comparison

PatternCodePerformanceComplexityProduction-ready
useEffect + stateMore verboseGoodLow
Suspense + use()SimpleExcellentMedium⚠️ Canary
Server ComponentsMinimalExcellentLow✅ (Next.js)
SWR / TanStack QueryDeclarativeExcellentLow

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

Interested in this course?

Contact us to book it or get a custom training plan for your team.