EngineeringPatterns
Entity Creation Mutation Pattern
Overview
For create-then-edit flows, trigger creation from the current screen with an explicit mutation, then navigate to the edit route after success.
Pattern
/tours → createTourFn() → /tours/:nanoId/edit
/tours/:tourId/edit → createStopFn() + addStopToTourFn() → /tours/:tourId/stops/:stopId/editWhy This Pattern?
Problems with mutation-in-loader /new routes
- They put a write on the navigation critical path
- They often trigger extra invalidation work before redirecting away
- They hide pending/error state in route transitions instead of at the button
- They make create flows slower without improving the edit route itself
Benefits of explicit client-side mutations
- Fast navigation path - create, then navigate directly
- Local pending state - button and section feedback stay contextual
- Cleaner route responsibilities - edit routes only load existing entities
- Better control over cache strategy - no forced pre-redirect invalidation
Implementation
Mutation Structure
// tours list page
import { useMutation } from '@tanstack/react-query'
import { createTourFn } from '@valguide/core/features/tours/tour/create-tour.fn'
const createTour = useMutation({
mutationFn: async (locale: string) => createTourFn({ data: { locale } }),
onSuccess: (result) => {
router.navigate({
to: '/tours/$nanoId/edit',
params: { nanoId: result.nanoId },
search: { locale: result.locale },
})
},
})Key Points
- Creation starts from user intent on the current page
- Server generates ID - no client-side
valguideId()needed - Navigation happens only after success
- List invalidation is not on the hot path
Edit Route (Clean)
// routes/_main/tours.$nanoId.edit.tsx
export const Route = createFileRoute('/_main/tours/$nanoId/edit')({
loader: async ({ params, context }) => {
// No creation logic - just fetch existing entity
const tourDetail = await context.queryClient.ensureQueryData(
tourDetailQueryOptions(params.nanoId)
)
if (!tourDetail) throw notFound()
return { tourDetail }
},
component: TourEditPage,
pendingComponent: TourEditSkeleton,
notFoundComponent: TourNotFound,
})Navigation
// From list page
const handleCreateTour = () => {
createTour.mutate(locale)
}
// From tour edit to create stop, then navigate
const handleCreateStop = async () => {
const stopId = await addStop()
if (!stopId) return
router.navigate({
to: '/tours/$nanoId/stops/$stopId/edit',
params: { nanoId: tourNanoId, stopId },
})
}Edge Cases
Refresh during creation
If the user refreshes while the mutation is in flight, the current page reloads and the in-flight request may be retried by the user. This is a simpler and more explicit failure mode than hidden loader-based writes.
Idempotent Creation (Optional)
For extra safety, make server creation idempotent with an idempotency key:
// Store key in sessionStorage, pass to server
const idempotencyKey = sessionStorage.getItem('create-tour-key') ?? crypto.randomUUID()
sessionStorage.setItem('create-tour-key', idempotencyKey)
await createTourFn({ data: { locale, idempotencyKey } })Current Status In This Codebase
- The dedicated Studio
/tours/newand/tours/:tourId/stops/newroutes were removed. - Tour creation now starts from the tours list mutation flow.
- Stop creation now starts from the tour editor mutation flow.