skip to content

A Formik 2 form's submit button, disabled by isSubmitting, stays disabled forever after the first submit — what in onSubmit causes that?

level: middleimportance: should knowfreq 38%

answer

  1. sync versus promise
  2. who resets the flag
  3. the pre-submit phase
  4. submitCount and touched

basics

~10 s

Formik 2 resets isSubmitting only when onSubmit returns a promise, once it settles. A synchronous onSubmit, or one that starts a request without returning it, leaves isSubmitting true until you call setSubmitting(false).

solid answer

~40 s

On `handleSubmit` or `submitForm`, Formik first touches every field present in `values`, sets `isSubmitting` to `true` and increments `submitCount`; then it runs all validation. If there are errors, it sets `isSubmitting` back to `false` and never calls `onSubmit`. If there are none, it calls `onSubmit(values, formikBag)`. If that returns a promise, Formik sets `isSubmitting` to `false` when it resolves or rejects. If it returns `undefined` — a synchronous handler, or `fetch(...).then(...)` without `return` — Formik assumes you will finish the cycle yourself with `formikBag.setSubmitting(false)`. Forgetting that leaves the button disabled. The fix is to make `onSubmit` `async` and `await` the request, or to call `setSubmitting(false)` in every path.

code

tsx · 27 lines
tsx
import { Field, Form, Formik } from 'formik';

type Comment = { comment: string };

export function CommentForm({ post }: { post: (c: Comment) => Promise<void> }) {
  return (
    <Formik<Comment>
      initialValues={{ comment: '' }}
      onSubmit={async (values, { resetForm, setStatus }) => {
        try {
          await post(values); // awaited: Formik resets isSubmitting when this settles
          resetForm();
        } catch {
          setStatus('Could not post your comment. Try again.');
        }
      }}
    >
      {({ isSubmitting, status }) => (
        <Form>
          <Field name="comment" as="textarea" />
          {status && <p role="alert">{status}</p>}
          <button type="submit" disabled={isSubmitting}>Post</button>
        </Form>
      )}
    </Formik>
  );
}

go deeper

for a junior

Remember the rule: return or await the request in onSubmit, or call setSubmitting(false) yourself.

for a middle

Walk through the three submit phases and explain why Formik leaves the flag alone for synchronous handlers.

for a senior

Handle rejections inside onSubmit with setStatus or setFieldError, keep initialValues complete so submit touches every field, and gate errors on submitCount where needed.

for a principal

Standardise an async submit contract for shared form components so every team gets consistent pending and error behaviour.

## The symptom ```tsx <Formik initialValues={{ comment: '' }} onSubmit={(values) => { api.post('/comments', values).then(showToast); // no return }} > {({ isSubmitting }) => ( <Form> <Field name="comment" as="textarea" /> <button type="submit" disabled={isSubmitting}>Post</button> </Form> )} </Formik> ``` The first click posts the comment. The button then stays disabled for the life of the form. ## What Formik does on submit, in order The Formik submission guide spells out three phases for `handleSubmit(e)` or `submitForm()`: **Pre-submit** 1. Touch all fields — every key present in `values` is marked `true` in `touched`. 2. Set `isSubmitting` to `true`. 3. Increment `submitCount`. **Validation** 4. Set `isValidating` to `true` and run field-level `validate` functions, the `validate` prop and the `validationSchema`, deeply merging the results. 5. If there are errors: set `errors`, set `isSubmitting` to `false`, and **stop** — `onSubmit` is not called. **Submission** 6. Call `onSubmit(values, formikBag)`. 7. If it returned a **promise**, wait for it to resolve or reject, then set `isSubmitting` to `false`. 8. If it returned **nothing**, do nothing more: you are expected to call `setSubmitting(false)`. `handleSubmit` also calls `preventDefault()` on the event, so the page does not reload. ## Why step 8 exists Formik 1 code commonly used callbacks as side effects inside `onSubmit`. If Formik 2 always reset `isSubmitting` right after a synchronous `onSubmit` returned, the flag would drop to `false` before those callbacks finished, and a disabled button would be useless. So Formik treats "no promise" as "the consumer owns the flag". The source comment says so directly: a synchronous handler must clean up with `setSubmitting(false)`. In the symptom above, `onSubmit` starts a promise but does not **return** it, so Formik sees `undefined` and never resets the flag. ## Fixes | Approach | How | Note | |---|---|---| | Return the promise | `onSubmit={(v) => api.post(...)}` | Formik resets on settle | | `async`/`await` | `onSubmit={async (v) => { await api.post(...) }}` | Clearest; errors reject the promise | | Manual reset | call `setSubmitting(false)` in `then` and `catch` | Needed for truly callback-based code | With a returned promise, a **rejection** still resets `isSubmitting`, and the rejection is re-thrown to the caller of `submitForm`. Catch it inside `onSubmit` if you want to show a server error with `setStatus` or `setFieldError` instead. ## The touched detail in pre-submit Step 1 touches the keys present in `values`. A field that was left out of `initialValues` and never changed has **no key**, so it is not touched on submit, and its error stays hidden behind the touched gate even though validation failed. This is why the submission guide says `initialValues` are required and should always be specified. ## Other flags worth knowing - **`isValidating`** is `true` during step 4; the docs say the submission handler is executing when `isSubmitting` is `true` and `isValidating` is `false`. - **`submitCount`** increments on every attempt, valid or not, which makes it a good gate for "show errors after the first submit". - **`formikBag`** passed to `onSubmit` carries helpers such as `setSubmitting`, `setFieldError`, `setStatus` and `resetForm`, but not `errors` or `touched`.

  • In Formik 2, is onSubmit called when validation fails on submit?
    No. Formik touches all fields, sets `isSubmitting`, validates, and if the merged errors object is not empty it sets `errors`, sets `isSubmitting` back to `false` and stops. `onSubmit` runs only for a valid form, so it never needs to re-check the validation rules.
  • How would you show a server-side error for one field after a Formik 2 submit?
    Inside `onSubmit`, catch the failed request and call `setFieldError('email', 'Already registered')` from the formik bag, or `setStatus(...)` for a form-wide message. Because `touched` was set for every field in the pre-submit phase, the field's error displays straight away through `<ErrorMessage>`.

saying these in an interview costs you the question

  • Formik always sets isSubmitting to false after onSubmit returns
  • onSubmit runs even when validation fails, with errors in the bag
  • A request started inside onSubmit is tracked even without returning it
  • Submit only touches the fields the user focused
  • isSubmitting stays true only while validation is running