Ticker

6/recent/ticker-posts

📝 React Forms – Stop Fighting With Form State

React Forms

😵 The Problem

Forms look simple.

A few inputs.

A submit button.

Maybe some validation.

Something like:

Name
Email
Password
[ Create Account ]

But the moment you build a real form, things start getting messy.

You need to know:

  • What did the user type?
  • Is the email valid?
  • Is the password strong enough?
  • What happens when they submit?
  • Should the button be disabled?
  • What should happen while the API request is running?
  • What do we show when something goes wrong?

If you let the browser handle everything by itself, you quickly lose control of the UI.

React gives you a way to keep the form state and the UI in sync.

That's where React Forms come in.

💡 The Solution: Controlled Inputs

One of the most common React patterns is the controlled input.

The basic idea is simple:

React owns the input value.

For example:

import { useState } from "react";

function LoginForm() {
  const [email, setEmail] = useState("");

  return (
    <input
      type="email"
      value={email}
      onChange={(event) => {
        setEmail(event.target.value);
      }}
    />
  );
}

Now there's a clear relationship:

User types
    ↓
onChange fires
    ↓
setEmail()
    ↓
State updates
    ↓
React renders
    ↓
Input gets the new value

The browser is still displaying the input.

But React is now the source of truth for its value.

🧠 Why Control the Input?

You might be wondering:

"Why not just read the value when the form is submitted?"

Sometimes you can.

But controlled inputs become useful when your UI needs to react while the user is typing.

For example:

{email.length > 0 && (
  <p>Email entered.</p>
)}

Or:

<button disabled={!email}>
  Continue
</button>

Or validation:

{email && !email.includes("@") && (
  <p>Enter a valid email.</p>
)}

Because the value lives in React state, your UI can respond immediately.

🔥 Handling Multiple Inputs

A real form usually has more than one field.

You could create separate state variables:

const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");

That's perfectly valid.

But as the form grows, this can become repetitive.

Another common approach is storing the form data in one object:

const [form, setForm] = useState({
  name: "",
  email: "",
  password: ""
});

Then:

function handleChange(event) {
  const { name, value } = event.target;

  setForm(prev => ({
    ...prev,
    [name]: value
  }));
}

Now your inputs can share the same handler:

<input
  name="name"
  value={form.name}
  onChange={handleChange}
/>

<input
  name="email"
  value={form.email}
  onChange={handleChange}
/>

<input
  name="password"
  type="password"
  value={form.password}
  onChange={handleChange}
/>

This pattern is especially useful for forms with several related fields.

🎯 Why [name]: value?

This part can look strange at first:

[name]: value

Suppose:

name = "email"
value = "hello@example.com"

JavaScript effectively creates:

{
  email: "hello@example.com"
}

So if the user changes the password:

name = "password"

the same handler updates:

{
  password: "..."
}

One handler.

Multiple inputs.

Much less repetitive code.

📝 Handling Form Submission

Now let's actually submit the form.

function LoginForm() {
  const [email, setEmail] = useState("");

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

    console.log("Submitting:", email);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(event) => {
          setEmail(event.target.value);
        }}
      />

      <button type="submit">
        Login
      </button>
    </form>
  );
}

Notice we're handling:

onSubmit={handleSubmit}

instead of putting logic directly on the button.

That's usually the better approach.

Why?

Because a form can be submitted in multiple ways.

The user might:

  • click the button
  • press Enter
  • trigger submission programmatically

The form's onSubmit represents the actual submission event.

⚠️ Don't Forget preventDefault()

Browsers have their own default form submission behavior.

In a traditional application, that might mean navigating or reloading the page.

In a React SPA, you usually want React to handle the submission.

So:

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

  // Your logic
}

Without it, you may suddenly see your page reload and wonder:

"Why did my entire React app refresh?" 😅

🔥 Real Example: Registration Form

Let's put everything together.

import { useState } from "react";

function RegisterForm() {
  const [form, setForm] = useState({
    name: "",
    email: "",
    password: ""
  });

  function handleChange(event) {
    const { name, value } = event.target;

    setForm(prev => ({
      ...prev,
      [name]: value
    }));
  }

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

    console.log("Registration data:", form);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="name"
        value={form.name}
        onChange={handleChange}
        placeholder="Name"
      />

      <input
        name="email"
        type="email"
        value={form.email}
        onChange={handleChange}
        placeholder="Email"
      />

      <input
        name="password"
        type="password"
        value={form.password}
        onChange={handleChange}
        placeholder="Password"
      />

      <button type="submit">
        Create Account
      </button>
    </form>
  );
}

Nothing fancy.

But you've already got the foundation for a real React form.

