# Forms That Don’t Break

> **Forms look easy until you build a real one.**

At first it is just a couple of inputs. Then you add validation. Then async checks. Then multi step flows. Then backend errors. And suddenly your “simple form” becomes the most fragile part of your app.

> **<mark class="bg-yellow-200 dark:bg-yellow-500/30">The mental model</mark>**

There are only three moving parts:

*   UI (inputs, dropdowns, radio buttons)
    
*   Validators (pure functions)
    
*   A central form hook
    

If you keep these separate, everything becomes manageable.

> **<mark class="bg-yellow-200 dark:bg-yellow-500/30">Folder structure (keep it small)</mark>**

This is enough for most use cases.

![](https://cdn.hashnode.com/uploads/covers/603254fff51caf4448562e48/84457327-0f8f-4658-8e94-f7fec4135813.png align="center")

### `Step 1: Validators (keep them boring)`

```javascript
// validators/index.js

export const required = (v) => (!v ? "Required" : null);

export const email = (v) =>
  !/^\S+@\S+\.\S+$/.test(v) ? "Invalid email" : null;

export const match = (field) => (v, values) =>
  v !== values[field] ? "Does not match" : null;
```

No React. No side effects. Just logic. This makes them reusable and easy to test.

### `Step 2: The form hook`

```javascript
// hooks/useForm.js
import { useState } from "react";

export function useForm(initialValues, validators) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});

  const setValue = (name, value) => {
    setValues((prev) => ({ ...prev, [name]: value }));
  };

  const validateField = (name) => {
    const value = values[name];
    const rules = validators[name] || [];

    for (let rule of rules) {
      const err = rule(value, values);
      if (err) {
        setErrors((prev) => ({ ...prev, [name]: err }));
        return err;
      }
    }

    setErrors((prev) => ({ ...prev, [name]: null }));
    return null;
  };

  const handleBlur = (name) => {
    setTouched((prev) => ({ ...prev, [name]: true }));
    validateField(name);
  };

  const validateForm = () => {
    let valid = true;

    Object.keys(values).forEach((key) => {
      if (validateField(key)) valid = false;
    });

    return valid;
  };

  return {
    values,
    errors,
    touched,
    setValue,
    handleBlur,
    validateForm,
  };
}
```

### `Step 3: Input components`

```javascript
// components/Input.jsx
export default function Input({ name, label, form }) {
  return (
    <div>
      <label>{label}</label>

      <input
        value={form.values[name] || ""}
        onChange={(e) => form.setValue(name, e.target.value)}
        onBlur={() => form.handleBlur(name)}
      />

      {form.touched[name] && form.errors[name] && (
        <div style={{ color: "red" }}>{form.errors[name]}</div>
      )}
    </div>
  );
}
```

> Dropdown

```javascript
// components/Select.jsx
export default function Select({ name, label, options, form }) {
  return (
    <div>
      <label>{label}</label>

      <select
        value={form.values[name] || ""}
        onChange={(e) => form.setValue(name, e.target.value)}
        onBlur={() => form.handleBlur(name)}
      >
        <option value="">Select</option>
        {options.map((o) => (
          <option key={o.value} value={o.value}>
            {o.label}
          </option>
        ))}
      </select>

      {form.touched[name] && form.errors[name] && (
        <div style={{ color: "red" }}>{form.errors[name]}</div>
      )}
    </div>
  );
}
```

> Radio group

```javascript
// components/RadioGroup.jsx
export default function RadioGroup({ name, label, options, form }) {
  return (
    <div>
      <label>{label}</label>

      {options.map((o) => (
        <label key={o.value}>
          <input
            type="radio"
            value={o.value}
            checked={form.values[name] === o.value}
            onChange={(e) => form.setValue(name, e.target.value)}
            onBlur={() => form.handleBlur(name)}
          />
          {o.label}
        </label>
      ))}

      {form.touched[name] && form.errors[name] && (
        <div style={{ color: "red" }}>{form.errors[name]}           </div>
      )}
    </div>
  );
}
```

### `Step 4: Putting everything together`

```javascript
// App.jsx
import Input from "./components/Input";
import Select from "./components/Select";
import RadioGroup from "./components/RadioGroup";
import { useForm } from "./hooks/useForm";
import { required, email, match } from "./validators";

export default function App() {
  const form = useForm(
    {
      email: "",
      password: "",
      confirm: "",
      role: "",
      gender: "",
    },
    {
      email: [required, email],
      password: [required],
      confirm: [required, match("password")],
      role: [required],
      gender: [required],
    }
  );

  const handleSubmit = () => {
    if (!form.validateForm()) return;
    alert("Submitted!");
  };

  return (
    <div>
      <h2>Signup</h2>

      <Input name="email" label="Email" form={form} />
      <Input name="password" label="Password" form={form} />
      <Input name="confirm" label="Confirm Password" form={form} />

      <Select
        name="role"
        label="Role"
        form={form}
        options={[
          { label: "Frontend", value: "fe" },
          { label: "Backend", value: "be" },
        ]}
      />

      <RadioGroup
        name="gender"
        label="Gender"
        form={form}
        options={[
          { label: "Male", value: "m" },
          { label: "Female", value: "f" },
        ]}
      />

      <button onClick={handleSubmit}>Submit</button>
    </div>
  );
}
```

### <mark class="bg-yellow-200 dark:bg-yellow-500/30">How the flow works</mark>

1.  Every field follows the same lifecycle:
    
2.  User types or selects
    
3.  setValue updates state
    
4.  User leaves field
    
5.  validateField runs
    
6.  Errors update
    
7.  UI reflects it
    

No special handling for dropdowns or radio buttons. Everything follows the same contract.

**<mark class="bg-yellow-200 dark:bg-yellow-500/30">Why this approach works</mark>**

*   One source of truth for data
    
*   Validation is predictable
    
*   UI is clean and reusable
    
*   Easy to extend
    

Most importantly, you are not writing custom logic per field type.

**<mark class="bg-yellow-200 dark:bg-yellow-500/30">How to use this in real projects</mark>**

This works really well for:

*   Signup and login flows
    
*   Onboarding forms
    
*   Internal dashboards
    
*   Admin panels
    
*   Workflow builders
    

If your form has more than 3 fields, this approach already helps.

**<mark class="bg-yellow-200 dark:bg-yellow-500/30">How scalable forms should be designed</mark>**

When things grow, don’t rewrite everything. Extend this system.

*   Multi step forms: validate only current step
    
*   Async validation: add debounce and request control
    
*   Large forms: reduce re-renders
    
*   Dynamic forms: render from config instead of JSX
    

The core stays the same.

**<mark class="bg-yellow-200 dark:bg-yellow-500/30">Improvements you can add</mark>**

Not going deep, but these matter:

*   *schema validation (Zod)*
    
*   *accessibility support*
    
*   *backend error mapping*
    
*   *draft saving*
    
*   *analytics*
    

**<mark class="bg-yellow-200 dark:bg-yellow-500/30">Final thought</mark>**

Forms break when logic is scattered. Keep state in one place. Keep validation simple. Keep UI dumb. Once you get this right, scaling forms stops being painful.
