# Stop Managing Async State by Hand — useTransition and useActionState Explained

You've written this code before. We all have:

```jsx
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

const handleClick = async () => {
  try {
    setLoading(true);
    setError(null);
    await saveUser(data);
  } catch (e) {
    setError(e.message);
  } finally {
    setLoading(false);
  }
};
```

It works. Nobody is going to fire you for writing it. But you're writing the same ceremony every single time — `setLoading(true)`, `try/catch`, `setLoading(false)` — and it's all coordination code. Logic that exists purely to keep UI in sync with a Promise. Not business logic. Not product logic. Plumbing.

React 18 and 19 introduced `useTransition` and `useActionState` to take that plumbing off your plate. Let's look at what they actually do and — more importantly — *why* they do it better than the `useState` approach.

---

## The Problem with useState + try/finally

Before we look at the new hooks, let's be precise about what's wrong with the old pattern. There are three distinct issues:

**1. You're conflating urgency.** When you call `setLoading(true)`, React treats that as a high-priority synchronous update — same priority as a keypress. But a loading spinner triggered by a background save is not urgent. It shouldn't compete with the user typing in a form field.

**2. Errors stay local.** Your `catch` block sets local state, which means you have to render error UI inside *this* component. You can't bubble errors up to a centralized `<ErrorBoundary>` without extra wiring.

**3. Siblings can't see the pending state.** If three components down the tree need to know that a save is in progress, you're prop-drilling `isPending` all the way down. Or lifting state. Either way — more plumbing.

---

## useTransition — Tell React "This Isn't Urgent"

`useTransition` gives you a way to mark a state update as non-urgent. React will schedule it at lower priority, keeping synchronous interactions (typing, clicking, scrolling) fully responsive in the meantime.

```jsx
const [isPending, startTransition] = useTransition();
```

Here's the same save button, rewritten:

```jsx
const SaveButton = ({ onSave }) => {
  const [isPending, startTransition] = useTransition();

  const handleClick = () => {
    startTransition(async () => {
      await onSave();
    });
  };

  return (
    <button onClick={handleClick} disabled={isPending}>
      {isPending ? "Saving..." : "Save"}
    </button>
  );
};
```

Functionally similar to before. The meaningful difference is what happens *under the hood*: React knows this update is a transition, so it won't interrupt a high-priority render to process it. If the user is typing in a search box while the save is in flight, the search stays snappy.

### Where it really shines — expensive re-renders

Transitions become visibly useful when the update triggers a heavy render. Imagine filtering a large list:

```jsx
const SearchPage = () => {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState(allItems);
  const [isPending, startTransition] = useTransition();

  const handleSearch = (e) => {
    const value = e.target.value;

    // Input updates immediately — high priority
    setQuery(value);

    // Filtering the list is deferred — low priority
    startTransition(() => {
      setResults(allItems.filter(item =>
        item.name.toLowerCase().includes(value.toLowerCase())
      ));
    });
  };

  return (
    <>
      <input value={query} onChange={handleSearch} />
      {isPending && <span>Updating results...</span>}
      <ItemList items={results} />
    </>
  );
};
```

Without `startTransition`, re-rendering a list of 10,000 items on every keystroke blocks the input. With it, the input stays instant and the list catches up when React has a free moment.

---

## useActionState — Your Async Function Becomes First-Class

`useTransition` is great, but you're still managing return values and errors yourself. `useActionState` goes one step further: it wraps your entire async operation and gives you state back directly.

```jsx
const [state, action, isPending] = useActionState(actionFn, initialState);
```

- `actionFn` receives `(previousState, ...args)` and returns the next state
- `state` is whatever your function returned last
- `isPending` is `true` while the action is running
- if `actionFn` throws, the error propagates to the nearest `<ErrorBoundary>`

### A real example — submitting a form

```jsx
const createPost = async (prevState, formData) => {
  const title = formData.get("title");
  const body = formData.get("body");

  if (!title) {
    return { status: "error", message: "Title is required" };
  }

  await fetch("/api/posts", {
    method: "POST",
    body: JSON.stringify({ title, body }),
  });

  return { status: "success", message: "Post published!" };
};

const NewPostForm = () => {
  const [state, submitAction, isPending] = useActionState(createPost, {
    status: "idle",
    message: "",
  });

  return (
    <form action={submitAction}>
      <input name="title" placeholder="Post title" />
      <textarea name="body" placeholder="Write something..." />

      {state.status === "error" && (
        <p style={{ color: "red" }}>{state.message}</p>
      )}

      {state.status === "success" && (
        <p style={{ color: "green" }}>{state.message}</p>
      )}

      <button type="submit" disabled={isPending}>
        {isPending ? "Publishing..." : "Publish"}
      </button>
    </form>
  );
};
```

