Framework Conversion Fixes

How to Cut Signup Abandonment with a Remix Action and useFetcher

TL;DR

A Remix action validates your signup server-side and returns field-level errors, so the form works without client JavaScript. useFetcher submits it without a full navigation and exposes the returned errors inline, while preserving the entered values. That removes the lost-input and JS-dependency causes of abandonment.

A Remix signup form leaks users when it clears on error or depends entirely on client JavaScript. Validate in a route action, return field-level errors, and submit with useFetcher — the form works without JS, errors show inline without a reload, and the entered values are preserved.

Why an action plus useFetcher

The Remix-specific construct is the route action: a server function a form posts to. As a native HTML submission it works before hydration. useFetcher then submits it via fetch without navigating away — perfect for an inline signup that should stay put and show errors in place — and exposes the returned data and a submission state. That's progressive enhancement built into the framework.

The action

Return both errors and the submitted values so the form can repopulate:

// app/routes/signup.tsx
import { json, redirect } from '@remix-run/node'
import type { ActionFunctionArgs } from '@remix-run/node'

export async function action({ request }: ActionFunctionArgs) {
  const form = await request.formData()
  const email = String(form.get('email') ?? '')
  const password = String(form.get('password') ?? '')

  const errors: Record<string, string> = {}
  if (!email.includes('@')) errors.email = 'Enter a valid email.'
  if (password.length < 8) errors.password = 'At least 8 characters.'

  if (Object.keys(errors).length) {
    return json({ errors, values: { email } }, { status: 400 })
  }

  // create the account here…
  return redirect('/welcome')
}

Returning values: { email } is what lets the form refill the email after a failed submit.

The form with useFetcher

import { useFetcher } from '@remix-run/react'

export default function Signup() {
  const fetcher = useFetcher<typeof action>()
  const data = fetcher.data
  const busy = fetcher.state !== 'idle'

  return (
    <fetcher.Form method="post" action="/signup">
      <input name="email" type="email" defaultValue={data?.values?.email ?? ''} required />
      {data?.errors?.email && <p className="error">{data.errors.email}</p>}

      <input name="password" type="password" required />
      {data?.errors?.password && <p className="error">{data.errors.password}</p>}

      <button disabled={busy}>{busy ? 'Creating…' : 'Create account'}</button>
    </fetcher.Form>
  )
}

<fetcher.Form> submits without a navigation, defaultValue repopulates the email, and fetcher.state lets you disable the button while the request is in flight so impatient users don't double-submit. If JavaScript hasn't loaded, the form still posts to /signup natively.

Steps to apply it

  1. Add an action to the signup route that returns json({ errors, values }) on failure.
  2. Render the form with <fetcher.Form method="post"> so it submits in place.
  3. Set each input's defaultValue from fetcher.data.values to preserve input.
  4. Disable the submit button while fetcher.state !== 'idle'.
  5. Fire signup_started on first input and signup_completed on the redirect.

Measure the result

SELECT
  countDistinctIf(person_id, event = 'signup_started') AS started,
  countDistinctIf(person_id, event = 'signup_completed') AS completed,
  round(
    countDistinctIf(person_id, event = 'signup_completed')
    / countDistinctIf(person_id, event = 'signup_started') * 100,
  1) AS completion_rate_pct
FROM events
WHERE timestamp > now() - INTERVAL 30 DAY

Illustrative sample output:

started completed completion_rate_pct
1,300 910 70.0

A 70% completion rate is healthy; track it after the change to confirm the improvement. If you'd rather have the weakest point found and shipped as a Pull Request, that's what Velyr does.

Frequently asked questions

What is a Remix action?

An action is a server-side function exported from a route module that handles non-GET requests. A form posts to it as a native HTML submission, so it works without client JavaScript; Remix then progressively enhances the form to submit via fetch without a full page reload.

Why use useFetcher instead of a plain Remix Form?

useFetcher submits without changing the route or triggering navigation, which is ideal for an inline signup that should stay on the same page and show errors in place. It exposes the action's returned data and a state you can use to disable the button while submitting.

How do I keep signup values after an error in Remix?

Return the submitted values alongside the errors from your action with json({ errors, values }). Read fetcher.data on the next render and set each input's defaultValue from it, so a failed submit repopulates the form.

Velyr is an AI growth agent that ships one weekly conversion fix as a GitHub Pull Request — you approve it over Telegram, and it rolls itself back if the numbers drop.

Start the Growth Agent