As React evolves, new patterns and hooks continue to reshape how developers write applications. One such recent addition is the useActionState
hook introduced in React 18.2. It allows you to manage form submissions in a more declarative and streamlined way, particularly when dealing with asynchronous actions like API calls.
While useActionState
makes writing UI code cleaner, testing it—especially unit testing the logic behind it—can be slightly tricky if you’re new to async hooks or custom reducer patterns in React. If you’re using Vitest—a fast unit testing framework designed for modern JavaScript projects—you can confidently write robust tests to validate your reducer logic, check error handling, and ensure side effects behave as expected.
In this blog, we’ll explore how to unit test useActionState
logic using Vitest, from testing the reducer in isolation to simulating form submissions and mocking asynchronous behavior.
What Are We Testing?
It’s important to understand that useActionState
separates two layers:
- The Reducer Function: This is the async function where all your logic lives—validation, API calls, and returning new state.
- The Hook Usage: React uses this reducer to manage state on form submission.
While it’s useful to do full integration testing with forms and UI, in unit tests, your focus should primarily be on testing the reducer function in isolation.
Setting Up the Project
Make sure your project uses:
- React 18.2+
- Vitest
- @testing-library/react (for UI-level testing, optional)
- jsdom (for DOM simulation)
Install required dependencies:
npm install --save-dev vitest @testing-library/react jsdom
In vite.config.ts
or vitest.config.ts
, enable jsdom
:
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
},
})
Example: useActionState Reducer for a Login Form
Here’s a simple async reducer that validates form data and simulates a login API call.
type LoginState = {
loading: boolean
error: string | null
success: string | null
}
export const initialLoginState: LoginState = {
loading: false,
error: null,
success: null,
}
export 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,
}
}
if (password.length < 6) {
return {
loading: false,
error: 'Password must be at least 6 characters long.',
success: null,
}
}
try {
const res = await fakeLoginApi(email, password)
if (!res.ok) {
return {
loading: false,
error: 'Invalid credentials',
success: null,
}
}
return {
loading: false,
error: null,
success: 'Login successful!',
}
} catch (e) {
return {
loading: false,
error: 'Network error',
success: null,
}
}
}
// Fake API function
export async function fakeLoginApi(email: string, password: string) {
if (email === 'test@example.com' && password === 'password123') {
return { ok: true }
} else {
return { ok: false }
}
}
Unit Testing the Reducer with Vitest
Now let’s write unit tests for the loginReducer
logic.
Create a file: loginReducer.test.ts
import { describe, it, expect } from 'vitest'
import { loginReducer, initialLoginState } from './loginReducer'
function createFormData(fields: Record<string, string>) {
const formData = new FormData()
Object.entries(fields).forEach(([key, value]) => {
formData.append(key, value)
})
return formData
}
describe('loginReducer', () => {
it('returns error when email or password is missing', async () => {
const formData = createFormData({})
const result = await loginReducer(initialLoginState, formData)
expect(result.error).toBe('Email and password are required.')
})
it('returns error when password is too short', async () => {
const formData = createFormData({ email: 'user@test.com', password: '123' })
const result = await loginReducer(initialLoginState, formData)
expect(result.error).toBe('Password must be at least 6 characters long.')
})
it('returns error for invalid credentials', async () => {
const formData = createFormData({
email: 'user@test.com',
password: 'wrongpassword',
})
const result = await loginReducer(initialLoginState, formData)
expect(result.error).toBe('Invalid credentials')
})
it('returns success for valid credentials', async () => {
const formData = createFormData({
email: 'test@example.com',
password: 'password123',
})
const result = await loginReducer(initialLoginState, formData)
expect(result.success).toBe('Login successful!')
expect(result.error).toBeNull()
})
})
Testing API Failures with Mocks
Let’s simulate a real API failure using mocking.
First, adjust fakeLoginApi
to live in a separate module, like api.ts
, and export it.
// api.ts
export async function fakeLoginApi(email: string, password: string) {
// simulate fetch
}
In your test, you can mock it:
import { vi } from 'vitest'
import * as api from './api'
it('handles API exceptions', async () => {
vi.spyOn(api, 'fakeLoginApi').mockRejectedValue(new Error('Network error'))
const formData = createFormData({
email: 'test@example.com',
password: 'password123',
})
const result = await loginReducer(initialLoginState, formData)
expect(result.error).toBe('Network error')
})
Optional: Testing with @testing-library/react
If you want to test the full form component with useActionState
, you can use @testing-library/react and simulate user actions.
import { render, screen, fireEvent } from '@testing-library/react'
import NewsletterForm from './NewsletterForm'
it('submits email and shows success', async () => {
render(<NewsletterForm />)
const input = screen.getByPlaceholderText(/enter your email/i)
fireEvent.change(input, { target: { value: 'test@example.com' } })
fireEvent.click(screen.getByText(/subscribe/i))
const successMessage = await screen.findByText(/successfully subscribed/i)
expect(successMessage).toBeInTheDocument()
})
Final Thoughts
Testing useActionState
is all about focusing on the reducer—the core logic where your form data is processed and API calls are made. With Vitest, you can write fast and simple unit tests to validate how your reducer behaves in different scenarios, ensuring that your forms and async logic are bulletproof.
Here’s a quick summary of best practices:
- Keep the reducer pure and testable.
- Use
FormData
mocks to simulate user input. - Mock external APIs when needed.
- Focus on edge cases: empty fields, failed responses, and unexpected errors.
useActionState
may be relatively new, but it’s built on solid patterns React developers already know. With Vitest, it becomes easy to write high-confidence tests that ensure your async form logic stays rock solid—even as your app grows.