Leo's dev blog

Standardized hook for handling route submission in a Remix app

Published on
4 mins read

I have been working with Remix for a while now and I love it. Here is a custom hook I use to handle in-route-submission in my Remix apps.

use-route-submission.ts
import { useActionData, useNavigation, useSubmit } from '@remix-run/react'
import { useEffect } from 'react'
import { parseSubmissionData } from '~/utils/object'
import { useIndexRouteDetector } from './use-index-route-detector'
import type { RouteSubmission, RouteSubmissionInput, SubmitData } from '~/types/hooks'

/**
 * Submit data to the current route from any nested element.
 * Prefer this over `useSubmitFetcher` as it allows the `actionData` from `useActionData` hook can be accessed from any nested component in the route no matter how deep it is.
 *
 * @param input - `object` - The input to use for the route submission
 * @param [input._action] - The `_action` to use for the route submission, it will be added to submit data
 * @param [input.onSubmitted] - A callback to run when the route submission is submitted
 * @returns RouteSubmission
 * @example
 * ```tsx
 * import { useRouteSubmission } from '~/hooks'
 *
 * export function MyComponent() {
 *  // An `_action` param should be added to differentiate between multiple submissions
 *  let [submit, submitting, submitData] = useRouteSubmission({ _action: 'myAction' })
 *  // or let {submit, submitting, submitData} = useRouteSubmission({ _action: 'myAction' })
 *
 *  let handleClick = () => submit({ name: 'John Doe' })
 *  let loading = submitting
 *
 *  return (
 *    <Button loading={loading} onClick={handleClick}>
 *      Submit
 *    </Button>
 *  )
 * }
 *```
 */
export function useRouteSubmission(input: RouteSubmissionInput): RouteSubmission {
  let _submit = useSubmit()
  let navigation = useNavigation()
  let isIndexRoute = useIndexRouteDetector()
  let actionData = useActionData()

  let { _action, onSubmitted } = input || {}
  let submitData = parseSubmissionData(navigation)
  let isActionMatched = submitData?._action === _action

  useEffect(() => {
    if (isActionMatched && navigation.state === 'loading') {
      onSubmitted?.(submitData, actionData)
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [navigation.state])

  let submit = (data: SubmitData = {}) => {
    let actionURL = window.location.pathname
    _submit(
      { data: JSON.stringify({ ...data, _action }) },
      {
        action: isIndexRoute ? `${actionURL}?index` : actionURL,
        method: 'post',
        replace: true,
      }
    )
  }
  let submitting = isActionMatched && navigation.state === 'submitting'

  return Object.defineProperty({ submit, submitting, submitData }, Symbol.iterator, {
    enumerable: false,
    value: function* () {
      yield submit
      yield submitting
      yield submitData
    },
  }) as RouteSubmission
}

The utils used in the hook are:

object.ts
import type { useNavigation } from '@remix-run/react'
import type {SubmitDataWithAction} from '~/types/hooks'

/**
 * Get the submitted data of a submission
 *
 * @param navigation - The current page navigation returned from `useNavigation` hook
 * @example
 * let data = parseSubmissionData(transition)
 */
export function parseSubmissionData(
  navigation: ReturnType<typeof useNavigation>,
): SubmitDataWithAction {
  let formData = navigation?.formData
  if (!formData) return null
  return JSON.parse((Object.fromEntries(formData) as any).data)
}

And

use-index-route-detector.ts
import { useLocation, useMatches } from '@remix-run/react'

export function useIndexRouteDetector() {
  let matches = useMatches()
  let location = useLocation()
  let match = matches.find(({ pathname }) => pathname === location.pathname)
  if (match) {
    return !!match.id.match(/\/index$/) || match.id === 'root'
  }
  return false
}

And the types used in the hook are:

hooks.ts
export type RouteSubmissionInput = {
  _action: string
  onSubmitted?: (
    submitData: SubmitDataWithAction,
    actionData: { [key: string]: any },
  ) => void
}

export type SubmitData = {
  [key: string]: any
}

export type SubmitDataWithAction = {
  _action?: string
  [key: string]: any
}

type SubmitFunction = (data?: SubmitData) => void

export type RouteSubmission = {
  submit: SubmitFunction
  submitting: boolean
  submitData: SubmitDataWithAction
} & [SubmitFunction, boolean, SubmitDataWithAction]

Example usage:

example.tsx
import { Button } from '~/components/button'
import { useRouteSubmission } from '~/hooks/use-route-submission'

export function SaveProject() {
  // An `_action` param should be added to differentiate between multiple submissions
  let [submit, submitting] = useRouteSubmission({ _action: 'SAVE_DATA' })
  // or let {submit, submitting} = useRouteSubmission({ _action: 'SAVE_DATA' })

  function save() {
    submit({ name: 'John Doe' })
  }

  return (
    <Button loading={submitting} onClick={save}>
      Save
    </Button>
  )
}

Happy submitting!