Notice what's gone: no `useState` for loading, no `useState` for error, no `try/catch` in the component. The action function returns state — either a validation error or a success — and the component just renders it.

### Unhandled errors go to ErrorBoundary

If your action throws (network failure, server 500), and you *don't* catch it inside the function, React automatically surfaces it to the nearest `<ErrorBoundary>`. No local error state needed for the unexpected stuff:

```jsx
const saveUser = async (prevState, formData) => {
  // If this throws, ErrorBoundary catches it — not us
  const res = await fetch("/api/user", {
    method: "PUT",
    body: formData,
  });

  if (!res.ok) throw new Error("Server error");

  return { saved: true };
};

// Somewhere up the tree:
<ErrorBoundary fallback={<p>Something went wrong. Please try again.</p>}>
  <UserForm />
</ErrorBoundary>
```

---

## useFormStatus — Any Child Can See the Pending State

Here's a bonus hook that pairs perfectly with form actions. Instead of prop-drilling `isPending`, any child component inside a `<form action={...}>` can call `useFormStatus()` to read the pending state directly:

```jsx
import { useFormStatus } from "react-dom";

const SubmitButton = () => {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? "Saving..." : "Save"}
    </button>
  );
};

const AvatarUpload = () => {
  const { pending } = useFormStatus();

  return (
    <div style={{ opacity: pending ? 0.5 : 1 }}>
      <img src="/avatar.jpg" alt="Profile picture" />
    </div>
  );
};

// Both components read pending state without any prop-drilling
const ProfileForm = () => {
  const [state, action] = useActionState(updateProfile, null);

  return (
    <form action={action}>
      <AvatarUpload />
      <input name="username" />
      <input name="bio" />
      <SubmitButton />
    </form>
  );
};
```

`SubmitButton` and `AvatarUpload` are completely decoupled from `ProfileForm`. They just know "is there a form action in flight above me?" That's it.

---

## Putting It All Together

Here's what a full async-first component looks like when you use all three hooks together:

```jsx
import { useActionState } from "react";
import { useFormStatus } from "react-dom";

// The action — pure async function, returns state
const publishArticle = async (prevState, formData) => {
  const title = formData.get("title");

  if (title.length < 5) {
    return { ok: false, error: "Title must be at least 5 characters" };
  }

  await fetch("/api/articles", {
    method: "POST",
    body: formData,
  });

  return { ok: true, error: null };
};

// Submit button reads form's pending state automatically
const SubmitButton = () => {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "Publishing..." : "Publish Article"}
    </button>
  );
};

// The form — clean, no loading state, no try/catch
const ArticleForm = () => {
  const [state, action] = useActionState(publishArticle, {
    ok: false,
    error: null,
  });

  if (state.ok) {
    return <p>? Article published successfully!</p>;
  }

  return (
    <form action={action}>
      <input name="title" placeholder="Article title" />
      <textarea name="body" placeholder="Content..." />
      {state.error && <p style={{ color: "red" }}>{state.error}</p>}
      <SubmitButton />
    </form>
  );
};

// Wrap it in an ErrorBoundary for unexpected failures
const App = () => (
  <ErrorBoundary fallback={<p>Something went wrong.</p>}>
    <ArticleForm />
  </ErrorBoundary>
);
```

---

## The Mental Shift

The old way asked: *"How do I coordinate loading, error, and success state around this async operation?"*

The new way asks: *"What does this action return, and how should the UI look for each outcome?"*

That's not just less code — it's a fundamentally different responsibility model. The component describes *what* the UI looks like. React handles *when* and *how* it updates. The coordination that used to live in your components now lives in the framework.

You don't have to rewrite everything today. `useState + try/finally` still works. But next time you find yourself typing `setLoading(true)` for the fourth time in a file, consider whether React could just handle that for you.

---

## Quick Reference

| You need... | Use... |
|---|---|
| Mark an update as non-urgent | `useTransition` |
| Manage async action state + pending | `useActionState` |
| Read pending state in a child component | `useFormStatus` |
| Handle unexpected errors centrally | `<ErrorBoundary>` |
| All of the above for a form | `<form action={fn}>` + `useActionState` + `useFormStatus` |

---

