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:
- Promise Pending: When
use
receives a pending Promise, it throws the Promise as an exception - Suspense Catches: The nearest
<Suspense>
boundary catches this “thrown Promise” and renders its fallback UI - Promise Resolution: When the Promise resolves, React re-renders the component, and
use
returns the resolved value - 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:
Start with New Forms: Use Actions for new form implementations rather than retrofitting existing forms.
Gradual
use
Adoption: Replace data fetching patterns incrementally, focusing on components with complex loading state management.Identify Optimistic Opportunities: Look for user actions that would benefit from immediate feedback (likes, comments, quick edits).
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.