Week 10

Refs

Custom hooks

React 19

React Server Components

Practice

Assignment

Front end Track

React 19

React 19 landed in December 2024 and is the most significant React release in years. It doesn't change how components or hooks fundamentally work โ€” everything you've learned still applies. What it does is introduce new primitives that simplify patterns you've already been writing, and lay the groundwork for how React applications will be built going forward.

This chapter walks through the highlights. You'll encounter some of these patterns immediately in Next.js projects, and you'll see others become more common as the ecosystem catches up.

<aside> ๐Ÿ’ก

Info

React 18 is still widely used in production codebases. The concepts here are new and not yet applied everywhere. As you work on existing projects, you'll often be in React 18 and writing the patterns from previous weeks. React 19 should be the standard, but not necessarily where every codebase is today.

</aside>

What Changed in React 19

https://www.youtube.com/watch?v=rwC7HY8_U_g

The headline additions are:

The underlying theme is that React 19 makes async interactions โ€” loading data, submitting forms, updating the server โ€” much less ceremonious. The patterns you've been writing with useState + useEffect + manual loading states are still valid, but many of them now have a more direct way to express them.

The use Hook

use is a new hook with an unusual property: unlike every other hook, it can be called conditionally. You can put it inside an if statement, a loop, or after an early return.

It does two things:

Reading a Promise

When you pass a Promise to use, React suspends the component until the Promise resolves, then continues rendering with the result. The nearest <Suspense> boundary shows its fallback while the component is suspended.

import { use, Suspense } from 'react';

interface User {
  id: number;
  name: string;
  email: string;
}

// The promise is created outside and passed in as a prop
function UserCard({ userPromise }: { userPromise: Promise<User> }) {
  // use() unwraps the promise โ€” the component suspends until it resolves
  const user = use(userPromise);

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

function ProfilePage({ userId }: { userId: string }) {
  // Create the promise at the render level โ€” not inside an effect
  const userPromise = fetch(`/api/users/${userId}`).then((r) => r.json());

  return (
    <Suspense fallback={<p>Loading profile...</p>}>
      <UserCard userPromise={userPromise} />
    </Suspense>
  );
}

Compare this to the useEffect approach from week 6: no useState for loading or error, no effect, no cleanup function. The data loading is expressed directly in the component tree.

The Promise is created in the parent and passed as a prop โ€” this is intentional. If you created the Promise inside UserCard itself, it would be recreated on every render, causing an infinite loop of fetching. Creating it in the parent or at the module level keeps it stable.

Reading Context Conditionally

use can also replace useContext in situations where you need to read context conditionally โ€” something that wasn't possible before React 19:

import { use } from 'react';
import { ThemeContext } from './ThemeContext';

function Panel({ showTheme }: { showTheme: boolean }) {
  // This is now valid โ€” use() can be called inside a condition
  if (showTheme) {
    const theme = use(ThemeContext);
    return <div style={{ background: theme.background }}>Content</div>;
  }

  return <div>Content</div>;
}

For straightforward context reading where you don't need the conditional flexibility, useContext is still perfectly fine and arguably clearer.

useTransition and isPending

useTransition lets you mark a state update as non-urgent. React processes urgent updates โ€” typing in an input, clicking a button โ€” immediately, and defers the transition until after. This keeps the UI responsive during expensive re-renders.

You've already seen useTransition available in React 18, but React 19 extends it to support async functions directly. This is the bigger change.

Async Transitions

In React 18, startTransition only accepted synchronous functions. In React 19, you can pass an async function โ€” React will keep isPending true for the entire duration of the async operation:

import { useState, useTransition } from 'react';

function PublishButton({ articleId }: { articleId: string }) {
  const [isPending, startTransition] = useTransition();

  const handlePublish = () => {
    startTransition(async () => {
      // isPending is true for the entire duration of this async operation
      await fetch(`/api/articles/${articleId}/publish`, { method: 'POST' });
    });
  };

  return (
    <button onClick={handlePublish} disabled={isPending}>
      {isPending ? 'Publishing...' : 'Publish'}
    </button>
  );
}

Before React 19, you'd manage this with a isLoading state variable, setting it to true before the fetch and false after. useTransition replaces that pattern with isPending, which React manages for you automatically.

Keeping the UI Responsive During Heavy Updates

Transitions are also useful for expensive synchronous updates โ€” filtering a large list, switching between complex views โ€” where you want the input to feel instant while the result catches up:

import { useState, useTransition } from 'react';

function SearchableProductList({ products }: { products: Product[] }) {
  const [query, setQuery] = useState('');
  const [filtered, setFiltered] = useState(products);
  const [isPending, startTransition] = useTransition();

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    // Urgent: update the input immediately
    setQuery(e.target.value);

    // Non-urgent: filtering can wait
    startTransition(() => {
      setFiltered(
        products.filter((p) =>
          p.name.toLowerCase().includes(e.target.value.toLowerCase())
        )
      );
    });
  };

  return (
    <>
      <input value={query} onChange={handleChange} placeholder="Search..." />
      {isPending && <p>Updating results...</p>}
      <ul>
        {filtered.map((p) => <li key={p.id}>{p.name}</li>)}
      </ul>
    </>
  );
}

