The application is using getLoadContext which gets the user and creates an api client bound to the current user. This api client, fetchTyped, wraps the native fetch function and will automatically include the jwt token stored in the session for that request. It also accepts a zod schema to validate the returned json. This is located on the app context object that can be retrieved in actions and loaders.
export async function loader({ context }: Route.LoaderArgs) {
const ctx = getAppContext(context)
const schema = z.object({
person: z.object({
id: z.string(),
firstname: z.string(),
// ...
}),
})
const res = await ctx.fetchTyped(`/contacts/123`, schema)
const firstname = res.person.firstname
// ...
}The third argument to fetchTyped is a RequestInit object from native fetch.
const data = await ctx.fetchTyped(`/contacts/123`, schema, {
method: 'POST',
body: JSON.stringify(payload),
})The fetchTyped client will also emit logs that can be seen from the browser console to aid in debugging.
All api interactions should be located in app/module/<module-name>/<resouce-name>-client.server.tsx. The resource name should be singular. Ex. contact-client.server.tsx, not contacts-client.server.tsx.
These modules should export functions to interact with the api. Example:
// app/module/note/note-client.server.tsx
type UpdateNotePayload = {
contents: string
}
export async function updateNote(ctx: AppContext, { noteId, payload }: { noteId: string; payload: UpdateNotePayload }) {
const schema = z
.object({
note: z.object({
id: z.string(),
}),
})
.or(errorSchema)
return ctx.fetchTyped(`/notes/${noteId}`, schema, {
method: 'POST',
body: JSON.stringify({ note: payload }),
})
}When importing, prefer to import all functions in the module instead of individual functions:
// app/routes/app.contact.$id.view/route.tsx
import * as contactClient from '~/app/module/contact/contact-client.server'
// usage
contactClient.createContact(ctx, config)The function names should not allude to an api being used.
Good:
getNotegetNotescreateNoteupdateNotedeleteNote
Bad:
fetchNote
These functions should only contain at most two arguments: ctx, and a configuration object. Descriptive id field names should be used to decrease ambiguity inside the configuration object. Ex. contactId, not id.
The functions should be ui agnostic. For example, they should not return toast messages. This should be done in the route or component files. Example:
// app/routes/app.contact.$id.view/route.tsx
export async function action({ request, params, context }: Route.ActionArgs) {
const formData = await request.formData()
const ctx = getAppContext(context)
const result = await updateContact(ctx, {
contactId: params.id,
payload: {
firstname: String(formData.get('firstname') ?? ''),
// ...
},
})
// use react router's data() utility to be able to send proper status code
return data(result, { status: result.success ? 200 : 400 })
}
// clientAction should be origin of toasts, not useEffects
export async function clientAction({ serverAction, request }: Route.ClientActionArgs) {
const result = await serverAction()
toast({
title: result.toast.title,
variant: result.toast.variant,
})
return result
}
// Wrapper function to put together toast messages based on the response.
// This code is specific to this use case of the api function.
async function updateContact(...args: Parameters<typeof contactClient.updateContact>) {
const result = await contactClient.updateContact(...args)
if ('person' in result) {
return {
success: true,
person: result.person,
toast: {
title: 'Contact updated!',
},
}
}
return {
success: false,
person: null,
toast: {
title: result.errors[0]?.detail ?? 'Error',
variant: 'destructive',
},
}
}