Module: React and Ecosystem
React and Ecosystem·105·8 MIN READ

105: React 19 and 19.2 — Actions, Optimistic UI, Form Status, and Activity

TOPICS COVERED: React 19 and 19.2 — Actions, Optimistic UI, Form Status, and Activity

Learning objectives

You will learn to:

  • understand React Actions as async transitions;
  • submit forms through function actions;
  • manage Action state with useActionState;
  • read parent form status with useFormStatus;
  • model temporary optimistic state with useOptimistic;
  • use <Activity> to preserve hidden UI state while changing priority/visibility;
  • distinguish React Actions from API authorization and server-state caching;
  • know when these APIs complement TanStack Query rather than replace it.

Actions mental model

An Action is async work executed inside a transition.

React can coordinate:

  • pending state;
  • optimistic UI;
  • form submission;
  • error propagation;
  • transition scheduling.

A simple client Action:

jsx
async function saveTask(formData) {
  const title =
    String(
      formData.get('title') ?? '',
    ).trim();

  if (title.length < 3) {
    return;
  }

  await createTask({ title });
}

function TaskForm() {
  return (
    <form action={saveTask}>
      <input
        name="title"
        required
        minLength={3}
      />
      <button>Save</button>
    </form>
  );
}

Passing a function to <form action> is different from a traditional URL string action.

useActionState

useActionState helps when the Action should return state such as validation errors or a saved result.

jsx
import {
  useActionState,
} from 'react';

async function createTaskAction(
  previousState,
  formData,
) {
  const title =
    String(
      formData.get('title') ?? '',
    ).trim();

  if (title.length < 3) {
    return {
      status: 'invalid',
      message:
        'Enter at least 3 characters.',
    };
  }

  try {
    const task =
      await createTask({ title });

    return {
      status: 'success',
      task,
      message: '',
    };
  } catch {
    return {
      status: 'error',
      message:
        'Could not create task.',
    };
  }
}

function TaskForm() {
  const [
    state,
    formAction,
    isPending,
  ] = useActionState(
    createTaskAction,
    {
      status: 'idle',
      task: null,
      message: '',
    },
  );

  return (
    <form action={formAction}>
      <label htmlFor="title">
        Title
      </label>

      <input
        id="title"
        name="title"
      />

      <button disabled={isPending}>
        {isPending
          ? 'Saving…'
          : 'Save task'}
      </button>

      {state.message && (
        <p
          role={
            state.status === 'error' ||
            state.status === 'invalid'
              ? 'alert'
              : 'status'
          }
        >
          {state.message}
        </p>
      )}
    </form>
  );
}

useActionState is not server validation by itself. The server still validates and authorizes.

useFormStatus

A deeply nested submit button can read the nearest parent form status.

jsx
import {
  useFormStatus,
} from 'react-dom';

function SubmitButton() {
  const {
    pending,
    data,
    method,
    action,
  } = useFormStatus();

  return (
    <button disabled={pending}>
      {pending
        ? 'Saving…'
        : 'Save'}
    </button>
  );
}

This avoids passing isPending through several component layers.

useFormStatus must be called by a component rendered inside the form whose status you want.

useOptimistic

Optimistic UI temporarily shows the expected result before the authoritative operation completes.

jsx
import {
  useOptimistic,
} from 'react';

function TaskList({
  tasks,
  addTaskAction,
}) {
  const [
    optimisticTasks,
    addOptimisticTask,
  ] = useOptimistic(
    tasks,
    (current, task) => [
      ...current,
      {
        ...task,
        optimistic: true,
      },
    ],
  );

  async function action(formData) {
    const title =
      String(
        formData.get('title') ?? '',
      ).trim();

    const optimisticTask = {
      id: `temp-${Date.now()}`,
      title,
      completed: false,
    };

    addOptimisticTask(
      optimisticTask,
    );

    await addTaskAction({
      title,
    });
  }

  return (
    <>
      <form action={action}>
        <input name="title" />
        <button>Add</button>
      </form>

      <ul>
        {optimisticTasks.map(
          (task) => (
            <li key={task.id}>
              {task.title}
              {task.optimistic
                ? ' (saving…)'
                : ''}
            </li>
          ),
        )}
      </ul>
    </>
  );
}

