React 18.2 introduced a powerful hook called useActionState
, designed to simplify form handling and async state transitions. While it’s commonly used to manage form submissions, one of its most practical applications is handling API calls.
In traditional React applications, managing form submissions and API requests often involves juggling multiple state variables like loading
, error
, and success
using useState
or useReducer
. But with useActionState
, React offers a more declarative and cleaner solution—especially when the form is the source of your user interaction.
In this blog, we’ll explore how to handle API calls effectively using useActionState
in React. We’ll look at structuring API logic, validating input, managing success and error states, and building a better user experience.
Why Use useActionState for API Calls?
The main reason is simplicity. useActionState
ties your form and its async behavior into one cohesive unit. Instead of manually handling onSubmit
, preventing default behavior, calling your API, updating state, and rendering messages, you let the form trigger everything declaratively.
Some advantages of using useActionState
for API calls:
- It handles async form submissions in one place.
- It provides a reducer-like function that runs after each form submission.
- It returns updated state, which you can use to display errors or success messages.
- It keeps your component code clean and declarative.
Basic useActionState API Call Example
Let’s start with a common scenario: subscribing a user to a newsletter via an API call.
Step 1: Define the state structure
type APIState = {
loading: boolean;
error: string | null;
success: string | null;
};
Step 2: Setup the initial state
const initialState: APIState = {
loading: false,
error: null,
success: null,
};
Step 3: Create the reducer function
This function handles form submission, validates the input, sends the API request, and returns new state.
const handleSubmit = async (
prevState: APIState,
formData: FormData
): Promise<APIState> => {
const email = formData.get('email');
if (!email || typeof email !== 'string' || !email.includes('@')) {
return {
loading: false,
error: 'Please enter a valid email address.',
success: null,
};
}
try {
const response = await fetch('https://api.example.com/subscribe', {
method: 'POST',
body: JSON.stringify({ email }),
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const result = await response.json();
return {
loading: false,
error: result.message || 'Subscription failed.',
success: null,
};
}
return {
loading: false,
error: null,
success: 'Successfully subscribed!',
};
} catch (error) {
return {
loading: false,
error: 'Network error. Please try again later.',
success: null,
};
}
};
Step 4: Use the hook inside your component
'use client';
import { useActionState } from 'react';
export default function NewsletterForm() {
const [state, formAction] = useActionState(handleSubmit, initialState);
return (
<form action={formAction}>
<input
type="email"
name="email"
placeholder="Enter your email"
required
/>
<button type="submit" disabled={state.loading}>
{state.loading ? 'Submitting...' : 'Subscribe'}
</button>
{state.error && <p style={{ color: 'red' }}>{state.error}</p>}
{state.success && <p style={{ color: 'green' }}>{state.success}</p>}
</form>
);
}
Tips for Handling API Calls Effectively with useActionState
1. Always validate input before the API call
Validating form values at the top of your reducer function prevents unnecessary network requests and gives users immediate feedback.
2. Keep your reducer pure and focused
Avoid side effects or complex UI logic inside the reducer. Use it only for validation, calling APIs, and returning state. Complex side effects (like redirects or global state updates) should be handled separately.
3. Avoid deeply nested or unnecessary state
Only store what you need: loading, success, error, or result data. Don’t store form values persistently unless required.
type State = {
loading: boolean;
error: string | null;
result: any; // optional, only if API returns something useful
};
4. Disable form while submitting
Prevent duplicate API requests by disabling the submit button while the reducer is processing.
5. Extract API logic into reusable functions
For better code organization, separate API calls into utility functions or services.
async function subscribeUser(email: string) {
const response = await fetch('/api/subscribe', {
method: 'POST',
body: JSON.stringify({ email }),
headers: { 'Content-Type': 'application/json' },
});
return await response.json();
}
Then call subscribeUser
from within your reducer.
6. Handle server errors gracefully
Not all API failures are network errors. Parse JSON responses and check for status codes to inform the user accurately.
7. Use TypeScript for safer form and API interactions
Strongly type the reducer arguments and return types to avoid bugs.
Advanced Example: Login Form with Token Handling
Let’s look at a more advanced example: a login form that sends credentials to an API and stores the token in localStorage
.
type LoginState = {
loading: boolean;
error: string | null;
success: string | null;
};
const loginReducer = async (
prev: LoginState,
formData: FormData
): Promise<LoginState> => {
const email = formData.get('email');
const password = formData.get('password');
if (!email || !password || typeof email !== 'string' || typeof password !== 'string') {
return {
loading: false,
error: 'Email and password are required.',
success: null,
};
}
try {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) {
const err = await res.json();
return {
loading: false,
error: err.message || 'Login failed.',
success: null,
};
}
const data = await res.json();
localStorage.setItem('token', data.token);
return {
loading: false,
error: null,
success: 'Logged in successfully.',
};
} catch {
return {
loading: false,
error: 'Something went wrong.',
success: null,
};
}
};
Use it in the component like this:
const initialLoginState = { loading: false, error: null, success: null };
const [state, formAction] = useActionState(loginReducer, initialLoginState);
return (
<form action={formAction}>
<input name="email" type="email" />
<input name="password" type="password" />
<button type="submit" disabled={state.loading}>
{state.loading ? 'Logging in...' : 'Login'}
</button>
{state.error && <p>{state.error}</p>}
{state.success && <p>{state.success}</p>}
</form>
);
When to Avoid useActionState for API Calls
Although useActionState
is excellent for form submissions tied to user input, it may not be ideal in the following cases:
- When you need to fetch data on component mount (use
useEffect
instead) - When your API calls are unrelated to form submissions
- For real-time data updates like websockets or polling
- For optimistic UI updates where instant feedback is needed
Final Thoughts
The useActionState
hook is a game-changer for managing form-driven API calls in React. It simplifies the logic, avoids excessive state management, and provides a clean structure for handling async operations. Whether you’re building login forms, contact forms, or multi-step workflows, useActionState
makes managing API requests more maintainable and declarative.
As React continues to evolve toward server-centric and declarative patterns, mastering hooks like useActionState
prepares you to build faster, cleaner, and more future-proof React applications.