DEV Community

Cover image for React Mastery Series — Part 4: React Forms & API Integration
Hamed Farazi
Hamed Farazi

Posted on

React Mastery Series — Part 4: React Forms & API Integration

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>
Enter fullscreen mode Exit fullscreen mode

React provides a more flexible approach by allowing components to control input values through state.

This leads to two common approaches:

  1. Controlled components.
  2. 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;
Enter fullscreen mode Exit fullscreen mode

Let's break it down.

Step 1: Create state

const [username, setUsername] = useState("");
Enter fullscreen mode Exit fullscreen mode

The input starts with an empty string.

Step 2: Connect state to the input

value={username}
Enter fullscreen mode Exit fullscreen mode

React controls the displayed value.

Step 3: Update state when the user types

onChange={event => setUsername(event.target.value)}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Understanding the Dynamic Property

This expression is especially important:

[name]: value
Enter fullscreen mode Exit fullscreen mode

It uses JavaScript's computed property syntax.

For example, if:

name = "email";
value = "alex@example.com";
Enter fullscreen mode Exit fullscreen mode

Then:

{
  ...previousData,
  [name]: value
}
Enter fullscreen mode Exit fullscreen mode

becomes:

{
  username: "Alex",
  email: "alex@example.com",
  password: ""
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)}
/>
Enter fullscreen mode Exit fullscreen mode

Email Input

<input
  type="email"
  value={email}
  onChange={event => setEmail(event.target.value)}
/>
Enter fullscreen mode Exit fullscreen mode

Password Input

<input
  type="password"
  value={password}
  onChange={event => setPassword(event.target.value)}
/>
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notice:

event.target.checked
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Textarea

<textarea
  value={message}
  onChange={event => setMessage(event.target.value)}
  placeholder="Write your message..."
/>
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Here:

defaultValue="Alex"
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Loading.
  2. Success.
  3. 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
  })
});
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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");
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

When userId changes:

  1. React runs the previous Effect's cleanup.
  2. The previous request can be cancelled.
  3. React runs the new Effect.
  4. 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Understanding Debouncing

Notice this part:

setTimeout(async () => {
  // API request
}, 400);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

could trigger multiple requests:

r
re
rea
reac
react
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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");
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  };
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why Use TanStack Query?

It helps manage server state separately from local component state.

For example:

  • useState is 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");
}
Enter fullscreen mode Exit fullscreen mode

Correct:

function handleSubmit(event) {
  event.preventDefault();

  console.log("Submitted");
}
Enter fullscreen mode Exit fullscreen mode

Without preventing the default behavior, the browser may navigate or reload the page.

Mistake 2: Forgetting the onChange Handler

Incorrect:

<input value={name} />
Enter fullscreen mode Exit fullscreen mode

Correct:

<input
  value={name}
  onChange={event => setName(event.target.value)}
/>
Enter fullscreen mode Exit fullscreen mode

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} />
Enter fullscreen mode Exit fullscreen mode

Correct:

<input
  type="checkbox"
  checked={accepted}
  onChange={event => setAccepted(event.target.checked)}
/>
Enter fullscreen mode Exit fullscreen mode

Mistake 4: Assuming Fetch Rejects on HTTP Errors

Incorrect:

const response = await fetch("/api/users");

const data = await response.json();
Enter fullscreen mode Exit fullscreen mode

Better:

const response = await fetch("/api/users");

if (!response.ok) {
  throw new Error("Request failed");
}

const data = await response.json();
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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)