React 19 Stable: 5 Features You Must Start Using Now

By LearnWebCraft Team8 min readIntermediate
react 19react hooksreact compileractions apiweb development

React 19 went stable on December 5, 2024, and I'm just going to say it: this is the biggest shift in how we write React since Hooks dropped back in 2019.

I've been using React 19 in production for the past month, and there are 5 features that have completely changed how I build apps. Not "oh that's nice" changes. I'm talking about "I can't imagine going back" changes.

Let me show you what I mean.

1. Actions: The Death of Manual Form Handling

This is the big one. Actions fundamentally change how you handle async operations in React, and once you start using them, you'll wonder how you ever lived without them.

The Old Way (React 18)

Remember this nightmare?

function TodoForm() {
  const [isPending, setIsPending] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit(e: FormEvent) {
    e.preventDefault();
    setIsPending(true);
    setError(null);

    try {
      const formData = new FormData(e.currentTarget);
      await createTodo(formData);
      // Reset form, show success, etc.
    } catch (err) {
      setError(err.message);
    } finally {
      setIsPending(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" />
      <button disabled={isPending}>
        {isPending ? 'Creating...' : 'Create Todo'}
      </button>
      {error && <p className="error">{error}</p>}
    </form>
  );
}

Boilerplate city. Loading states, error handling, try-catch blocks everywhere. And if you forgot to set isPending back to false? Your button stays disabled forever.

The New Way (React 19 Actions)

function TodoForm() {
  const [error, submitAction, isPending] = useActionState(
    async (prevState, formData) => {
      try {
        await createTodo(formData);
        return null; // Success
      } catch (err) {
        return err.message; // Error
      }
    },
    null // Initial state
  );

  return (
    <form action={submitAction}>
      <input name="title" />
      <button disabled={isPending}>
        {isPending ? 'Creating...' : 'Create Todo'}
      </button>
      {error && <p className="error">{error}</p>}
    </form>
  );
}

Look at that. No manual isPending state. No manual error handling. No preventDefault(). It just... works.

Actions handle pending states, errors, and sequential requests automatically. This is huge.

2. useOptimistic: Make Your UI Feel Instant

Ever built a "like" button that feels laggy? useOptimistic fixes that, and it's honestly magical.

The Problem

// Old way: UI waits for server
async function handleLike() {
  setIsLiking(true);
  await likePost(postId); // User waits here 😴
  setLikes(likes + 1);
  setIsLiking(false);
}

The UI freezes while waiting for the server. Even if it's only 200ms, it feels slow.

The Solution

function LikeButton({ postId, initialLikes }) {
  const [likes, setLikes] = useState(initialLikes);
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    likes,
    (currentLikes, amount) => currentLikes + amount
  );

  async function handleLike() {
    addOptimisticLike(1); // UI updates INSTANTLY
    
    try {
      const newLikes = await likePost(postId);
      setLikes(newLikes); // Sync with server
    } catch {
      // Automatically rolls back on error
    }
  }

  return (
    <button onClick={handleLike}>
      ❤️ {optimisticLikes}
    </button>
  );
}

The UI updates immediately. No waiting. And if the server request fails? It automatically rolls back. It's like magic, except it's just good engineering.

I've used this for likes, follows, bookmarks, shopping cart updates—anywhere you want that instant feedback. The UX improvement is night and day.

3. React Compiler: The End of Manual Memoization

This is the feature that made me audibly say "holy shit" when I first saw it work.

The Old Way

// You had to manually memoize EVERYTHING
const MemoizedChild = memo(ExpensiveChild);

function Parent({ data }) {
  const processedData = useMemo(() => {
    return data.map(item => ({ ...item, processed: true }));
  }, [data]);

  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []);

  return <MemoizedChild data={processedData} onClick={handleClick} />;
}

Exhausting. And if you forgot one useMemo? Performance tank.

The New Way (React Compiler)

// Just write normal code
function Parent({ data }) {
  const processedData = data.map(item => ({ ...item, processed: true }));

  const handleClick = () => {
    console.log('clicked');
  };

  return <ExpensiveChild data={processedData} onClick={handleClick} />;
}

The compiler automatically:

  • Memoizes components
  • Memoizes expensive computations
  • Memoizes callbacks
  • Only re-renders when necessary

No more useMemo, useCallback, or memo. The compiler does it all.

Real-World Impact

I removed 127 lines of memoization code from a production app. Performance stayed the same (actually improved slightly). Code became way more readable.

That's the dream, right there.

4. use(): Read Promises Directly in Render

This one's subtle but powerful.