Optimistic data is speculative. The final server result remains authoritative.

If the server assigns IDs or normalizes data, reconcile with the response.

React Action versus TanStack Query mutation

These are not mutually exclusive concepts.

React Actions coordinate UI transitions and form behavior.

TanStack Query v5 coordinates server-state cache:

  • cache entries;
  • invalidation;
  • stale/fresh state;
  • refetching;
  • mutation state;
  • optimistic cache updates;
  • pagination.

A production app may use a form Action and then invalidate/update relevant TanStack Query cache entries.

Do not duplicate server data into unrelated local state just because an Action returned it.

Server Functions

In an RSC-aware framework, a function marked "use server" can be called from client code as a Server Function.

Important:

"use server" does not mark a component as a Server Component.

There is no "use server" directive for declaring Server Components. It declares Server Functions.

Server Components and Server Functions are covered more deeply in lesson 115.

<Activity> in React 19.2

<Activity> lets React hide or show a subtree while preserving its state and managing its work priority.

A simplified conceptual example:

jsx
import {
  Activity,
  useState,
} from 'react';

function Dashboard() {
  const [tab, setTab] =
    useState('tasks');

  return (
    <>
      <nav>
        <button
          onClick={() =>
            setTab('tasks')
          }
        >
          Tasks
        </button>

        <button
          onClick={() =>
            setTab('analytics')
          }
        >
          Analytics
        </button>
      </nav>

      <Activity
        mode={
          tab === 'tasks'
            ? 'visible'
            : 'hidden'
        }
      >
        <TaskWorkspace />
      </Activity>

      <Activity
        mode={
          tab === 'analytics'
            ? 'visible'
            : 'hidden'
        }
      >
        <Analytics />
      </Activity>
    </>
  );
}

Use Activity when preserving hidden subtree state is valuable.

Do not use it automatically instead of conditional rendering. Hidden UI can still consume memory, and preserving state is not always desirable.

Pending UX

A good pending state:

  • disables duplicate destructive submits when required;
  • leaves useful context visible;
  • uses aria-busy or status text when appropriate;
  • does not lie about success before authority confirms it;
  • supports retry on real failure.

Do not replace an entire form with a spinner if the user needs to understand what is being submitted.

Validation model

Use three levels:

  1. native/client constraints for immediate guidance;
  2. schema/client form validation where useful;
  3. server validation and authorization as authority.

An Action error such as:

text
Title is required

can be returned as form state.

An authorization failure such as:

text
You may not edit this task

must be enforced by the server.

Common mistakes

Calling optimistic setter outside an Action

useOptimistic is designed around Action/transition work.

Using Actions as a cache

Actions do not replace query freshness/invalidation.

Treating pending as success

Do not permanently show a server-generated task ID before the server returns it.

Confusing Server Components with "use server"

"use server" is for Server Functions.

Exercises

  1. Convert a controlled submit workflow to a form Action.
  2. Return validation errors through useActionState.
  3. Move submit pending UI into a child SubmitButton using useFormStatus.
  4. Add an optimistic task and reconcile with the server result.
  5. Compare an optimistic Action with a TanStack Query v5 mutation design.
  6. Build tab panels with Activity and explain when preserving state is useful.

Exit questions

  1. What makes an Action different from an ordinary async function?
  2. What does useActionState return?
  3. How does useFormStatus find the relevant form?
  4. What is optimistic state?
  5. Why do Actions not replace authorization or a query cache?
  6. What problem does <Activity> solve?

Official references


Deep dive: Actions unify async transition semantics, not server ownership

React Actions make certain async workflows easier to express, especially forms.

But an Action does not answer:

  • where server data is cached;
  • who authorizes the request;
  • how data becomes stale;
  • how pagination works;
  • whether write is idempotent.

