Doing React the Right Way - 9/10

React 19: The Evolution of Data Handling

React’s evolution continues with version 19, introducing powerful new patterns that simplify common development tasks. These additions aren’t just syntactic sugar—they represent fundamental improvements to how we handle asynchronous data, forms, and user experience optimization.

The patterns you’ve learned for managing loading states, form submissions, and optimistic updates are being refined and streamlined. React 19 provides built-in solutions for challenges that previously required custom implementations or third-party libraries.

Let’s explore these new capabilities and understand how they improve both developer experience and application performance.

The use Hook: Unified Resource Reading

For years, consuming asynchronous data in React has required ceremonial boilerplate: separate state variables for data, loading, and error states, orchestrated by useEffect. The use hook fundamentally changes this pattern.

The use hook provides a unified interface for reading values from resources—whether they’re Promises (async data) or Context values. Unlike other hooks, use can be called conditionally and within loops, providing more flexible composition patterns.

Transforming Data Fetching

Here’s how use simplifies asynchronous data consumption:

Traditional Approach:

"use client";
import { useState, useEffect } from "react";

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;

    async function fetchUser() {
      try {
        setLoading(true);
        setError(null);
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) throw new Error("Failed to fetch user");
        const userData = await response.json();

        if (!cancelled) {
          setUser(userData);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err.message);
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    }

    fetchUser();
    return () => {
      cancelled = true;
    };
  }, [userId]);

  if (loading) return <div>Loading user...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!user) return <div>User not found</div>;

  return <div>Welcome, {user.name}!</div>;
}

Modern Approach with use:

"use client";
import { use, Suspense } from "react";

function UserProfile({ userId }: { userId: string }) {
  // use() unwraps the Promise and integrates with Suspense
  const user = use(
    fetch(`/api/users/${userId}`).then((res) => {
      if (!res.ok) throw new Error("Failed to fetch user");
      return res.json();
    })
  );

  return <div>Welcome, {user.name}!</div>;
}

// Parent component handles loading and error states declaratively
function UserPage({ userId }: { userId: string }) {
  return (
    <ErrorBoundary fallback={<div>Failed to load user</div>}>
      <Suspense fallback={<div>Loading user...</div>}>
        <UserProfile userId={userId} />
      </Suspense>
    </ErrorBoundary>
  );
}

How use Integrates with Suspense

The use hook works by leveraging React’s Suspense mechanism:

  1. Promise Pending: When use receives a pending Promise, it throws the Promise as an exception
  2. Suspense Catches: The nearest <Suspense> boundary catches this “thrown Promise” and renders its fallback UI
  3. Promise Resolution: When the Promise resolves, React re-renders the component, and use returns the resolved value
  4. Error Handling: If the Promise rejects, use throws the error, which can be caught by Error Boundaries

This integration eliminates manual state management while providing better composition and error boundaries.

Important: Promise Caching

A critical consideration when using use is Promise identity. Creating new Promises on every render will cause infinite re-rendering:

// ❌ Creates new Promise on every render - will loop infinitely
function BadExample({ userId }: { userId: string }) {
  const user = use(
    fetch(`/api/users/${userId}`).then((res) => res.json()) // New Promise every time!
  );
  return <div>{user.name}</div>;
}

// ✅ Proper approach with memoized Promise
function GoodExample({ userId }: { userId: string }) {
  const userPromise = useMemo(
    () => fetch(`/api/users/${userId}`).then((res) => res.json()),
    [userId]
  );

  const user = use(userPromise);
  return <div>{user.name}</div>;
}

In practice, you’ll typically use use with data fetching libraries that handle Promise caching automatically, or with Server Components where the Promise is created once during server rendering.

Actions and Form Handling Revolution

React 19 introduces Actions—a new paradigm for handling data mutations that deeply integrates with HTML forms. Actions eliminate much of the boilerplate associated with form state management while providing better user experience patterns.

Understanding Actions