🧠 Validation

Now comes the part every real form needs.

Let's say:

function validate(form) {
  const errors = {};

  if (!form.name.trim()) {
    errors.name = "Name is required";
  }

  if (!form.email.includes("@")) {
    errors.email = "Enter a valid email";
  }

  if (form.password.length < 8) {
    errors.password = "Password must be at least 8 characters";
  }

  return errors;
}

Then during submission:

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

  const errors = validate(form);

  if (Object.keys(errors).length > 0) {
    setErrors(errors);
    return;
  }

  // Submit form
}

The important idea here isn't the exact validation code.

It's the separation.

Your submit handler shouldn't become a giant block of validation rules, API calls, notifications, and navigation.

Keep responsibilities clear.

⏳ Handling Loading State

Now imagine the form calls an API.

You don't want users clicking the submit button five times while the request is running.

So add a loading state:

const [isSubmitting, setIsSubmitting] = useState(false);

Then:

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

  setIsSubmitting(true);

  try {
    await createAccount(form);
  } catch (error) {
    console.error(error);
  } finally {
    setIsSubmitting(false);
  }
}

And the button:

<button
  type="submit"
  disabled={isSubmitting}
>
  {isSubmitting ? "Creating..." : "Create Account"}
</button>

Now the UI communicates what's happening.

Submit
  ↓
Loading
  ↓
API request
  ↓
Success / Error

That's what makes a form feel like a real application instead of a basic HTML demo.

💡 Real Developer Insight

Here's something that becomes painful in larger projects:

Trying to make one form component responsible for everything.

Imagine a 30-field form.

If the component contains:

  • all input state
  • validation
  • API requests
  • error formatting
  • navigation
  • notifications
  • analytics
  • conditional fields
  • formatting logic

…it becomes a monster.

The solution isn't necessarily another 15 hooks.

First, separate responsibilities.

For example:

Form Component
     ↓
User interaction
     ↓
Validation
     ↓
Submit function
     ↓
API/service layer

The component should mostly answer:

"What does the user see, and what happens when they interact with it?"

It shouldn't become your entire business layer.

And for large production forms, libraries such as React Hook Form can reduce boilerplate significantly.

But understanding controlled inputs first is still important.

Otherwise you're just using a library without understanding the problem it solves.

🔄 Controlled vs Uncontrolled Inputs

There are two common approaches.

Controlled

React owns the value:

<input
  value={email}
  onChange={handleChange}
/>

Good when you need:

  • live validation
  • conditional UI
  • instant state access
  • dynamic field behavior

Uncontrolled

The DOM keeps the value:

<input
  ref={inputRef}
/>

You read the value when needed.

This can be simpler for some forms and can reduce the amount of state you manage manually.

Neither approach is universally "better."

The right choice depends on how much control your UI actually needs.

⚠️ Common Developer Mistakes

1. Forgetting name

If you're using a shared change handler:

const { name, value } = event.target;

then your input needs:

name="email"

Otherwise your handler doesn't know which field changed.

2. Mutating Form State Directly

Don't do:

form.email = "hello@example.com"; // ❌

Instead:

setForm(prev => ({
  ...prev,
  email: "hello@example.com"
}));

Treat state as immutable.

3. Forgetting preventDefault()

For client-side form handling:

event.preventDefault();

is usually necessary.

4. Disabling the Button Without Handling Loading Properly

Don't just disable the button and leave users wondering what happened.

Give feedback:

{isSubmitting ? "Saving..." : "Save"}

Good UX matters.

5. Validating Only on the Client

Client-side validation improves UX.

But it isn't security.

Anything important must still be validated on the server.

Never assume:

"The frontend validated it, so the data is safe."

The server should always treat incoming data as untrusted.

🚀 Best Practice Summary

✅ Keep form state predictable and update it immutably
✅ Use onSubmit for form submission instead of relying only on button clicks
✅ Use controlled inputs when the UI needs to react to field values
✅ Separate validation and API logic when forms start becoming complex
✅ Always validate important data on the server, even if client-side validation exists

🎯 Conclusion

React Forms start simple:

Input
  ↓
State
  ↓
Submit

Then the real application arrives.

Validation.

Loading.

Errors.

Conditional fields.

API calls.

Success states.

And suddenly that tiny form isn't so tiny anymore. 😅

The key is not to over-engineer it from day one.

Start with a clear data flow:

User input
    ↓
React state
    ↓
Validation
    ↓
Submit
    ↓
API
    ↓
Success / Error

Once that flow is clear, even complex forms become much easier to reason about.

Good React forms aren't about writing less code. They're about keeping the data flow predictable. 🚀

Reactions

Post a Comment

0 Comments