Those remain separate concerns.

Function form action lifecycle

jsx
function TaskForm() {
  async function create(formData) {
    const title = String(formData.get('title') ?? '').trim();
    await saveTask({ title });
  }

  return (
    <form action={create}>
      <input name="title" />
      <SubmitButton />
    </form>
  );
}

When the form action function succeeds, React can reset uncontrolled fields in appropriate form-action behavior.

If you control fields manually, you still own their reset state.

Test exact behavior for your React/framework version rather than assuming browser form behavior.

useFormStatus details

A child can read the nearest form submission:

jsx
function SubmitButton() {
  const { pending, data, method, action } = useFormStatus();

  return (
    <button disabled={pending}>
      {pending ? 'Creating…' : 'Create task'}
    </button>
  );
}

The component calling useFormStatus must be inside the form tree. The same component that renders the <form> cannot read that form's status before it exists as its parent; put status UI in a descendant.

useActionState previous state

Action signature:

jsx
async function action(previousState, formData) {
  ...
  return nextState;
}

This is useful for accumulated form result state.

Example validation:

jsx
const initialState = {
  values: {
    title: '',
  },
  errors: {},
  message: '',
};

async function createTaskAction(previousState, formData) {
  const title = String(formData.get('title') ?? '').trim();

  if (title.length < 3) {
    return {
      values: { title },
      errors: {
        title: 'Use at least 3 characters.',
      },
      message: 'Check the form.',
    };
  }

  const result = await api.createTask({ title });

  return {
    values: { title: '' },
    errors: {},
    message: `Created ${result.task.title}`,
  };
}

This result state is UI/form state, not a replacement for a query cache.

Action errors

Decide whether failure is:

text
expected validation
→ return structured state

or:

text
unexpected exception
→ throw / error boundary / monitoring as architecture dictates

Do not turn every server failure into an unhandled render exception.

Optimistic UI correctness

Optimism has three truths:

text
predicted UI
request in progress
authoritative server result

A robust workflow handles all transitions.

Create example:

jsx
const [optimisticTasks, addOptimisticTask] = useOptimistic(
  tasks,
  (current, optimisticTask) => [
    ...current,
    optimisticTask,
  ],
);

The optimistic record may need:

jsx
{
  clientId,
  title,
  status: 'sending'
}

When server returns:

jsx
{
  id: 'server-123',
  title: 'Normalized title'
}

reconcile IDs/data.

Do not let temporary client IDs leak permanently into URLs or relationships unless your API supports client-generated IDs.

Rollback versus error annotation

On failure, choices include:

  • remove optimistic item;
  • restore previous value;
  • keep failed item with Retry;
  • mark it failed and let user edit;
  • show conflict resolver.

The correct choice depends on user value.

For a chat message, keeping failed message with Retry may be better than making it disappear.

For a destructive deletion, immediate rollback may be clearer.

Activity versus conditional rendering

Conditional:

jsx
{tab === 'editor' && <Editor />}

removes Editor when false, cleaning up state/effects.

Activity hidden mode can preserve internal state while hiding/deprioritizing subtree.

This trade-off affects:

  • memory;
  • preserved drafts;
  • subscription lifecycle;
  • privacy/sensitive fields;
  • return speed.

Do not use Activity for a logout boundary where state must be destroyed.

Activity and Effects

When an Activity becomes hidden, React can clean up Effects while preserving state and restore them when visible.

This makes Activity more than CSS display: none.

Understand library/current documentation before assuming hidden activity continues every subscription.

Transition relationship

Actions operate within transition semantics, so React can coordinate pending rendering without blocking urgent inputs.

But long JavaScript work inside an Action still blocks the main thread. Scheduling cannot make CPU-heavy synchronous code free.

Move expensive computation or server work to appropriate systems.

Progressive enhancement and framework Actions

In RSC/server frameworks, form Actions can integrate with server functions and progressive enhancement.

Important separation:

text
React form Action
= UI async transition mechanism
text
Server Function
= privileged server boundary callable from client/framework

