Will King
Design Pattern
Remix

Better Remix action responses in Remix with KeyedFlash

In Remix one of my favorite features is the ability to handle multiple actions on the same route. There are a lot of places we use this in our app at Crunchy Data, and over time I noticed that there was a piece of this workflow that could be improved across the board. Let's dig into it.

This is our cluster (database) settings page:

It has multiple actions that are spread across the length of the page. Before we migrated to remix we were using express + react templating. This combo enforced hard reloads on every form submission. This meant scroll would be reset to the top.

With that context in mind, our system for notifying users if their form submission was a success or failure was a global flash message prepended to the top of the layout.

However, once we migrated to Remix….well can you guess the issue we ran into?

Our scroll no longer resets on form submission. Now when you submit the forms further down the page you never see the success or error message at the top of the page.

Okay okay, you're probably reading this and thinking, “Why don't you just have a fixed position notification?” or “There are ways to force scroll reset in Remix” and you'd be correct. There are a lot of options for solving this problem, but instead of spending time telling you why we didn't do them I want to discuss the solution we did pick and why. That doesn't mean it is the “best” pick, but it is definitely the best choice for us.

Also, the pattern is not tied to the UI implementation we chose so you can learn the pattern and make the UI whatever works for your application.

A system for managing multiple actions in a route.

To kick things off let's start with the data structure that we are going to be using as the return in our actions.

/** 
* We could also add more to this type if our UI requires
* more detail to render. (eg: title, icon, link, etc)
**/
type Flash = { 
  message: string
  kind: "error" | "info" | "warning" | "success"
}

type KeyedFlash = { 
  key: string 
  flash: Flash
}

The flash portion is pretty self explanatory, we will populate that with the data we want to render to our flash UI. The important part is the key that is added to it in the KeyedFlash type. This allows us to match based on the key so that we can cherry pick the flash all across our UI. Let's take a look at how we will use these types to implement our KeyedFlash UI.

/**
 * Use only supported flash types to assign Icons
 **/
const flashMessageIcons: Record<
  Flash["kind"],
  FC<PropsWithChildren<SVGProps<SVGSVGElement>>>
> = {
  error: XCircleIcon,
  info: ExclamationCircleIcon,
  success: CheckCircleIcon,
  warning: ExclamationTriangleIcon,
};

/**
 * Use only supported flash types to assign tailwind styles
 **/
const flashMessageClassNames: Record<Flash["kind"], [string, string]> = {
  error: ["text-danger-800", "bg-danger"],
  info: ["text-info-800", "bg-info"],
  success: ["text-success-800", "bg-success"],
  warning: ["text-warning-700", "bg-warning"],
};

/**
 * Displays flash messages
 */
export function FlashMessage({
  message,
  kind,
}: Flash & ComponentPropsWithoutRef<"div">) {
  const Icon = flashMessageIcons[kind];
  const [iconClass, bgClass] = flashMessageClassNames[kind];

  return (
    <div className={`rounded-lg text-white shadow ${bgClass}`}>
      <div className="relative flex flex-row px-4">
        <Icon className={`mt-3 h-6 w-6 shrink-0 ${iconClass}`} />
        <div className="h-full flex-auto p-3">{message}</div>
      </div>
    </div>
  );
}

/**
 * Only renders a FlashMessage when the keys match.
 **/
export function KeyedFlash({
  flashKey,
  flash,
  ...props
}: {
  flashKey: string | string[];
  flash?: TKeyedFlash;
} & ComponentPropsWithoutRef<"div">) {
	if (!flash) return null

	const hasArrayOfKeys = Array.isArray(flashKey)
	const keyMatch = hasArrayOfKeys
		? flashKey.includes(flash.key)
		: flashKey === flash.key
	if (!keyMatch) return null

	return <FlashMessage {...flash.flash} />
}

KeyedFlash in Action

Okay, now that we have covered the implementation let's take a look at how that plays out on a page with multiple actions.

export async function loader() {
	// Here we load all of the pending upgrades if they exist and any global flash messages.
}

// Validator that we use for the Restart action.
const RestartValidator = v.object({
	service: v.union(
		v.literal(OwlclientClusterActionRestartService.Server),
		v.literal(OwlclientClusterActionRestartService.Postgres),
	),
})