The input stays responsive on every keystroke. The list updates when React has time. isPending gives you a way to show feedback in between.

useOptimistic

useOptimistic handles a specific but very common UX pattern: you want the UI to update immediately when the user does something, without waiting for the server to confirm it. If the server responds with an error, the UI rolls back automatically.

Think of liking a post on social media. The like count jumps immediately when you click โ€” it doesn't wait for a network round trip. But if the request fails, it reverts. That's optimistic UI.

import { useOptimistic } from 'react';

interface Comment {
  id: number;
  text: string;
  author: string;
}

function CommentSection({
  postId,
  initialComments,
}: {
  postId: string;
  initialComments: Comment[];
}) {
  const [comments, setComments] = useState(initialComments);

  const [optimisticComments, addOptimisticComment] = useOptimistic(
    comments,
    // This function describes how to update the state optimistically
    (currentComments, newComment: Comment) => [
      ...currentComments,
      newComment,
    ]
  );

  const handleSubmit = async (text: string) => {
    const tempComment: Comment = {
      id: Date.now(), // temporary ID
      text,
      author: 'You',
    };

    // Show the comment immediately โ€” before the server responds
    addOptimisticComment(tempComment);

    // Send to the server
    const saved = await fetch(`/api/posts/${postId}/comments`, {
      method: 'POST',
      body: JSON.stringify({ text }),
    }).then((r) => r.json());

    // Update with the real data from the server
    setComments((prev) => [...prev, saved]);
  };

  return (
    <ul>
      {optimisticComments.map((comment) => (
        <li key={comment.id}>
          <strong>{comment.author}</strong>: {comment.text}
        </li>
      ))}
    </ul>
  );
}

If the fetch fails, React automatically reverts optimisticComments back to comments. The user sees the comment disappear โ€” which is the correct behaviour when something goes wrong. You get this rollback for free without any error-handling code in your component.

When to Reach for These vs What You Already Know

These new primitives are genuinely useful, but they're not replacements for everything you've learned. Here's a rough guide:

Keep using useState + useEffect + useFetch when you're fetching data in a client component that owns its own loading state. This pattern is well understood, works in both React 18 and 19, and is completely fine.

Reach for use with <Suspense> when you're working in a Next.js App Router project and passing Promises from Server Components to Client Components. This is where the pattern feels most natural and where the ergonomics improve the most over useEffect.

Reach for useTransition when a state update is noticeably slow โ€” filtering a large dataset, navigating between complex views โ€” and you want the UI to stay responsive. Also useful for any async operation where you want isPending without managing a loading state yourself.

Reach for useOptimistic when you want an action to feel instant โ€” liking, following, adding to a cart, marking as done โ€” and you're comfortable with the UI reverting if the server disagrees.

If you're working in a React 18 codebase, none of the React 19 APIs are available. The patterns from previous weeks are what you'll use. The concepts here are worth understanding because they inform how the ecosystem is moving โ€” and because you'll encounter them increasingly in documentation, tutorials, and new projects going forward.

Additional Resources

Videos

Reading


The HackYourFuture curriculum is licensed underย CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

CC BY-NC-SA 4.0 Icons

Built with โค๏ธ by the HackYourFuture community ยท Thank you, contributors

Found a mistake or have a suggestion? Let us know in the feedback form.