*React 18 introduced `useTransition`. `useActionState` and the async form actions landed in React 19. `useFormStatus` is in `react-dom`.*

---

## Real World: Wiring a Rich Text Editor

Theory is great. Let's look at a real library — [jodit-react](https://github.com/jodit/jodit-react) — and see exactly where these hooks belong.

Jodit exposes two relevant callbacks:

```tsx
<JoditEditor
  value={content}
  onChange={(value) => ...}  // fires on every keystroke
  onBlur={(value) => ...}    // fires when the editor loses focus
  config={config}
/>
```

### onChange + useTransition

`onChange` fires on *every single keystroke* inside the editor. If your parent component does something expensive in response — rendering a live preview, counting words, syncing to a store — you're triggering that work on every keypress.

This is exactly the problem `useTransition` was made for:

```tsx
const ArticleEditor = () => {
  const [content, setContent] = useState('');
  const [preview, setPreview] = useState('');
  const [isPending, startTransition] = useTransition();

  const handleChange = (value: string) => {
    // Typing stays instant — high priority
    setContent(value);

    // Preview re-render is deferred — low priority
    startTransition(() => {
      setPreview(renderMarkdown(value));
    });
  };

  return (
    <div>
      <JoditEditor value={content} onChange={handleChange} config={config} />
      {isPending && <span>Updating preview...</span>}
      <Preview html={preview} />
    </div>
  );
};
```

Without `startTransition`, every keypress triggers a full `renderMarkdown` + `<Preview>` re-render before React can paint the next character. The editor feels sluggish. With it — the editor stays instant, preview catches up when React has bandwidth.

### onBlur + useActionState

`onBlur` fires when the user clicks away from the editor — the natural moment to auto-save. This is a textbook `useActionState` case:

```tsx
const saveArticle = async (prevState: SaveState, value: string) => {
  const res = await fetch('/api/article', {
    method: 'PUT',
    body: JSON.stringify({ content: value }),
    headers: { 'Content-Type': 'application/json' },
  });

  if (!res.ok) {
    return { status: 'error', savedAt: null } as const;
  }

  return { status: 'saved', savedAt: new Date() } as const;
};

const ArticleEditor = () => {
  const [content, setContent] = useState('');
  const [saveState, save, isSaving] = useActionState(saveArticle, {
    status: 'idle',
    savedAt: null,
  });

  return (
    <ErrorBoundary fallback={<p>Auto-save failed. Please refresh.</p>}>
      <div>
        <StatusBar state={saveState} pending={isSaving} />
        <JoditEditor
          value={content}
          onChange={setContent}
          onBlur={(value) => save(value)}
          config={config}
        />
      </div>
    </ErrorBoundary>
  );
};

const StatusBar = ({ state, pending }) => {
  if (pending) return <span>Saving...</span>;
  if (state.status === 'saved') return <span>Saved at {state.savedAt.toLocaleTimeString()}</span>;
  if (state.status === 'error') return <span style={{ color: 'red' }}>Save failed</span>;
  return null;
};
```

No `useState` for saving/error. `onBlur` just calls `save(value)` — `useActionState` handles the rest. If the network request throws unexpectedly, it bubbles to `<ErrorBoundary>` automatically.

### The config trap — a bonus warning

There's one subtle footgun in `JoditEditor` worth knowing. Look at the init `useEffect` inside the library:

```ts
useEffect(() => {
  const jodit = JoditConstructor.make(element, config);
  // ...
}, [JoditConstructor, config, editorRef]);  // config is a dependency!
```

If you pass `config` as an inline object, you're creating a new reference on every render — which **destroys and recreates the entire editor** each time:

```tsx
// ? This recreates the editor on every render
<JoditEditor config={{ height: 400, readonly: false }} ... />

// ✅ Stable reference — editor initializes once
const config = useMemo(() => ({ height: 400, readonly: false }), []);
<JoditEditor config={config} ... />
```

Not directly related to `useTransition` or `useActionState` — but a good reminder that React's dependency arrays mean what they say. Unstable object references are a common source of "why does my editor keep resetting?" bugs.

---

### The full picture

```
User types          → onChange  → useTransition  → deferred preview re-render
User clicks away    → onBlur    → useActionState → auto-save + status UI
Network error       →           → ErrorBoundary  → centralized error UI
```

Same component. Three async concerns. Zero `setLoading`. Zero `try/catch` in JSX. That's Async React in practice.

_Full page: https://xdsoft.net/blog/stop-managing-async-state-by-hand-usetransition-and-useactionstate-explained_
