As a professional React developer, you’re no stranger to the challenges of component-based development. But even the most experienced engineers need to keep sharpening their skills — especially when a major version like React 19 ships and changes how you think about memoization, data fetching, and form handling.

This list covers 15 tips: the 10 classic patterns that still matter, updated for 2026, plus 5 React 19 additions that belong in your toolkit.

A well-equipped hiking backpack with a rolled-up mat on top, symbolizing preparedness and versatility.

The Classics — Still Essential

1. Memoize Expensive Calculations with useMemo

Use useMemo to cache the result of expensive computations so they don’t re-run on every render:

const expensiveResult = useMemo(() => {
  return computeExpensiveValue(a, b);
}, [a, b]);

The calculation only reruns when a or b changes. This is particularly useful with large datasets or heavy transformations. Note: if you’re on React 19 with the React Compiler enabled, the compiler handles this automatically — see Tip #11.

2. Prevent Unnecessary Re-Renders with React.memo

Wrap functional components with React.memo to skip re-renders when props haven’t changed:

const MyComponent = React.memo(({ data }: { data: string }) => {
  return <div>{data}</div>;
});

Most useful for components deep in a tree that receive stable props from unstable parent renders.

3. Extract Reusable Logic with Custom Hooks

Encapsulate stateful logic in custom hooks to keep components clean and share behavior across the codebase:

function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(data => { setData(data); setLoading(false); });
  }, [url]);

  return { data, loading };
}

One hook, reused anywhere. See Tip #12 for the React 19 use() alternative.

4. Avoid Prop Drilling with the Context API

Use React Context to share data through the component tree without passing props through every level:

const UserContext = React.createContext<{ name: string } | null>(null);

function ParentComponent() {
  return (
    <UserContext.Provider value={{ name: 'Jane' }}>
      <ChildComponent />
    </UserContext.Provider>
  );
}

function ChildComponent() {
  const user = useContext(UserContext);
  return <div>{user?.name}</div>;
}

For complex global state, pair Context with useReducer (Tip #10) or a dedicated state library.

5. Code-Split with React.lazy and Suspense

Load components only when they’re needed to reduce your initial bundle:

const HeavyChart = React.lazy(() => import('./HeavyChart'));

function Dashboard() {
  return (
    <Suspense fallback={<div>Loading chart...</div>}>
      <HeavyChart />
    </Suspense>
  );
}

In React 19, Suspense works more predictably with async data fetching — pair it with the use() hook (Tip #12) for cleaner data loading patterns.

6. Memoize Callbacks with useCallback

Prevent functions from being recreated on every render, especially when passing them to memoized child components:

const handleSubmit = useCallback((value: string) => {
  onSubmit(value);
}, [onSubmit]);

Without useCallback, a new function reference is created on every render, which defeats React.memo on child components. Like useMemo, the React Compiler can automate this in React 19.

7. Catch Errors Gracefully with Error Boundaries

Error boundaries prevent a single component crash from taking down the entire app:

class ErrorBoundary extends React.Component<
  { children: React.ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  render() {
    if (this.state.hasError) {
      return <p>Something went wrong. Please refresh.</p>;
    }
    return this.props.children;
  }
}

React 19 improves error reporting with new root-level options (onCaughtError, onUncaughtError) that give you better control over how errors surface in production monitoring.

8. Keep the DOM Clean with Fragments

Avoid unnecessary wrapper <div> elements that can break CSS layouts:

return (
  <>
    <h1>Title</h1>
    <p>Content</p>
  </>
);

Fragments render nothing to the DOM — no extra nodes, no layout side effects.

9. Always Use Stable Keys in Lists

Give every list item a unique, stable key so React can efficiently track changes:

const items = data.map(item => (
  <li key={item.id}>{item.name}</li>
));

Don’t use array indexes as keys — they break when items are reordered or deleted. Use a unique ID from your data.

10. Handle Complex State with useReducer

When state logic grows beyond a few useState calls, useReducer gives you structure:

type Action = { type: 'increment' } | { type: 'decrement' } | { type: 'reset' };

function reducer(state: { count: number }, action: Action) {
  switch (action.type) {
    case 'increment': return { count: state.count + 1 };
    case 'decrement': return { count: state.count - 1 };
    case 'reset':     return { count: 0 };
    default: throw new Error('Unknown action');
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0 });
  return (
    <>
      <span>{state.count}</span>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>−</button>
    </>
  );
}

React 19 Additions

11. Let the React Compiler Handle Memoization

For React 19, an optional React Compiler (stable as babel-plugin-react-compiler 1.x) analyzes your code and applies useMemo, useCallback, and React.memo automatically — without you writing them manually. It doesn’t ship inside the react package; you add the Babel plugin (or your framework’s equivalent) yourself.

To enable it, add to babel.config.js:

module.exports = {
  plugins: [
    ['babel-plugin-react-compiler', {}],
  ],
};

With the compiler active, hand-written useMemo and useCallback are largely redundant for most use cases. Don’t fight the compiler — let it work, and only reach for manual memoization when you have a specific reason.

12. Read Promises and Context with use()

The use() hook lets you read a Promise or a Context value directly inside a component or custom hook — no useEffect required for data fetching:

// Fetch data with use() + Suspense
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise); // suspends until resolved
  return <h1>{user.name}</h1>;
}

