Where stray thoughts land,

Buttons inside a <form> default to submit.

The story of an Add-tag button that kept submitting the reply form.

On the LoanTalk admin page, while drafting a reply, clicking the “Add tag” button inside a modal would silently submit the entire reply form. The fix turned out to be a single attribute, but the HTML detail it surfaced — and the guards we built to catch the next one — were worth writing down.

1. Diagnose

The first thing to narrow down was “which action is actually submitting the form.” The reply-drafting area is wrapped in react-hook-form’s <FormProvider>, with the answer textarea, tag manager, and tag-creation modal all sitting inside as children.

I set a breakpoint on the form’s submit event in DevTools’ Event Listeners panel and clicked Add tag again. The submit event fired, and its target was the outer form itself. So a tiny button inside a modal was triggering the form’s submit handler.

That gave me a strong hunch. An HTML <button> element defaults to type="submit" when type isn’t specified — and not a single button in the modal had type declared. The styled-components wrappers made them look like all sorts of buttons, but once flattened to DOM they were all type="submit". After the onClick handler ran, the browser’s default behavior went on to fire the form’s submit, exactly as the spec says it should.

2. Reproduce

To make sure that was really the cause, I stripped away all the libraries and built a minimal reproduction.

function Demo() {
  return (
    <form onSubmit={(e) => { e.preventDefault(); alert('submitted!') }}>
      <input placeholder="reply body" />
      <button onClick={() => alert('Add tag clicked')}>
        Add tag
      </button>
    </form>
  )
}

Click “Add tag” once, and two alerts fire in sequence — first “Add tag clicked,” then “submitted!” The onClick handler runs, and then the browser’s default behavior submits the outer form.

Change the <button>’s type to "button", and the second alert never fires. A one-line difference completely changes the runtime behavior.

<button type="button" onClick={...}>Add tag</button>

The hypothesis was correct. Every button in the modal had been falling into the same trap.

3. Fix

The real-world fix was just as small. Add type="button" to every click-only button in the modal.

// before
<CreateButton onClick={handleCreateTag}>Add</CreateButton>
<CancelButton onClick={handleClose}>Cancel</CancelButton>
<SaveButton onClick={handleSave}>Save</SaveButton>

// after
<CreateButton type="button" onClick={handleCreateTag}>Add</CreateButton>
<CancelButton type="button" onClick={handleClose}>Cancel</CancelButton>
<SaveButton type="button" onClick={handleSave}>Save</SaveButton>

There were two other ways I could’ve fixed it. Call e.preventDefault() from inside the onClick, or e.stopPropagation() to cut off the event bubbling entirely. Either would stop the immediate problem. But both of those approaches stop the accident just before it happens. Add a similar button six months from now and it walks right into the same trap. Declaring type="button" is different in spirit — you’re saying, structurally, that this button isn’t a submit button. The bug never has a chance to start.

When an event-propagation bug has more than one place you could intercept it, the innermost point — the source — is almost always the cleanest fix.

4. Prevent recurrence

This class of bug is too common to keep catching with debugging. We added a few guards.

First, we turned on the ESLint rule react/button-has-type. It warns at build time whenever a <button> has no type attribute. When the default behavior is dangerous, letting the linter watch it is by far the most reliable defense — human attention is the weakest safety net.

Second, we considered marking type as required on the props of our common Button components. If your styled-components forward props through, you can require type at the type level so a missing one fails the type check. We had too many existing buttons to flip the switch in one go, so we’re applying it forward — every new shared Button has the constraint baked in.

Third, code review checklists got an explicit “if there’s a new button inside a <form>, is the type set?” line. But review is a backup at best. Automated checks are more trustworthy because they don’t get tired.

HTML defaults sometimes work in the opposite direction of intuition. When that’s the case, blocking the bug in one place is fine — but it’s far safer to let the tools remind you, every time, that a dangerous default is sitting just under your feet.