An Action is an async function that you can pass directly to a <form> element’s action prop. React manages the entire submission lifecycle, including loading states and error handling.

useActionState: Comprehensive Form Management

The useActionState hook replaces multiple useState calls with a single, coherent state management pattern:

"use client";
import { useActionState } from "react";

interface FormState {
  message: string;
  errors?: Record<string, string>;
}

async function createUserAction(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;

  // Validation
  const errors: Record<string, string> = {};
  if (!name?.trim()) errors.name = "Name is required";
  if (!email?.trim()) errors.email = "Email is required";
  if (email && !email.includes("@")) errors.email = "Invalid email format";

  if (Object.keys(errors).length > 0) {
    return { message: "", errors };
  }

  try {
    // Simulate API call
    await new Promise((resolve) => setTimeout(resolve, 1000));

    // Simulate potential server error
    if (email === "error@example.com") {
      throw new Error("Server error occurred");
    }

    return { message: "User created successfully!" };
  } catch (error) {
    return {
      message: "Failed to create user: " + (error as Error).message,
    };
  }
}

export function UserCreateForm() {
  const [state, formAction, isPending] = useActionState(createUserAction, {
    message: "",
  });

  return (
    <form action={formAction}>
      <h2>Create New User</h2>

      <div>
        <label htmlFor="name">Name:</label>
        <input id="name" name="name" type="text" required />
        {state.errors?.name && (
          <span className="error">{state.errors.name}</span>
        )}
      </div>

      <div>
        <label htmlFor="email">Email:</label>
        <input id="email" name="email" type="email" required />
        {state.errors?.email && (
          <span className="error">{state.errors.email}</span>
        )}
      </div>

      <button type="submit" disabled={isPending}>
        {isPending ? "Creating User..." : "Create User"}
      </button>

      {state.message && (
        <div className={state.errors ? "error" : "success"}>
          {state.message}
        </div>
      )}
    </form>
  );
}

useFormStatus: Distributed Form State

The useFormStatus hook allows any component within a form to access the form’s submission status, eliminating the need to pass loading states through props:

"use client";
import { useFormStatus } from "react-dom";

function SubmitButton() {
  const { pending, data } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? (
        <>
          <Spinner size="sm" />
          Submitting...
        </>
      ) : (
        "Submit Form"
      )}
    </button>
  );
}

function FormWithDistributedStatus() {
  const [state, formAction] = useActionState(submitAction, { message: "" });

  return (
    <form action={formAction}>
      <input name="data" type="text" required />

      {/* This button automatically knows the form's status */}
      <SubmitButton />

      {state.message && <div>{state.message}</div>}
    </form>
  );
}

This pattern is particularly powerful for complex forms with multiple sections, each potentially needing to display loading states or submission feedback.

Progressive Enhancement

Actions work with standard HTML form behavior, providing excellent progressive enhancement. Even without JavaScript, forms will submit to the server using traditional POST requests:

// This form works without JavaScript and enhances with React
function ProgressiveForm() {
  const [state, formAction] = useActionState(serverAction, { message: "" });

  return (
    <form action={formAction}>
      {/* Standard HTML form elements */}
      <input name="email" type="email" required />
      <button type="submit">Subscribe</button>

      {/* Enhanced feedback with JavaScript */}
      {state.message && <div>{state.message}</div>}
    </form>
  );
}

useOptimistic: Immediate UI Updates

Modern applications require instant feedback for user actions. The useOptimistic hook provides a safe way to implement optimistic updates—immediately updating the UI while the actual operation completes in the background.

Optimistic Update Pattern

"use client";
import { useOptimistic, useState, startTransition } from "react";

interface Message {
  id: string;
  text: string;
  status: "sent" | "pending";
}

async function sendMessage(text: string): Promise<Message> {
  // Simulate network delay
  await new Promise((resolve) => setTimeout(resolve, 2000));

  return {
    id: Date.now().toString(),
    text,
    status: "sent" as const,
  };
}