// Read context with use() — works conditionally, unlike useContext
function ThemedButton() {
  const theme = use(ThemeContext);
  return <button style={{ color: theme.color }}>Click</button>;
}

use() is unique among hooks: it can be called inside conditionals and loops. When used with a Promise, it integrates with Suspense to show a fallback while the data loads.

13. Handle Async Actions with useActionState

useActionState (formerly useFormState) manages the state of async actions, including form submissions:

async function submitForm(prevState: State, formData: FormData) {
  const name = formData.get('name');
  const result = await saveToServer(name);
  return result.ok ? { message: 'Saved!' } : { message: 'Error saving.' };
}

function ContactForm() {
  const [state, formAction, isPending] = useActionState(submitForm, { message: '' });

  return (
    <form action={formAction}>
      <input name="name" />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Saving...' : 'Save'}
      </button>
      <p>{state.message}</p>
    </form>
  );
}

No useState for loading state, no manual error handling — useActionState handles the pending state and result in one hook.

14. Show Optimistic Updates with useOptimistic

useOptimistic lets you update the UI immediately while an async operation is in progress, then reconcile with the real result:

function MessageList({ messages, sendMessage }) {
  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (state, newMessage) => [...state, { text: newMessage, pending: true }]
  );

  async function handleSend(formData: FormData) {
    const text = formData.get('text') as string;
    addOptimisticMessage(text);        // shows immediately
    await sendMessage(text);           // actual network call
  }

  return (
    <form action={handleSend}>
      {optimisticMessages.map((m, i) => (
        <p key={i} style={{ opacity: m.pending ? 0.5 : 1 }}>{m.text}</p>
      ))}
      <input name="text" /><button type="submit">Send</button>
    </form>
  );
}

The optimistic message appears right away (here at half opacity while pending is true), then React replaces it with the confirmed server data when the request finishes.

15. Pass ref as a Prop — No More forwardRef

In React 19, ref is a regular prop. You no longer need React.forwardRef to forward refs to child components:

// React 19 — ref as a plain prop
function MyInput({ ref, ...props }: React.ComponentProps<'input'>) {
  return <input ref={ref} {...props} />;
}

// Usage
function Form() {
  const inputRef = useRef<HTMLInputElement>(null);
  return <MyInput ref={inputRef} placeholder="Type here" />;
}

forwardRef still works in React 19 for backward compatibility, but it’s no longer needed for new components. Clean up your codebase as you go.

Wrapping Up

These 15 techniques — the classic performance patterns and the React 19 additions — are your toolkit for writing React code that’s fast, readable, and maintainable. The fundamentals (memoization, code splitting, error handling) remain essential. React 19’s new hooks (use, useActionState, useOptimistic) reduce the boilerplate around the patterns you were already implementing manually.

Pick one tip from the React 19 section and try it in your next PR. That’s how these things stick.