Forms are everywhere in modern web applications.
Whether you're building a login page, registration system, checkout process, search interface, or admin dashboard, you'll need to collect information from users and process it correctly.
In React, forms introduce several important concepts:
- Controlled and uncontrolled components.
- Managing input state.
- Handling form submissions.
- Validating user input.
- Displaying validation errors.
- Working with asynchronous operations.
- Fetching data from APIs.
- Handling loading and error states.
- Updating application data.
In this article, we'll explore these concepts step by step and build practical examples that demonstrate how real React applications handle forms and communicate with APIs.
1. Understanding Forms in React
In traditional HTML, a form manages its own input values through the DOM.
For example:
<form>
<input type="text" name="username" />
<button type="submit">
Submit
</button>
</form>
React provides a more flexible approach by allowing components to control input values through state.
This leads to two common approaches:
- Controlled components.
- Uncontrolled components.
Let's understand the difference.
2. Controlled Components
A controlled component is an input element whose value is controlled by React state.
Whenever the user changes the input, an event handler updates the state.
Basic Example
import { useState } from "react";
function UsernameForm() {
const [username, setUsername] = useState("");
return (
<div>
<input
type="text"
value={username}
onChange={event => setUsername(event.target.value)}
placeholder="Enter your username"
/>
<p>Username: {username}</p>
</div>
);
}
export default UsernameForm;
Let's break it down.
Step 1: Create state
const [username, setUsername] = useState("");
The input starts with an empty string.
Step 2: Connect state to the input
value={username}
React controls the displayed value.
Step 3: Update state when the user types
onChange={event => setUsername(event.target.value)}
Every change updates the React state.
Why Use Controlled Components?
Controlled components provide several advantages:
- You always know the current input value.
- You can validate input while the user types.
- You can conditionally disable buttons.
- You can dynamically change input values.
- You can easily reset forms.
- You can display real-time feedback.
They are especially useful for forms with validation and conditional behavior.
3. Handling Multiple Inputs
Real applications usually contain multiple input fields.
Instead of creating separate state variables for every input, you can store form data in an object.
import { useState } from "react";
function RegistrationForm() {
const [formData, setFormData] = useState({
username: "",
email: "",
password: ""
});
function handleChange(event) {
const { name, value } = event.target;
setFormData(previousData => ({
...previousData,
[name]: value
}));
}
return (
<form>
<input
name="username"
value={formData.username}
onChange={handleChange}
placeholder="Username"
/>
<input
name="email"
type="email"
value={formData.email}
onChange={handleChange}
placeholder="Email"
/>
<input
name="password"
type="password"
value={formData.password}
onChange={handleChange}
placeholder="Password"
/>
<pre>{JSON.stringify(formData, null, 2)}</pre>
</form>
);
}
Understanding the Dynamic Property
This expression is especially important:
[name]: value
It uses JavaScript's computed property syntax.
For example, if:
name = "email";
value = "alex@example.com";
Then:
{
...previousData,
[name]: value
}
becomes:
{
username: "Alex",
email: "alex@example.com",
password: ""
}
This pattern makes handling multiple inputs much easier.
4. Handling Form Submission
A form needs a submit handler.
In React, you can listen to the form's onSubmit event.
import { useState } from "react";
function LoginForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
function handleSubmit(event) {
event.preventDefault();
console.log({
email,
password
});
}
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={event => setEmail(event.target.value)}
placeholder="Email"
required
/>
<input
type="password"
value={password}
onChange={event => setPassword(event.target.value)}
placeholder="Password"
required
/>
<button type="submit">
Login
</button>
</form>
);
}
Why Use preventDefault()?
HTML forms normally perform a browser navigation or submission action.
In a typical React single-page application, you usually want to handle the submission using JavaScript.
That's why we use:
event.preventDefault();
It prevents the browser's default form submission behavior.
Why Use type="submit"?
A button inside a form can submit the form when its type is submit.
You can also use:
<button type="button">
Cancel
</button>
This button won't submit the form.
Being explicit about button types prevents unexpected form submissions.
5. Working with Different Input Types
React supports the standard HTML form controls.
Let's explore some common examples.
Text Input
<input
type="text"
value={name}
onChange={event => setName(event.target.value)}
/>
Email Input
<input
type="email"
value={email}
onChange={event => setEmail(event.target.value)}
/>
Password Input
<input
type="password"
value={password}
onChange={event => setPassword(event.target.value)}
/>
Checkbox
Checkboxes use the checked property rather than value to control whether they are selected.
import { useState } from "react";
function TermsCheckbox() {
const [accepted, setAccepted] = useState(false);
return (
<label>
<input
type="checkbox"
checked={accepted}
onChange={event => setAccepted(event.target.checked)}
/>
I accept the terms and conditions.
</label>
);
}
Notice:
event.target.checked
This returns a boolean.
Radio Buttons
import { useState } from "react";
function GenderForm() {
const [gender, setGender] = useState("");
return (
<div>
<label>
<input
type="radio"
name="gender"
value="male"
checked={gender === "male"}
onChange={event => setGender(event.target.value)}
/>
Male
</label>
<label>
<input
type="radio"
name="gender"
value="female"
checked={gender === "female"}
onChange={event => setGender(event.target.value)}
/>
Female
</label>
<p>Selected: {gender}</p>
</div>
);
}
Select Dropdown
import { useState } from "react";
function CountrySelect() {
const [country, setCountry] = useState("");
return (
<div>
<select
value={country}
onChange={event => setCountry(event.target.value)}
>
<option value="">Select a country</option>
<option value="germany">Germany</option>
<option value="france">France</option>
<option value="canada">Canada</option>
</select>
<p>Selected country: {country}</p>
</div>
);
}
Textarea
<textarea
value={message}
onChange={event => setMessage(event.target.value)}
placeholder="Write your message..."
/>
In React, use the value prop to control a textarea rather than placing its initial content between the opening and closing tags.
6. Uncontrolled Components
Unlike controlled components, uncontrolled components store their current values in the DOM.
React accesses those values when needed, commonly through refs.
Example
import { useRef } from "react";
function UncontrolledForm() {
const usernameRef = useRef(null);
function handleSubmit(event) {
event.preventDefault();
console.log(usernameRef.current.value);
}
return (
<form onSubmit={handleSubmit}>
<input
ref={usernameRef}
type="text"
defaultValue="Alex"
/>
<button type="submit">
Submit
</button>
</form>
);
}
Here:
defaultValue="Alex"
sets the initial value.
The DOM manages subsequent changes.
Controlled vs Uncontrolled Components
| Feature | Controlled | Uncontrolled |
|---|---|---|
| Value managed by | React state | DOM |
| Main API | value | defaultValue |
| Change handling | onChange | Optional |
| Access current value | State | Ref |
| Real-time validation | Easy | Requires additional handling |
| Dynamic behavior | Flexible | Less direct |
When Should You Use Each?
Use controlled components when:
- You need real-time validation.
- Input values affect other UI elements.
- You need conditional form behavior.
- You want predictable React state.
Use uncontrolled components when:
- You only need the values at submission time.
- You're integrating with traditional HTML forms.
- You're working with file inputs.
- You want a simpler form with minimal React state.
7. Form Validation
Validation ensures that users submit acceptable information.
For example:
- Email must be valid.
- Password must meet minimum requirements.
- Username cannot be empty.
- Required fields must be completed.
- Password confirmation must match.
Let's build a simple validation system.
Basic Validation Example
function validateForm(values) {
const errors = {};
if (!values.username.trim()) {
errors.username = "Username is required";
}
if (!values.email.trim()) {
errors.email = "Email is required";
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) {
errors.email = "Please enter a valid email";
}
if (values.password.length < 8) {
errors.password = "Password must contain at least 8 characters";
}
return errors;
}
The function receives form values and returns an object containing validation errors.
If there are no errors, the returned object is empty.
Using Validation in a React Component
import { useState } from "react";
function RegistrationForm() {
const [values, setValues] = useState({
username: "",
email: "",
password: ""
});
const [errors, setErrors] = useState({});
function handleChange(event) {
const { name, value } = event.target;
setValues(previous => ({
...previous,
[name]: value
}));
}
function handleSubmit(event) {
event.preventDefault();
const validationErrors = validateForm(values);
setErrors(validationErrors);
if (Object.keys(validationErrors).length === 0) {
console.log("Form submitted:", values);
}
}
return (
<form onSubmit={handleSubmit}>
<div>
<input
name="username"
value={values.username}
onChange={handleChange}
placeholder="Username"
/>
{errors.username && (
<p>{errors.username}</p>
)}
</div>
<div>
<input
name="email"
type="email"
value={values.email}
onChange={handleChange}
placeholder="Email"
/>
{errors.email && (
<p>{errors.email}</p>
)}
</div>
<div>
<input
name="password"
type="password"
value={values.password}
onChange={handleChange}
placeholder="Password"
/>
{errors.password && (
<p>{errors.password}</p>
)}
</div>
<button type="submit">
Register
</button>
</form>
);
}
This is a simple validation approach. In larger applications, you may want to validate fields as users interact with them and show errors only after a field has been touched or the form has been submitted.
Client-Side vs Server-Side Validation
Client-side validation improves the user experience.
However, it is not a security boundary.
Users can bypass browser validation and send requests directly to your server.
Always validate important data on the backend as well.
Never trust client-submitted information simply because it passed frontend validation.
8. Handling Asynchronous Form Submission
Many forms communicate with a backend.
For example:
- Login requests.
- Registration requests.
- Contact forms.
- Payment forms.
- Profile updates.
These operations are asynchronous.
You need to handle at least three states:
- Loading.
- Success.
- Error.
Example: Login Form
import { useState } from "react";
function LoginForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
async function handleSubmit(event) {
event.preventDefault();
setLoading(true);
setError("");
setSuccess("");
try {
const response = await fetch("/api/login", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
email,
password
})
});
if (!response.ok) {
throw new Error("Login failed");
}
const data = await response.json();
setSuccess("Login successful!");
console.log(data);
} catch (error) {
setError(error.message);
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={event => setEmail(event.target.value)}
placeholder="Email"
required
/>
<input
type="password"
value={password}
onChange={event => setPassword(event.target.value)}
placeholder="Password"
required
/>
<button type="submit" disabled={loading}>
{loading ? "Logging in..." : "Login"}
</button>
{error && <p>{error}</p>}
{success && <p>{success}</p>}
</form>
);
}
Understanding the Fetch Request
Let's examine the important parts.
const response = await fetch("/api/login", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
email,
password
})
});
method
Specifies the HTTP method.
Common methods include:
- GET
- POST
- PUT
- PATCH
- DELETE
headers
Provides metadata about the request.
Here, we're telling the server that the request body contains JSON.
body
Contains the information sent to the server.
JSON.stringify() converts a JavaScript object into a JSON string.
Why Check response.ok?
The fetch() function does not automatically reject its Promise when the server returns an HTTP error such as 404 or 500.
That's why we check:
if (!response.ok) {
throw new Error("Login failed");
}
This allows our catch block to handle unsuccessful HTTP responses.
9. Understanding the Fetch API
The Fetch API is a built-in browser API for making HTTP requests.
Let's look at common operations.
GET — Fetch Data
async function getUsers() {
const response = await fetch("/api/users");
if (!response.ok) {
throw new Error("Could not fetch users");
}
return response.json();
}
POST — Create Data
async function createUser(user) {
const response = await fetch("/api/users", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(user)
});
if (!response.ok) {
throw new Error("Could not create user");
}
return response.json();
}
PUT — Replace Data
async function updateUser(id, user) {
const response = await fetch(`/api/users/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(user)
});
if (!response.ok) {
throw new Error("Could not update user");
}
return response.json();
}
PATCH — Partially Update Data
async function updateUserEmail(id, email) {
const response = await fetch(`/api/users/${id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ email })
});
if (!response.ok) {
throw new Error("Could not update email");
}
return response.json();
}
DELETE — Remove Data
async function deleteUser(id) {
const response = await fetch(`/api/users/${id}`, {
method: "DELETE"
});
if (!response.ok) {
throw new Error("Could not delete user");
}
}
These examples assume the backend supports the corresponding endpoints and request formats.
10. Fetching Data Inside useEffect
A common React pattern is fetching data when a component needs to display it.
Let's build a users list.
import { useEffect, useState } from "react";
function UsersList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
const controller = new AbortController();
async function fetchUsers() {
try {
setLoading(true);
setError("");
const response = await fetch(
"https://jsonplaceholder.typicode.com/users",
{
signal: controller.signal
}
);
if (!response.ok) {
throw new Error("Failed to load users");
}
const data = await response.json();
setUsers(data);
} catch (error) {
if (error.name !== "AbortError") {
setError(error.message);
}
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}
fetchUsers();
return () => {
controller.abort();
};
}, []);
if (loading) {
return <p>Loading...</p>;
}
if (error) {
return <p>Error: {error}</p>;
}
return (
<div>
<h2>Users</h2>
{users.map(user => (
<div key={user.id}>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
))}
</div>
);
}
export default UsersList;
Why Use AbortController?
Suppose a user navigates away from the page while a request is still running.
The request may no longer be useful.
AbortController allows us to cancel the request when the Effect is cleaned up.
This is especially useful when working with requests that depend on changing component inputs.
What Happens When Data Changes?
Imagine fetching a user's profile based on a userId prop.
useEffect(() => {
// Fetch data for the current userId
}, [userId]);
When userId changes:
- React runs the previous Effect's cleanup.
- The previous request can be cancelled.
- React runs the new Effect.
- A request for the new user can begin.
This helps prevent outdated requests from interfering with the current UI.
11. Building a Search Interface with an API
Search functionality is common in real-world applications.
Let's build a search component that requests data from an API.
import { useEffect, useState } from "react";
function SearchUsers() {
const [query, setQuery] = useState("");
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!query.trim()) {
setUsers([]);
setLoading(false);
setError("");
return;
}
const controller = new AbortController();
const timeoutId = setTimeout(async () => {
try {
setLoading(true);
setError("");
const response = await fetch(
`/api/users?search=${encodeURIComponent(query)}`,
{
signal: controller.signal
}
);
if (!response.ok) {
throw new Error("Search failed");
}
const data = await response.json();
setUsers(data);
} catch (error) {
if (error.name !== "AbortError") {
setError(error.message);
}
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}, 400);
return () => {
clearTimeout(timeoutId);
controller.abort();
};
}, [query]);
return (
<div>
<input
value={query}
onChange={event => setQuery(event.target.value)}
placeholder="Search users..."
/>
{loading && <p>Searching...</p>}
{error && <p>{error}</p>}
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
Understanding Debouncing
Notice this part:
setTimeout(async () => {
// API request
}, 400);
The request waits for 400 milliseconds after the user stops typing.
This technique is called debouncing.
Without debouncing, typing a search query such as:
react
could trigger multiple requests:
r
re
rea
reac
react
Debouncing reduces unnecessary requests.
The cleanup function cancels the previous timer and request when the query changes.
12. Updating Data After an API Request
Fetching data is only part of API integration.
Applications also need to create, update, and delete records.
Let's build a small example that adds a new user.
import { useState } from "react";
function CreateUser() {
const [name, setName] = useState("");
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
async function handleSubmit(event) {
event.preventDefault();
setLoading(true);
setMessage("");
try {
const response = await fetch("/api/users", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ name })
});
if (!response.ok) {
throw new Error("Could not create user");
}
const newUser = await response.json();
setMessage(`Created user: ${newUser.name}`);
setName("");
} catch (error) {
setMessage(error.message);
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={event => setName(event.target.value)}
placeholder="User name"
required
/>
<button disabled={loading}>
{loading ? "Creating..." : "Create User"}
</button>
{message && <p>{message}</p>}
</form>
);
}
In a real application, you would also update the displayed users list after successfully creating the record.
This can be done by:
- Updating local React state.
- Refreshing the data.
- Invalidating a query cache.
- Updating a shared state store.
13. Handling File Uploads
File uploads are another common form requirement.
React can access selected files through the input element.
import { useState } from "react";
function FileUpload() {
const [file, setFile] = useState(null);
const [message, setMessage] = useState("");
function handleFileChange(event) {
setFile(event.target.files[0] ?? null);
}
async function handleSubmit(event) {
event.preventDefault();
if (!file) {
setMessage("Please select a file.");
return;
}
const formData = new FormData();
formData.append("file", file);
try {
const response = await fetch("/api/upload", {
method: "POST",
body: formData
});
if (!response.ok) {
throw new Error("Upload failed");
}
setMessage("File uploaded successfully!");
} catch (error) {
setMessage(error.message);
}
}
return (
<form onSubmit={handleSubmit}>
<input
type="file"
onChange={handleFileChange}
/>
<button type="submit">
Upload
</button>
{message && <p>{message}</p>}
</form>
);
}
Important: Don't Set Content-Type Manually for FormData
When sending FormData, the browser automatically sets the appropriate multipart content type and boundary.
Avoid doing this:
headers: {
"Content-Type": "multipart/form-data"
}
Let the browser generate it.
Also remember that file type and size checks in the frontend are not sufficient security controls. The server must validate uploaded files.
14. Using FormData for Traditional Form Submission
For some forms, you don't need to manage every input through separate React state.
The browser's FormData API can collect form values.
function ContactForm() {
function handleSubmit(event) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const values = Object.fromEntries(formData.entries());
console.log(values);
}
return (
<form onSubmit={handleSubmit}>
<input
name="name"
placeholder="Your name"
required
/>
<input
name="email"
type="email"
placeholder="Your email"
required
/>
<textarea
name="message"
placeholder="Your message"
required
/>
<button type="submit">
Send Message
</button>
</form>
);
}
This approach is particularly convenient for forms where you only need the values when the user submits.
Remember that unchecked checkboxes and unselected controls may not appear in the resulting FormData.
15. Organizing API Logic
As an application grows, placing every API request directly inside components can make the code difficult to maintain.
A better approach is to separate API logic into dedicated modules.
For example:
src/
├── components/
│ ├── UserList.jsx
│ └── CreateUser.jsx
│
├── services/
│ └── userService.js
│
├── hooks/
│ └── useUsers.js
│
├── App.jsx
└── main.jsx
Step 1: Create an API Service
services/userService.js
const API_URL = "/api/users";
export async function getUsers(signal) {
const response = await fetch(API_URL, { signal });
if (!response.ok) {
throw new Error("Failed to fetch users");
}
return response.json();
}
export async function createUser(user) {
const response = await fetch(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(user)
});
if (!response.ok) {
throw new Error("Failed to create user");
}
return response.json();
}
export async function deleteUser(id) {
const response = await fetch(`${API_URL}/${id}`, {
method: "DELETE"
});
if (!response.ok) {
throw new Error("Failed to delete user");
}
}
Step 2: Create a Custom Hook
hooks/useUsers.js
import { useEffect, useState } from "react";
import { getUsers } from "../services/userService";
export function useUsers() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
const controller = new AbortController();
async function loadUsers() {
try {
const data = await getUsers(controller.signal);
setUsers(data);
} catch (error) {
if (error.name !== "AbortError") {
setError(error.message);
}
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}
loadUsers();
return () => controller.abort();
}, []);
return {
users,
loading,
error
};
}
Step 3: Use the Hook in a Component
components/UserList.jsx
import { useUsers } from "../hooks/useUsers";
function UserList() {
const {
users,
loading,
error
} = useUsers();
if (loading) {
return <p>Loading users...</p>;
}
if (error) {
return <p>{error}</p>;
}
return (
<ul>
{users.map(user => (
<li key={user.id}>
{user.name}
</li>
))}
</ul>
);
}
export default UserList;
This structure separates responsibilities:
- Components handle presentation.
- Services handle HTTP requests.
- Custom Hooks manage reusable React logic.
For larger applications, you may also introduce a dedicated API client and a data-fetching library.
16. React Query and Modern Data Fetching
As applications become more complex, manually managing every API request can become repetitive.
You may need to handle:
- Caching.
- Background refetching.
- Request deduplication.
- Retries.
- Pagination.
- Infinite scrolling.
- Optimistic updates.
- Synchronizing server data.
A popular solution is TanStack Query, formerly known as React Query.
It provides tools for managing asynchronous server state.
Installing TanStack Query
npm install @tanstack/react-query
Configure the Query Client
import {
QueryClient,
QueryClientProvider
} from "@tanstack/react-query";
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<Users />
</QueryClientProvider>
);
}
export default App;
Fetch Data with useQuery
import { useQuery } from "@tanstack/react-query";
async function fetchUsers() {
const response = await fetch("/api/users");
if (!response.ok) {
throw new Error("Failed to fetch users");
}
return response.json();
}
function Users() {
const {
data,
isLoading,
isError,
error
} = useQuery({
queryKey: ["users"],
queryFn: fetchUsers
});
if (isLoading) {
return <p>Loading...</p>;
}
if (isError) {
return <p>{error.message}</p>;
}
return (
<ul>
{data.map(user => (
<li key={user.id}>
{user.name}
</li>
))}
</ul>
);
}
Why Use TanStack Query?
It helps manage server state separately from local component state.
For example:
-
useStateis great for a modal's open/closed state. - TanStack Query is useful for managing users fetched from an API.
They solve different problems and can work together.
17. Common Mistakes When Working with Forms and APIs
Let's review mistakes that frequently cause problems.
Mistake 1: Forgetting event.preventDefault()
Incorrect:
function handleSubmit(event) {
console.log("Submitted");
}
Correct:
function handleSubmit(event) {
event.preventDefault();
console.log("Submitted");
}
Without preventing the default behavior, the browser may navigate or reload the page.
Mistake 2: Forgetting the onChange Handler
Incorrect:
<input value={name} />
Correct:
<input
value={name}
onChange={event => setName(event.target.value)}
/>
A controlled input needs a way to update its state.
Mistake 3: Using value Instead of checked for Checkboxes
Incorrect:
<input type="checkbox" value={accepted} />
Correct:
<input
type="checkbox"
checked={accepted}
onChange={event => setAccepted(event.target.checked)}
/>
Mistake 4: Assuming Fetch Rejects on HTTP Errors
Incorrect:
const response = await fetch("/api/users");
const data = await response.json();
Better:
const response = await fetch("/api/users");
if (!response.ok) {
throw new Error("Request failed");
}
const data = await response.json();
Mistake 5: Forgetting Loading States
Users should know when an operation is running.
Use a loading state to prevent duplicate submissions and provide feedback.
Mistake 6: Ignoring Errors
Always consider network failures and unsuccessful server responses.
A good application should display meaningful error messages.
Mistake 7: Not Cancelling Outdated Requests
When requests depend on changing values, consider using AbortController or another strategy to prevent stale results from updating the UI.
Mistake 8: Trusting Frontend Validation
Frontend validation improves usability, but backend validation is essential for security and data integrity.
Mistake 9: Hardcoding Sensitive API Credentials
Never put private API keys or secrets inside frontend code.
Anything bundled into a browser application can be inspected by users.
Keep sensitive credentials on the server.
18. Practical Project: Build a Contact Form with API Integration
Let's combine the concepts into a more complete example.
Our contact form will include:
- Controlled inputs.
- Form validation.
- Loading state.
- API submission.
- Success feedback.
- Error handling.
- Input reset.
import { useState } from "react";
function ContactForm() {
const [formData, setFormData] = useState({
name: "",
email: "",
message: ""
});
const [errors, setErrors] = useState({});
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState("");
function handleChange(event) {
const { name, value } = event.target;
setFormData(previous => ({
...previous,
[name]: value
}));
}
function validate() {
const newErrors = {};
if (!formData.name.trim()) {
newErrors.name = "Name is required";
}
if (!formData.email.trim()) {
newErrors.email = "Email is required";
} else if (
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)
) {
newErrors.email = "Enter a valid email";
}
if (!formData.message.trim()) {
newErrors.message = "Message is required";
}
return newErrors;
}
async function handleSubmit(event) {
event.preventDefault();
setStatus("");
const validationErrors = validate();
setErrors(validationErrors);
if (Object.keys(validationErrors).length > 0) {
return;
}
setLoading(true);
try {
const response = await fetch("/api/contact", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(formData)
});
if (!response.ok) {
throw new Error("Failed to send message");
}
setStatus("Your message was sent successfully!");
setFormData({
name: "",
email: "",
message: ""
});
setErrors({});
} catch (error) {
setStatus(error.message);
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit}>
<h2>Contact Us</h2>
<div>
<label htmlFor="name">Name</label>
<input
id="name"
name="name"
value={formData.name}
onChange={handleChange}
/>
{errors.name && <p>{errors.name}</p>}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
/>
{errors.email && <p>{errors.email}</p>}
</div>
<div>
<label htmlFor="message">Message</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
/>
{errors.message && <p>{errors.message}</p>}
</div>
<button type="submit" disabled={loading}>
{loading ? "Sending..." : "Send Message"}
</button>
{status && <p role="status">{status}</p>}
</form>
);
}
export default ContactForm;
This example is a good starting point for building real forms.
For production, you should also consider:
- Server-side validation.
- Rate limiting and spam protection.
- Accessible error announcements.
- Secure handling of submitted information.
- Appropriate success and failure responses.
- Preventing duplicate submissions on the backend.
19. Best Practices for React Forms and API Integration
Here are some practical recommendations.
1. Keep form state predictable.
Use controlled inputs when the UI needs to respond to input changes.
2. Separate validation logic.
Keep validation functions outside the component when possible.
3. Always handle loading and error states.
Don't leave users wondering whether an operation succeeded.
4. Separate API services from UI components.
This makes code easier to test and maintain.
5. Cancel outdated requests when appropriate.
Use AbortController for requests that should stop when their Effect is cleaned up.
6. Use appropriate data-fetching tools.
For complex server-state requirements, consider TanStack Query or framework-provided data-loading features.
7. Validate data on the server.
Never depend exclusively on frontend validation.
8. Make forms accessible.
Use labels, meaningful error messages, keyboard-friendly controls, and appropriate semantic HTML.
9. Avoid storing unnecessary duplicate state.
If a value can be calculated from existing state, calculate it rather than maintaining another synchronized state variable.
10. Never expose private credentials in frontend code.
Sensitive operations should be handled by a secure backend.
20. Final Thoughts
Forms and API integration are essential skills for every React developer.
Understanding how to manage input values, validate user data, submit forms, and communicate with APIs allows you to build applications that go beyond static interfaces.
The most important concepts from this article are:
- Controlled and uncontrolled components.
- Managing multiple inputs.
- Handling form submissions.
- Validating user input.
- Working with asynchronous operations.
- Fetching and updating API data.
- Managing loading and error states.
- Organizing API services.
- Using TanStack Query for server-state management.
Don't just memorize these patterns.
Build a registration form, create a contact page, connect an application to an API, and experiment with different validation strategies.
The more you practice, the more natural these patterns will become.
What's Next?
In Part 5 of the React Mastery Series, we'll explore:
React Architecture & Best Practices — Building Maintainable and Scalable Applications
We'll cover:
- Organizing React project structures.
- Component architecture.
- Container and presentation components.
- Reusable components.
- Composition patterns.
- Separation of concerns.
- Custom Hooks and service layers.
- Error boundaries.
- Environment variables.
- Clean code principles.
- Scaling React applications.
We'll move from writing individual components to designing complete React applications.
Happy coding! 🚀
Top comments (0)