function ChatInterface() {
  const [messages, setMessages] = useState<Message[]>([]);

  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (currentMessages: Message[], newMessage: string): Message[] => [
      ...currentMessages,
      {
        id: `temp-${Date.now()}`,
        text: newMessage,
        status: "pending",
      },
    ]
  );

  const handleSendMessage = async (formData: FormData) => {
    const messageText = formData.get("message") as string;
    if (!messageText.trim()) return;

    // Immediately add optimistic message
    addOptimisticMessage(messageText);

    try {
      // Send actual message
      const newMessage = await sendMessage(messageText);

      // Update with real message (React will merge automatically)
      setMessages((prev) => [...prev, newMessage]);
    } catch (error) {
      // On error, React automatically reverts the optimistic update
      console.error("Failed to send message:", error);
    }
  };

  return (
    <div className="chat-interface">
      <div className="messages">
        {optimisticMessages.map((message) => (
          <div
            key={message.id}
            className={`message ${
              message.status === "pending" ? "pending" : ""
            }`}
          >
            {message.text}
            {message.status === "pending" && (
              <span className="status">Sending...</span>
            )}
          </div>
        ))}
      </div>

      <form action={handleSendMessage}>
        <input
          name="message"
          type="text"
          placeholder="Type a message..."
          required
        />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

Automatic Rollback

The power of useOptimistic lies in its automatic error handling. If the server action fails, React automatically reverts the optimistic update, removing the pending message from the UI without requiring manual error state management.

Integration with Actions

useOptimistic works seamlessly with Actions for comprehensive form handling with immediate feedback:

function OptimisticForm() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (current, newTodo: string) => [
      ...current,
      { id: Date.now(), text: newTodo, completed: false },
    ]
  );

  const [state, formAction] = useActionState(
    async (prevState: any, formData: FormData) => {
      const todoText = formData.get("todo") as string;

      // Add optimistic update
      addOptimisticTodo(todoText);

      try {
        const newTodo = await createTodo(todoText);
        setTodos((prev) => [...prev, newTodo]);
        return { message: "Todo added successfully" };
      } catch (error) {
        return { message: "Failed to add todo" };
      }
    },
    { message: "" }
  );

  return (
    <div>
      <ul>
        {optimisticTodos.map((todo) => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>

      <form action={formAction}>
        <input name="todo" placeholder="Add todo..." required />
        <button type="submit">Add Todo</button>
      </form>
    </div>
  );
}

Architectural Impact

These React 19 features represent more than API improvements—they establish new architectural patterns:

Simplified State Management: Less manual state orchestration for common patterns like data fetching and form handling.

Better Error Boundaries: Clearer separation between different types of errors (network, validation, server) through Suspense and Error Boundaries.

Progressive Enhancement: Forms and interactions work without JavaScript and enhance with React capabilities.

Optimistic UX: Built-in support for immediate feedback patterns that improve perceived performance.

Server Integration: Better alignment with Server Components and server-side form processing.

Migration Strategy

When adopting React 19 features:

  1. Start with New Forms: Use Actions for new form implementations rather than retrofitting existing forms.

  2. Gradual use Adoption: Replace data fetching patterns incrementally, focusing on components with complex loading state management.

  3. Identify Optimistic Opportunities: Look for user actions that would benefit from immediate feedback (likes, comments, quick edits).

  4. Leverage Suspense: Use React 19 as an opportunity to implement proper loading boundaries throughout your application.

The Evolution Continues

React 19’s features represent a maturation of patterns the community has been developing for years. Instead of requiring third-party solutions or extensive boilerplate, React now provides built-in, optimized implementations for common data handling patterns.

These improvements don’t replace existing patterns overnight, but they offer cleaner, more maintainable approaches for new development. Understanding and adopting these patterns positions your applications for better performance, user experience, and developer productivity.

In our final article, we’ll explore application architecture and routing patterns that tie together all the concepts we’ve covered, providing a complete foundation for modern React development.