Skip to main content

React 19: Handling Errors and Resets in useActionState Without Crashing

The introduction of useActionState (previously useFormState) in React 19 marks a paradigm shift from client-side event handlers to integrated Actions. However, developers migrating from onSubmit handlers are encountering a critical friction point: exception handling.

In traditional React, an uncaught Promise rejection inside an onClick or onSubmit handler simply logs an error to the console. The UI remains interactive. In React 19's useActionStatean uncaught error acts as a render error. It propagates up the stack until it hits the nearest Suspense boundary or Error Boundary, potentially unmounting your entire feature.

Furthermore, because these actions often run on the server, a failed validation or database error that isn't handled correctly causes the form state to desynchronize, often resetting controlled inputs to their initial values and frustrating users.

Here is the root cause analysis and a production-grade pattern to handle Action errors gracefully.

The Root Cause: Transitions vs. Event Handlers

To understand why your app crashes, you must understand the execution context of useActionState.

When you invoke the function returned by useActionState, React executes the underlying action within a Transition. Transitions are designed to handle state updates that might suspend. React treats any error thrown during a Transition as a critical failure of that update.

  1. Event Handler (onSubmit): Code runs outside the render cycle. Exceptions are standard JavaScript errors.
  2. Action (useActionState): The action is tightly bound to React's render phase to manage isPending states and optimistic updates. If the Action throws, React determines the Transition failed. Since the component tree cannot transition to the "next" state, and the "current" state produced an error, React invokes the Error Boundary.

Additionally, the "Input Reset" issue occurs because useActionState is fundamentally a reducer. It takes (prevState, payload) => newState. If your action throws, it never returns a newState. If your component re-renders due to a parent update or a separate side effect while the action is pending or failed, the inputs may revert to initialState because the intermediate user input was never committed to the state history.

The Solution: The "Result Pattern" Action Wrapper

To fix this, we must ensure the Action never throws back to the component. Instead, it must catch all exceptions internally and return a structured ActionState object that includes:

  1. Status flags.
  2. Validation errors.
  3. Previous payload (crucial for preventing input resets).

1. Define the Robust State Type

First, define a generic state structure that handles both success and failure, ensuring type safety across the boundary.

// types.ts
export type ActionState<T> = {
  success: boolean;
  message?: string;
  errors?: Record<string, string[]>; // Field-specific validation errors
  inputs?: Partial<T>; // Retain user input on error
};

2. Implementation: The Safe Action Pattern

Do not use try/catch inside the component. Use it inside the Action. Below is a complete implementation of a User Registration flow using React 19 features.

// actions.ts
'use server';

import { z } from 'zod';
import { ActionState } from './types';

// Define schema for validation
const schema = z.object({
  email: z.string().email(),
  username: z.string().min(3),
});

type FormValues = z.infer<typeof schema>;

export async function registerUser(
  prevState: ActionState<FormValues>,
  formData: FormData
): Promise<ActionState<FormValues>> {
  // 1. Extract raw values to return them if validation fails
  const rawData = {
    email: formData.get('email') as string,
    username: formData.get('username') as string,
  };

  try {
    // 2. Validate Data
    const validatedFields = schema.safeParse(rawData);

    if (!validatedFields.success) {
      return {
        success: false,
        message: 'Please correct the errors below.',
        errors: validatedFields.error.flatten().fieldErrors,
        inputs: rawData, // Return input so the form doesn't reset
      };
    }

    // 3. Perform Logic (Database, API, etc.)
    // Simulate a database delay and potential error
    await new Promise((resolve) => setTimeout(resolve, 1000));
    
    // Simulate a unique constraint violation
    if (rawData.username === 'admin') {
      throw new Error('Username is already taken.');
    }

    return {
      success: true,
      message: 'User registered successfully!',
      inputs: {}, // Clear inputs on success
    };

  } catch (error) {
    // 4. Catch UNEXPECTED errors (DB down, 500s)
    // Log internally for observability
    console.error('Registration error:', error);

    return {
      success: false,
      message: error instanceof Error ? error.message : 'An unexpected error occurred.',
      inputs: rawData, // CRITICAL: Preserve user inputs on crash
    };
  }
}

3. The Component: Resilient UI

Now, consume the action. Note how we use defaultValue populated by state.inputs. This is what solves the "unexpected reset" problem.

// RegisterForm.tsx
'use client';

import { useActionState } from 'react';
import { registerUser } from './actions';
import { ActionState } from './types';

const initialState: ActionState<{ email: string; username: string }> = {
  success: false,
  inputs: { email: '', username: '' },
};