The Old Pattern

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

  if (!user) return <Loading />;

  return <div>{user.name}</div>;
}

The New Pattern

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

// Parent component
function App() {
  const userPromise = fetchUser(userId);
  
  return (
    <Suspense fallback={<Loading />}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  );
}

Why this is better:

  • No useEffect needed
  • Works seamlessly with Suspense
  • Easier to compose and test
  • Better TypeScript inference

It's cleaner. It's more declarative. It just feels right.

5. Server Components (Now Production-Ready)

Server Components were experimental in React 18. In React 19, they're production-ready and integrated deeply with frameworks like Next.js.

What Are Server Components?

Components that run only on the server, never shipped to the client.

// app/dashboard/page.tsx (Server Component)
async function DashboardPage() {
  // This runs on the SERVER
  const user = await db.user.findUnique({ where: { id: userId } });
  const posts = await db.post.findMany({ where: { authorId: userId } });

  return (
    <div>
      <h1>Welcome, {user.name}</h1>
      <PostList posts={posts} />
    </div>
  );
}

Benefits:

  • ✅ Zero JavaScript sent to client (for this component)
  • ✅ Direct database access (no API layer needed)
  • ✅ Faster initial page loads
  • ✅ Better SEO (fully rendered HTML)

When to Use Server vs Client Components

Server Components (default):

  • Fetching data
  • Accessing backend resources
  • Keeping sensitive info on server
  • Reducing client bundle size

Client Components ('use client'):

  • Interactivity (onClick, onChange, etc.)
  • Browser APIs (localStorage, window, etc.)
  • React hooks (useState, useEffect, etc.)
  • Third-party libraries that need the browser

The Upgrade Path: Should You Migrate?

If you're starting a new project: Use React 19. No question.

If you have an existing app:

Easy Wins (Do These First)

  1. Replace form handling with Actions - Huge DX improvement
  2. Add useOptimistic to interactive features - Better UX
  3. Let the compiler optimize - Enable it, remove manual memoization

Bigger Lifts (Plan These)

  1. Adopt Server Components - Requires framework support (Next.js 15+)
  2. Refactor data fetching to use() - Nice-to-have, not urgent

What I'm Doing Differently

Since adopting React 19, my code looks different:

Before (React 18)

function TodoApp() {
  const [todos, setTodos] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  const addTodo = useCallback(async (title) => {
    setIsLoading(true);
    try {
      const newTodo = await api.createTodo(title);
      setTodos([...todos, newTodo]);
    } catch (err) {
      setError(err.message);
    } finally {
      setIsLoading(false);
    }
  }, [todos]);

  // ... more boilerplate
}

After (React 19)

function TodoApp() {
  const [todos, setTodos] = useState([]);
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(todos);

  const [error, addTodo, isPending] = useActionState(
    async (_, formData) => {
      const title = formData.get('title');
      addOptimisticTodo({ id: Date.now(), title, done: false });
      
      try {
        const newTodo = await api.createTodo(title);
        setTodos([...todos, newTodo]);
        return null;
      } catch (err) {
        return err.message;
      }
    },
    null
  );

  // So much cleaner
}

The Bottom Line

React 19 isn't just "React with new features." It's a paradigm shift:

  • Actions replace manual async handling
  • useOptimistic makes UIs feel instant
  • React Compiler removes performance footguns
  • use() simplifies data fetching
  • Server Components reduce client bundle size

If you're still writing React like it's 2022, you're missing out.

Start with Actions and useOptimistic. Those two alone will make your apps feel 10x better.


Frequently Asked Questions

Is the React Compiler enabled by default in React 19? Not yet. It ships with React 19, but it's opt-in. You'll need to enable it in your build config. The React team is testing it at scale and plans to enable it by default in a future version.

Do I have to rewrite my entire app to use Partial Prerendering? Nope. If you're already using Suspense for loading states, you're most of the way there. The framework handles the complex parts.

Can I still use API Routes in React 19? Yes. While Server Actions are recommended for data mutations from React Server Components, API Routes are still great for REST/GraphQL endpoints, webhooks, or third-party integrations.

Does the React Compiler mean I should remove all my existing useMemo and useCallback hooks? Leave them for now. The compiler is smart enough to respect existing manual memoization. For new code, you can skip them and let the compiler do its job.

What about React 18 projects? Should I upgrade immediately? Not necessarily. React 19 is stable, but if your React 18 app is working fine, there's no rush. Plan the upgrade, test it thoroughly, and migrate when you're ready. The benefits are real, but so is the testing overhead.

Related Articles