They often work together but are not identical concepts.

Server authorization example

Never:

jsx
async function deleteTaskAction(formData) {
  const id = formData.get('id');
  return db.task.delete(id);
}

without authorization.

Instead server function conceptually:

jsx
const user = await requireUser();
const task = await db.task.findById(id);

if (task.ownerId !== user.id) {
  throw new ForbiddenError();
}

await db.task.delete(id);

The client-provided ID is untrusted.

Action versus TanStack Query mutation decision

Use React Action when:

  • form/action semantics are central;
  • framework server functions are used;
  • pending/optimistic form state is enough.

Use TanStack Query mutation when:

  • server cache needs invalidation/update;
  • many consumers observe same resource;
  • mutation lifecycle needs cache coordination;
  • offline/network modes/pagination/infinite data matter.

Use together when responsibilities are clear.

Failure clinic

Optimistic success message before server truth

Do not show permanent "Saved" until authoritative success.

useFormStatus placed outside form

It will not observe the intended submission.

Server Function treated as trusted internal call

Client can invoke it with manipulated arguments. Authorize every operation.

Activity used to hide confidential state after logout

Preserving state may be wrong. Unmount/reset sensitive subtrees.

Exercises

  1. Return field errors from useActionState.
  2. Build a reusable submit button using useFormStatus.
  3. Implement optimistic create with temporary ID and server reconciliation.
  4. Compare rollback versus failed-item Retry UX.
  5. Replace conditional tab unmounting with Activity and observe state preservation.
  6. Write a server authorization checklist for one Action.

Mastery check

Explain:

  • Action state versus server cache;
  • optimistic prediction versus authority;
  • useFormStatus scope;
  • useActionState previous state;
  • Activity preservation trade-offs;
  • why server functions still require authorization.

Production case study: optimistic comment submission with retryable failed state

A disappearing failed optimistic item can be frustrating.

Instead, model optimistic comments:

jsx
{
  clientId: 'local-1',
  body: 'Looks good',
  status: 'sending'
}

On failure:

jsx
{
  clientId: 'local-1',
  body: 'Looks good',
  status: 'failed',
  error: 'Network unavailable'
}

UI:

jsx
<li>
  <p>{comment.body}</p>

  {comment.status === 'sending' && (
    <span>Sending…</span>
  )}

  {comment.status === 'failed' && (
    <>
      <span role="alert">Not sent.</span>
      <button onClick={() => retry(comment.clientId)}>
        Retry
      </button>
    </>
  )}
</li>

For this product, "keep failed draft visible" is better than rollback disappearance.

For a bank transfer, optimistic success would be inappropriate.

The lesson: useOptimistic provides a mechanism; product risk determines whether optimism is appropriate and how failure is represented.


Additional depth: progressive enhancement and Action architecture

Function Actions become especially powerful in frameworks that can submit before all client JavaScript is ready.

A robust form should still have meaningful HTML:

jsx
<form action={createTaskAction}>
  <label htmlFor="title">Title</label>
  <input id="title" name="title" required minLength={3} />
  <button>Create</button>
</form>

The browser platform remains the base.

React adds:

text
pending state
Action state
optimistic state
transition coordination

useActionState has an optional permalink mechanism for progressive-enhancement scenarios where the Action can be submitted before hydration and navigation needs a stable URL representation.

This is framework-oriented and should not be introduced unless the application uses server-rendered progressive forms, but learners should know why the API exists.

Action idempotency

A pending-disabled button reduces accidental duplicate clicks but cannot guarantee only one request.

Server-side operations such as:

text
charge payment
create booking
place order

may need idempotency keys or transactional uniqueness.

React pending state is UX, not distributed-systems protection.

Optimistic risk classification

Safe-ish optimism:

text
favorite toggle
task checkbox
local comment display

High-risk optimism:

text
payment succeeded
inventory reserved
legal approval recorded
refund completed

For high-integrity operations, show pending then authoritative success rather than speculative success.

This is a product/risk decision, not a Hook preference.