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:
getNote
getNotes
createNote
updateNote
deleteNote
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',
},
}
}