Create the Launch Details Component
Create ./app/styles/launch.css
.photo-grid {padding-top: 30px;display: grid;grid-gap: 10px;grid-template-columns: repeat(auto-fill, 300px);justify-content: center;}
Following Remix's file based routing we create ./app/routes/launch/$launchId.tsx
Let's build this component piece by piece.
First, we add our imports, custom typescript interfaces, and helper function for calculating cargo weights
import { useRef } from "react";import {ActionFunction,Form,HeadersFunction,json,LoaderFunction,MetaFunction,useLoaderData,useTransition,} from "remix";import { AuthenticityTokenInput, badRequest, notFound } from "remix-utils";import {CargoWeightCapacityFragment,CurrentCargoWeightFragment,LaunchDetailsFragment,UserLaunchDetailsQuery,} from "~/graphql/generated";import { sdk } from "~/utils/api.server";import { getSessionData } from "~/utils/auth.server";import { admin } from "~/utils/firebase.server";import { useUpdateEffect } from "react-use";import { z } from "zod";import styles from "~/styles/launch.css";interface AnonymousLoaderData {launch: NonNullable<LaunchDetailsFragment>;}interface UserLoaderData extends AnonymousLoaderData {launch: NonNullable<LaunchDetailsFragment> & CargoWeightCapacityFragment;cargo: UserLaunchDetailsQuery["cargo"];maxWeight: number;remainingWeight: number;}type LoaderData = AnonymousLoaderData | UserLoaderData;// Helper function for extracting weight info from the GraphQL queriesconst calculateWeight = (launch: CargoWeightCapacityFragment,cargo_aggregate: CurrentCargoWeightFragment): {remainingWeight: number;maxWeight: number;} => {// Get how much weight the rocket can take to Marsconst maxWeight =launch!.rocket?.rocket?.payload_weights?.find((payload) => payload!.id === "mars")?.lb! || 0;const remainingWeight =maxWeight - (cargo_aggregate!.aggregate!.sum?.weight || 0);return {maxWeight,remainingWeight,};};
Now various Remix functions doing things like setting cache headers and adding CSS
// Set title based on dataexport const meta: MetaFunction = ({ data }) => {return { title: `${(data as LoaderData).launch.mission_name} details` };};export function links() {return [{ rel: "stylesheet", href: styles }];}// If we are logged in, load JavaScriptexport let handle = {hydrate(loader: LoaderData) {return "cargo" in loader ? true : false;},};// If we aren't logged in, reuse cache from loader// Otherwise no-cache because we want fresh dataexport const headers: HeadersFunction = ({ loaderHeaders }) => {return {"Cache-Control": loaderHeaders.get("Cache-Control") || "no-cache",};};
Now we set up our Remix action that receives a cargo, checks how much weight is left on the rocket, and then validates the cargo. In a real app, you would use a database transaction to ensure the current cargo doesn't change while you do this.
// Add cargo to rocketexport const action: ActionFunction = async ({ request, params }) => {// Check CSRF token and get the users Firebase ID tokenconst { idToken } = await getSessionData(request, true);const data = await request.formData();// Homework is to setup a optimistic UI delete action// https://www.youtube.com/watch?v=vTzNpiOk668// https://youtu.be/EdB_nj01C80if (data.get("_action") === "add") {// Because we are handling the insert permission instead of Hasura, we need to verify userconst { sub: userId } = await admin.auth().verifySessionCookie(idToken!);// Get how much cargo is currently booked for the userconst { cargo_aggregate, launch } = await sdk.CurrentCargoInfo({id: params.launchId!,launchId: params.launchId!,},{Authorization: `Bearer ${idToken}`,});// Calculate how much weight is leftconst { remainingWeight } = calculateWeight(launch!, cargo_aggregate);// We do data validation with zod// For more advanced use cases I recommend Remix Validated Form + Zod// https://www.remix-validated-form.io/const input = z.object({name: z.string().min(1).max(100),weight: z.number().int().positive().lte(remainingWeight),}).parse({name: data.get("name"),weight: parseInt(data.get("weight") as string),});return sdk.AddCargo({cargo: {...input,launchId: params.launchId!,userId,},},// Pass in the admin secret because we did the validation{"x-hasura-admin-secret": process.env.HASURA_ADMIN_SECRET!,});}throw badRequest({ message: "No action" });};
We fetch different data based on if the user is logged in or not
export const loader: LoaderFunction = async ({ request, params }) => {const { idToken } = await getSessionData(request);if (!idToken) {const { launch } = await sdk.AnonymousLaunchDetails({id: params.launchId!,});if (!launch) {return notFound("Not Found");}return json<LoaderData>({ launch },{headers: {"Cache-Control": "public, max-age=5, s-maxage=345600",},});}// If user logged in get launch + cargo detailsconst { launch, cargo, cargo_aggregate } = await sdk.UserLaunchDetails({id: params.launchId!,launchId: params.launchId!,},// Pass in the user JWT so Hasura permissions can work{Authorization: `Bearer ${idToken}`,});if (!launch) {return notFound("Not Found");}return json<LoaderData>({launch,cargo,...calculateWeight(launch, cargo_aggregate),});};
Finally, we write the UI component
export default function Launch() {const { launch, ...cargoDetails } = useLoaderData<LoaderData>();const transition = useTransition();const formRef = useRef<HTMLFormElement>(null);const nameRef = useRef<HTMLInputElement>(null);const weightRef = useRef<HTMLInputElement>(null);// From Ryan Florence's excellent video on pending UI// This tells us if we are currently mutating// https://www.youtube.com/watch?v=y4VLIFjFq8kconst isAdding =transition.state === "submitting" &&transition.submission.formData.get("_action") === "add";/*** From Ryan Florence's video https://www.youtube.com/watch?v=bMLej7bg5Zoz* useUpdateEffect doesn't run on first render*/useUpdateEffect(() => {if (!isAdding) {// When done adding cargo, clear form and refocus on first inputformRef.current?.reset();nameRef.current?.focus();}}, [isAdding]);return (<><sectionstyle={{textAlign: "center",}}><h1>{launch.mission_name}</h1><imgsrc={launch.links?.mission_patch! ?? launch.links?.flickr_images?.[0]}alt={`${launch.mission_name} patch`}height="256"width="256"></img><p>{launch.details}</p></section><section className="photo-grid">{launch.links?.flickr_images?.map((imgUrl) => {return (<imgsrc={imgUrl!}alt="Picture of launch"height="300"width="300"loading="lazy"key={imgUrl}></img>);})}</section>{/* If user is logged in then display the cargo form */}{"cargo" in cargoDetails && (<section><h3>Total Capacity for Mars: {cargoDetails.maxWeight}lb, Weight Left:{" "}{cargoDetails.remainingWeight}lb</h3>{/* replace on the form means nothing is added to the history stack */}<Form replace method="post" ref={formRef}>{/* Usually all forms should have a CSRF token */}<AuthenticityTokenInput /><label htmlFor="name">Name:</label><inputtype="text"name="name"id="name"requiredref={nameRef}></input><label htmlFor="name">Weight (lbs):</label><inputtype="number"name="weight"id="weight"min="1"step="1"max={cargoDetails.remainingWeight}requiredref={weightRef}></input><buttondisabled={isAdding || cargoDetails.remainingWeight <= 0}type="submit"name="_action"value="add">{isAdding ? "Adding..." : "Add"}</button></Form><ul>{cargoDetails.cargo.map(({ id, name, weight }) => (<li key={id}>{name} - {weight}</li>))}</ul></section>)}</>);}
Congrats, your app should be completed and working!
- Build apps and APIs 10x faster
- Built-in authorization and caching
- 8x more performant than hand-rolled APIs