export async function action({
	context,
	params,
}: ActionArgs) {
	const { clusterId } = params
	invariant(clusterId, 'Missing cluster id from path.')

    /**
     * Handle multiple actions on a route by switching on a hidden `action`
     * input to differentiate the actions. We use constants to set actions in
     * the UI and to pattern match in the case statements.
     **/
	switch (context.body.action) {
		case UPDATE_CLUSTER_ACTION: {
			const validated = UpdateClusterValidator.try(context.body, {
		        mode: 'strip',
	        })

	        if (!validated.ok) {
		        captureError(validated)

                /**
                 * Pass the keyed flash type to the `json` generic slot to make the type
                 * inference from `useActionData<typeof action>() better.
                 **/
		        return json<TKeyedFlash>({
			        key: UPDATE_CLUSTER_ACTION,
			        flash: {
			    	    messageType: 'error',
			    	    message: issue?.error ?? 'Update validation failed.',
			        },
		        })
	        }

            try {
                // ...update action stuff
               return json<TKeyedFlash({
                    key: UPDATE_CLUSTER_ACTION,
                    flash: { kind: "success", message: "Successfully updated your cluster" },
                });
            } catch(error) {
                /**
                 * Because redirects work by throwing a Response, you need to check if the
                 * caught error is a response and return it or throw it again
                 **/
                if (e instanceof Response) return e;
              
                const message = e instanceof Error && error?.message 
                    ? error.message 
                    : "Unknown server error."

                return json<TKeyedFlash>({
                    key: UPDATE_CLUSTER_ACTION,
                    flash: { kind: "error", message },
                });
            }
		}

		case RESTART_ACTION: {
			// ...same pattern, but we use this action constant as the flash key.
		}

		case SUSPEND_ACTION: {
			// ...same pattern, but we use this action constant as the flash key.
		}

		case RESUME_ACTION: {
			// ...same pattern, but we use this action constant as the flash key.
		}

		case REFRESH_ACTION: {
			// ...same pattern, but we use this action constant as the flash key.
		}

		case CANCEL_UPGRADES_ACTION: {
			// ...same pattern, but we use this action constant as the flash key.
		}

		case DELETE_CLUSTER_ACTION: {
			// ...same pattern, but we use this action constant as the flash key.
		}

		default:
			throw new Response(null, { status: 405 })
	}
}

export default function ClusterSettingsPage() {
	const { flashMessages, upgradeList } = useLoaderData<typeof loader>()
	const actionData = useActionData<typeof action>()
    const { cluster } = useClusterLayoutLoaderData()
	const navigation = useNavigation()

	return (
		<ColumnMenuLayout>
			<section className="flex flex-col gap-4">
				<KeyedFlash flashKey={UPDATE_CLUSTER_ACTION} flash={actionData} />
				<UpdateClusterForm actionId={UPDATE_CLUSTER_ACTION} />
			</section>
			<DangerZone title="Upgrades & Maintenance">
				<KeyedFlash
					flashKey={[REFRESH_ACTION, CANCEL_UPGRADES_ACTION]}
					flash={actionData}
				/>
				<Upgrades />
				<RefreshClusterForm actionId={REFRESH_ACTION} />
				<CancelUpgradesForm actionId={CANCEL_UPGRADES_ACTION} />
			</DangerZone>
			<DangerZone>
				<KeyedFlash
					flashKey={[
						RESTART_ACTION,
						SUSPEND_ACTION,
						RESUME_ACTION,
						DELETE_CLUSTER_ACTION,
					]}
					flash={actionData}
				/>
				<RestartForm actionId={RESTART_ACTION} />
				{cluster.is_suspended ? (
					<ResumeForm actionId={RESUME_ACTION} />
				) : (
					<SuspendForm actionId={SUSPEND_ACTION} />
				)}
				<RestartForm actionId={DELETE_CLUSTER_ACTION} />
			</DangerZone>
		</ColumnMenuLayout>
	)
}

That is all there is to it. Now each section in the settings page has a targeted flash message that groups together the actions based on the action IDs for the forms in that section. Just to tie everything up in a nice little bow here is the KeyedFlash in action:

Updates and More

Get updated when new articles, products, or components are released. Also, whatever else I feel like would be fun to send out.