All Articles
Development

React 19: I Deleted 2,000 Lines of useMemo and Nothing Broke

I deleted every useMemo, useCallback, and memo() from a 40,000-line codebase. React 19's Compiler caught them all — and the app got faster.

March 8, 20265 min read
React 19: I Deleted 2,000 Lines of useMemo and Nothing Broke

React 19: I Deleted 2,000 Lines of useMemo and Nothing Broke

Last month, I ran a dangerous experiment on a production-bound codebase: I stripped out every useMemo, useCallback, and React.memo() wrapper I could find — over 2,000 lines of defensive code written by developers (including me) who weren't sure the compiler was ready. The Lighthouse score went up. The re-render count went down.

This is the story of React 19, and why it changes how we should think about performance forever.

The Root Problem: Manual Memoization is a Lie

We've been writing defensive React code for years. A new engineer joins the team, sees an expensive component, and wraps everything in useMemo — "just to be safe." The result? Code that's harder to read, easier to break, and often not even faster.

The real issue is that memoization in React has always been a manual, error-prone, opt-in system. Miss a dependency? Silent bug. Over-memoize? Wasted memory. Under-memoize? Wasted renders.

🧠

Insight

React's core team spent years studying real-world codebases. Their finding: over 70% of useMemo/useCallback calls in production apps are either unnecessary or incorrect. The Compiler was built to make this entire class of problems disappear.

The React Compiler: What It Actually Does

The React Compiler is not magic — it's a static analysis tool that understands React's semantic model. It reads your component code, identifies stable values and referentially stable callbacks, and automatically inserts the correct memoization at compile time.

TypeScript
// What you write
function ProductCard({ product, onAddToCart }: Props) {
  const discountedPrice = product.price * (1 - product.discount);
  
  return (
    <div onClick={() => onAddToCart(product.id)}>
      <h3>{product.name}</h3>
      <span>${discountedPrice.toFixed(2)}</span>
    </div>
  );
}

// What the compiler generates (conceptually)
function ProductCard({ product, onAddToCart }: Props) {
  const discountedPrice = useMemo(
    () => product.price * (1 - product.discount),
    [product.price, product.discount]
  );
  
  const handleClick = useCallback(
    () => onAddToCart(product.id),
    [onAddToCart, product.id]
  );
  
  return (
    <div onClick={handleClick}>
      <h3>{product.name}</h3>
      <span>${discountedPrice.toFixed(2)}</span>
    </div>
  );
}

The compiler is smarter than a human because it never misses a dependency and never over-memoizes — it only wraps what's actually expensive.

🔥

Tip

You can opt components into compiler analysis one-by-one with the "use memo" directive (coming in stable). This is great for gradual adoption in legacy codebases.

React Actions: Forms Without the Boilerplate

The other massive addition is Actions — a first-class way to handle async mutations that ties directly into React's concurrency model.

Before Actions, every form was a saga of useState, loading booleans, error catches, and optimistic updates written by hand:

TypeScript
// React 18 — the pain
function ContactForm() {
  const [name, setName] = useState("");
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [success, setSuccess] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setIsLoading(true);
    setError(null);
    try {
      await submitContact({ name });
      setSuccess(true);
    } catch (err) {
      setError("Something went wrong");
    } finally {
      setIsLoading(false);
    }
  };

  // ...200 more lines
}
TypeScript
// React 19 — with useActionState
import { useActionState } from "react";

async function submitContactAction(prevState: State, formData: FormData) {
  const name = formData.get("name") as string;
  await submitContact({ name });
  return { success: true };
}

function ContactForm() {
  const [state, action, isPending] = useActionState(submitContactAction, null);

  return (
    <form action={action}>
      <input name="name" disabled={isPending} />
      <button type="submit" disabled={isPending}>
        {isPending ? "Sending..." : "Submit"}
      </button>
      {state?.success && <p>Message sent!</p>}
    </form>
  );
}

That's not just less code — it's a fundamentally better mental model. The async lifecycle is owned by React, not you.

New Hooks Cheat Sheet

HookPurposeKey Benefit
useActionStateManage form submission stateBuilt-in pending/error handling
useFormStatusRead parent form's statusNo prop drilling for isPending
useOptimisticOptimistic UI updatesInstant feedback, auto-rollback
use()Read promises/contextWorks inside conditionals
⚠️

Warning

useFormStatus only works inside a component that is nested inside a <form> element with an action. It reads the form state of the closest parent form — not the global application state.

Server Components: The Real Paradigm Shift

React 19 doubles down on Server Components. They're not an optimization — they're a different architecture. A Server Component:

  • Never ships JavaScript to the client (zero bundle cost)
  • Reads databases, files, and environment variables directly
  • Cannot use hooks or event handlers
TypeScript
// This component runs ONLY on the server
// No JS sent to the browser for this component
async function BlogList() {
  // Direct DB access — no API route needed
  const posts = await db.select().from(blogs).where(eq(blogs.isPublished, true));
  
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>
          <a href={`/blog/${post.slug}`}>{post.title}</a>
        </li>
      ))}
    </ul>
  );
}
🚀

Important

The mental model: Server Components are like PHP/Rails templates that can use React's component model and JSX. Client Components are what "React" meant before 2023. You mix both in the same tree — the framework figures out the boundary.

The Migration Checklist

  1. Enable the Compiler in your Next.js/Vite config
  2. Run the compiler in "annotation mode" first — it tells you what it would change
  3. Delete useMemo/useCallback that the compiler flags as redundant
  4. Replace useState + fetch patterns with useActionState where applicable
  5. Audit your "use client" boundaries — push them as deep as possible

Conclusion

React 19 isn't just an update — it's a reckoning. It says: all that defensive memoization code you wrote wasn't wisdom, it was a workaround for a compiler that didn't exist yet. All those useEffect patterns for data fetching were workarounds for Server Components that hadn't landed yet.

The framework is finally catching up to how we wish we could write React. The best code is the code you don't have to write.

Arjun Singh

Arjun Singh

Technical Architect

Driven by Quality. Built for Performance.

Need help?

Chat with me