export function RegisterForm() {
  const [state, formAction, isPending] = useActionState(registerUser, initialState);

  return (
    <div className="max-w-md mx-auto p-6 border rounded-lg shadow-sm">
      <h1 className="text-xl font-bold mb-4">Create Account</h1>

      {/* Global Error Message */}
      {!state.success && state.message && (
        <div className="mb-4 p-3 bg-red-50 text-red-700 rounded text-sm">
          {state.message}
        </div>
      )}

      {/* Success Message */}
      {state.success && state.message && (
        <div className="mb-4 p-3 bg-green-50 text-green-700 rounded text-sm">
          {state.message}
        </div>
      )}

      <form action={formAction} className="space-y-4">
        <div>
          <label htmlFor="email" className="block text-sm font-medium">
            Email
          </label>
          <input
            id="email"
            name="email"
            type="email"
            // KEY FIX: Use defaultValue from state.inputs to persist data on error
            defaultValue={state.inputs?.email}
            className="mt-1 block w-full rounded-md border-gray-300 shadow-sm p-2 border"
            aria-invalid={!!state.errors?.email}
          />
          {state.errors?.email && (
            <p className="text-red-500 text-xs mt-1">{state.errors.email[0]}</p>
          )}
        </div>

        <div>
          <label htmlFor="username" className="block text-sm font-medium">
            Username
          </label>
          <input
            id="username"
            name="username"
            type="text"
            // KEY FIX: Persist data
            defaultValue={state.inputs?.username}
            className="mt-1 block w-full rounded-md border-gray-300 shadow-sm p-2 border"
          />
           {state.errors?.username && (
            <p className="text-red-500 text-xs mt-1">{state.errors.username[0]}</p>
          )}
        </div>

        <button
          type="submit"
          disabled={isPending}
          className="w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700 disabled:opacity-50"
        >
          {isPending ? 'Registering...' : 'Sign Up'}
        </button>
      </form>
    </div>
  );
}

Why This Works

1. The Try/Catch Firewall

By wrapping the entire logic of registerUser in a try/catch block, we convert exceptions (Control Flow) into Data (State). When an error occurs, the Promise resolves successfully with an error payload. React sees a successful Transition completion and updates the state variable, allowing you to render the error message conditionally rather than exploding the component tree.

2. The Input Rehydration Strategy

In standard HTML form submissions, if the server rejects the request, the page reloads, and data is lost unless explicitly sent back. React 19 mimics this via Hydration.

When useActionState updates, it replaces the current state with the return value of the action. If you return { success: false, message: '...' }, you have effectively discarded the user's input. By extracting formData immediately and including it in the return object (inputs: rawData), and binding inputs via defaultValue={state.inputs?.field}, you ensure that even if the server rejects the request 50 times, the user's keystrokes persist in the DOM.

Conclusion

React 19 moves data mutation closer to the metal of the framework. While this reduces the need for useEffect and manual loading states, it requires a stricter discipline regarding error boundaries. Treat your Actions as API endpoints that must always return a 200 OK structure, even when the business logic implies a 500 Error. Handle the error logic in your return payload, not by throwing exceptions. 

Popular posts from this blog

Restricting Jetpack Compose TextField to Numeric Input Only

Jetpack Compose has revolutionized Android development with its declarative approach, enabling developers to build modern, responsive UIs more efficiently. Among the many components provided by Compose, TextField is a critical building block for user input. However, ensuring that a TextField accepts only numeric input can pose challenges, especially when considering edge cases like empty fields, invalid characters, or localization nuances. In this blog post, we'll explore how to restrict a Jetpack Compose TextField to numeric input only, discussing both basic and advanced implementations. Why Restricting Input Matters Restricting user input to numeric values is a common requirement in apps dealing with forms, payment entries, age verifications, or any data where only numbers are valid. Properly validating input at the UI level enhances user experience, reduces backend validation overhead, and minimizes errors during data processing. Compose provides the flexibility to implement ...

jetpack compose - TextField remove underline

Compose TextField Remove Underline The TextField is the text input widget of android jetpack compose library. TextField is an equivalent widget of the android view system’s EditText widget. TextField is used to enter and modify text. The following jetpack compose tutorial will demonstrate to us how we can remove (actually hide) the underline from a TextField widget in an android application. We have to apply a simple trick to remove (hide) the underline from the TextField. The TextField constructor’s ‘colors’ argument allows us to set or change colors for TextField’s various components such as text color, cursor color, label color, error color, background color, focused and unfocused indicator color, etc. Jetpack developers can pass a TextFieldDefaults.textFieldColors() function with arguments value for the TextField ‘colors’ argument. There are many arguments for this ‘TextFieldDefaults.textFieldColors()’function such as textColor, disabledTextColor, backgroundColor, cursorC...

jetpack compose - Image clickable

Compose Image Clickable The Image widget allows android developers to display an image object to the app user interface using the jetpack compose library. Android app developers can show image objects to the Image widget from various sources such as painter resources, vector resources, bitmap, etc. Image is a very essential component of the jetpack compose library. Android app developers can change many properties of an Image widget by its modifiers such as size, shape, etc. We also can specify the Image object scaling algorithm, content description, etc. But how can we set a click event to an Image widget in a jetpack compose application? There is no built-in property/parameter/argument to set up an onClick event directly to the Image widget. This android application development tutorial will demonstrate to us how we can add a click event to the Image widget and make it clickable. Click event of a widget allow app users to execute a task such as showing a toast message by cli...