Errors Aren't Strings: Four Unfashionable React 19 Patterns
29 minutes ago

The Context
A lot of React codebases rhyme. You've seen the shape. A form component has inline error strings. A pile of useState(false) for loading. A Tailwind config file that's gradually absorbed the entire design system through config overrides. A dozen components that each stringify errors differently because nobody agreed on a pattern. A t('form.error.required') reaching for i18n that isn't wired up yet. A useMemo on every derived value because someone on the team once got burned.
RSVPed started in that same place — it's not like I had a grand vision on day one. What I had was a vague irritation every time I opened EventForm.tsx and saw yet another way of rendering an error message that was subtly different from the last one. Four small patterns came out of that irritation. Individually they're modest. Together they change how the whole UI layer feels — fewer decisions per component, fewer strings in the wrong place, fewer flashes, fewer config files.
None of them are novel. That's the point. They're patterns that are well-understood but rarely all adopted at once. What's unusual about RSVPed isn't the ideas; it's the discipline to hold the line on all four simultaneously.
Pattern 1 — Errors Aren't Strings. They're Codes.
The default way to return errors from a server action is whatever felt right that day. Sometimes a string. Sometimes { error: 'something failed' }. Sometimes a thrown exception. The component receives some shape, coerces it to something renderable, and shows a message.
The problem is every component ends up owning an error rendering decision. Now you've got 17 places that each independently decide how to render VALIDATION_ERROR, and they all drift. One uses a red banner at the top; one uses an inline text under the field; one throws a toast. i18n becomes a grep job. Adding a new error code means adding 17 new switch cases.
The fix is three layers:
Layer 1 — Error code enums, per domain.
// server/actions/types.ts export enum EventErrorCodes { UNAUTHORIZED = 'UNAUTHORIZED', VALIDATION_ERROR = 'VALIDATION_ERROR', CREATION_FAILED = 'CREATION_FAILED', NOT_FOUND = 'NOT_FOUND', CAPACITY_EXCEEDED = 'CAPACITY_EXCEEDED', } export enum RsvpErrorCodes { /* ... */ } export enum CommunityErrorCodes { /* ... */ }
No message strings. Just identifiers. One enum per domain — events have different error vocabularies than RSVPs, which have different vocabularies than communities.
Layer 2 — Typed message maps.
// server/actions/constants.ts export const EventActionErrorCodeMap: Record<EventErrorCodes, string> = { [EventErrorCodes.VALIDATION_ERROR]: 'Please fix the errors in the form.', [EventErrorCodes.UNAUTHORIZED]: 'You are not authorized to perform this action.', [EventErrorCodes.CREATION_FAILED]: 'Failed to create the event. Please try again.', [EventErrorCodes.NOT_FOUND]: 'Event not found.', [EventErrorCodes.CAPACITY_EXCEEDED]: 'This event is at capacity.', }
The Record<EventErrorCodes, string> type is load-bearing. TypeScript refuses to compile if you add a new code and forget to give it a message. The compiler is your i18n completeness check. When translation happens, the English map becomes EventActionErrorCodeMap.en.ts and the new locale is exactly the same file structure with different values — zero code changes outside constants.ts.
Layer 3 — `useActionStateWithError`.
// lib/hooks/useActionStateWithError.ts export const useActionStateWithError = <TState extends ServerActionResponse>({ action, initialState, errorCodeMap, displayMode = 'inline', }: UseActionStateWithErrorParams<TState>) => { const [state, formAction, isPending] = useActionState(action, initialState) const errorComponent = useServerActionErrorHandler({ state, isPending, errorCodeMap, displayMode, }) return { state, formAction, isPending, errorComponent } }
Usage in a component:
const { formAction, isPending, errorComponent } = useActionStateWithError({ action: saveEvent, initialState: null, errorCodeMap: EventActionErrorCodeMap, displayMode: 'inline', // or 'toast' }) return ( <Form action={formAction}> <Input name="title" defaultValue={event?.title} /> {errorComponent} <Button type="submit" disabled={isPending}>Save</Button> </Form> )
The hook wraps useActionState and produces a pre-rendered errorComponent that maps the returned code to the user-facing message. Components don't render error strings. They render {errorComponent}. The error-display mode (inline form / toast) is a parameter, not a per-component choice — so when the product team decides "actually, field-level errors should be toasts on mobile", you change one flag site-wide.
The server action stays clean:
export async function saveEvent(_, formData): Promise<EventActionResponse> { const validation = eventSchema.safeParse(transformedData) if (!validation.success) { return { success: false, error: EventErrorCodes.VALIDATION_ERROR, fieldErrors: validation.error.flatten().fieldErrors, } } let event try { const api = await getAPI() event = await api.event.create(createData) } catch (error) { if (error instanceof TRPCError && error.code === 'UNAUTHORIZED') { return { success: false, error: EventErrorCodes.UNAUTHORIZED } } return { success: false, error: EventErrorCodes.CREATION_FAILED } } // CRITICAL: redirect outside try/catch — Next.js throws internally redirect(Routes.Main.Events.ManageBySlug(event.slug)) }
No strings in the action. No error rendering in the component. The hook is the single seam between them. When i18n happens, every map is one translation file. When a new error code is added, the compiler tells you where to add the message. When the design team wants to change "inline" to "toast", the change is one parameter.
Pattern 2 — The Invisible Bug in React 19 Forms
React 19's useActionState does a helpful thing: on a successful submit, it clears the form. That's what you want for a "send message" input. It's what you emphatically don't want for an event edit form. You submit, the form flashes blank for a frame, then re-populates with the saved values, then settles. It looks like the app hiccuped. Worse, it causes an accessibility problem: screen readers announce the reset, then the re-render, so the user hears "form cleared" followed by the whole form being re-read.
The root cause: React fires a native reset event on the form on success, which clears all controlled and uncontrolled inputs. Re-hydrating from server state causes a double render.
The fix is a tiny component that nobody in your team will ever notice:
// components/shared/Form.tsx export const Form = forwardRef<HTMLFormElement, FormProps>( ({ onReset, onSubmit, ...props }, ref) => { const handleReset = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault() // kill the default reset onReset?.(e) // let callers opt in if they need it } return <form ref={ref} onReset={handleReset} onSubmit={onSubmit} {...props} /> } ) Form.displayName = 'Form'
Then the rule: always import `Form` from `@/components/shared`, never use native `<form>`. Biome lint could enforce this (no-restricted-imports style), but usually code review does. A pre-commit hook greps for <form\s in changed files and warns.
The thing about this pattern is that you can't demonstrate its absence. It's one of those fixes you put in once, and nobody ever notices the bug it prevented. No user files a ticket about the form not flashing. No engineer gets paged for a problem that doesn't exist. But every form in the app uses it, and every form in the app behaves the way users expect, which is the highest compliment a form wrapper can get.
Pattern 3 — Strings Live in `copy.ts`
Every UI string on RSVPed lives in a colocated copy.ts file. No inline hardcoded text in components (except aria-label, which is often context-specific enough to stay inline):
// app/(main)/copy.ts export const copy = { nav: { logo: "RSVP'd", createEvent: 'Create Event', stir: 'Stir', profile: 'Profile', }, home: { title: 'Events', emptyState: 'No events yet. Check back soon, or create one.', }, stir: { title: "Let's Stir Things Up!", description: 'Discover events and communities through natural language.', chipExamples: ['tech meetups this weekend', 'free events near me', 'what should I go to?'], placeholder: 'Ask anything about events...', }, rsvp: { confirm: (eventTitle: string) => `Confirm your RSVP to ${eventTitle}?`, waitlist: (eventTitle: string) => `Join the ${eventTitle} waitlist?`, cancelled: 'RSVP cancelled.', }, }
Hierarchical, typed, route-group-scoped. Functions for strings that take parameters. Arrays for lists of examples. You want to change the nav label? One file. You want to know every place the word "RSVP" is user-facing? grep copy\.rsvp in components. You want to know every string in the app? Count the copy.ts files — four, one per route group.
It's not novel. It's just that most codebases don't bother until they need i18n, and by then there are 2000 strings scattered across 400 components. RSVPed does it day one. When i18n does come, it's a single-file swap per route group — not a multi-month excavation. The function-based strings (copy.rsvp.confirm(eventTitle)) translate cleanly too, because ICU message format handles parameter interpolation the same way.
Pattern 4 — Delete `tailwind.config.ts`
Tailwind v4 reads CSS variables directly. Which means the config file can die. On RSVPed, it's already dead — there is no tailwind.config.ts file. Everything is in one place:
/* app/theme.css */ @theme static { /* Color families */ --color-cranberry-50: #fef2f4; --color-cranberry-500: #e11d48; --color-cranberry-900: #881337; --color-barney-50: /* ... purple ... */; --color-blue-50: /* ... blue ... */; /* Semantic tokens */ --color-brand: var(--color-cranberry-500); --color-brand-pale-bg: var(--color-cranberry-50); --color-brand-faint-bg: rgb(from var(--color-cranberry-500) r g b / 0.08); --color-error: var(--color-red-500); --color-success: var(--color-emerald-500); --color-warning: var(--color-amber-500); /* Layout */ --max-w-page: 820px; --max-w-wide-page: 960px; /* Typography */ --font-sans: 'Inter', system-ui, sans-serif; --font-serif: 'Averia Serif Libre', Georgia, serif; /* Radii, spacing, etc. */ --radius: 0.75rem; --radius-sm: 0.5rem; } .dark { --color-brand: var(--color-cranberry-400); --color-brand-pale-bg: var(--color-cranberry-950); /* ... dark overrides */ }
Color families (cranberry, barney, blue — each with 5 shades). Semantic tokens (brand, error, success, warning) mapped to families. Translucent variants (brand-pale-bg, brand-faint-bg) for subtle backgrounds. Spacing, radii, fonts — all in the same block. Dark mode is a .dark class override on :root.
Components only use semantic utilities: bg-brand, text-muted-foreground, border-border. They never reach into the color scale directly (bg-cranberry-500 is a smell, not a pattern). ShadCN components are barrel-exported from components/ui/index.ts so imports stay tidy:
import { Button, Card, CardHeader, Dialog, Input } from '@/components/ui'
The upside that nobody talks about: the design system is one file long. A new contributor reads theme.css and components/ui/index.ts and has seen the whole surface. Changing the brand color is a one-line edit. A/B testing a new color palette is a second theme.css file behind a feature flag.
The Other Things That Fall Out
Once those four are in place, other patterns become easy.
tRPC pagination as middleware, not boilerplate
Pagination isn't per-route boilerplate; it's a procedure wrapper:
export const paginatedProcedure = publicProcedure .input(PaginationSchema) .use(async ({ input, next }) => { const { page, size } = input return next({ ctx: { pagination: { skip: (page - 1) * size, take: size, createMetadata: (total: number) => ({ page, size, total, totalPages: Math.ceil(total / size), hasMore: page * size < total, hasPrevious: page > 1, }), }, }, }) })
Routers consume it naturally:
const eventsRouter = router({ list: paginatedProcedure .input(z.object({ categoryId: z.string().optional() })) .query(async ({ ctx, input }) => { const [items, total] = await Promise.all([ ctx.prisma.event.findMany({ where: buildWhere(input), skip: ctx.pagination.skip, take: ctx.pagination.take, }), ctx.prisma.event.count({ where: buildWhere(input) }), ]) return { data: items, pagination: ctx.pagination.createMetadata(total) } }), })
No per-route offset math. GenericPagination on the client renders the ShadCN Pagination component and syncs page state to URL search params — one component handles all pagination UI across the app.
tRPC error factories
// server/api/shared/errors.ts export const TRPCErrors = { eventNotFound: () => new TRPCError({ code: 'NOT_FOUND', message: 'Event not found' }), unauthorized: () => new TRPCError({ code: 'UNAUTHORIZED', message: 'Not authorized' }), forbidden: () => new TRPCError({ code: 'FORBIDDEN', message: 'Forbidden' }), alreadyMember: () => new TRPCError({ code: 'CONFLICT', message: 'Already a member' }), eventFull: () => new TRPCError({ code: 'BAD_REQUEST', message: 'Event at capacity' }), internal: (cause?: unknown) => new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Internal error', cause }), }
Mutation catch blocks follow a strict pattern — re-throw known TRPCErrors, wrap unknown ones:
try { return await ctx.prisma.event.create({ data }) } catch (error) { if (error instanceof TRPCError) throw error throw TRPCErrors.internal(error) }
No raw new TRPCError({ code: 'NOT_FOUND' }) anywhere. No accidentally swallowing an authorization error inside a retry loop.
What I considered and rejected
- A single global `errors.ts` with every code across every domain. Tried it. It grew to 200 entries and became the central junk drawer. One enum per domain keeps each small and reviewable.
- Rendering error messages at the server-action site instead of in the hook. Less flexible; the display-mode toggle (inline vs toast) would require changes in the server action.
- Keeping the native `<form>` and using `defaultValue` + controlled state. Works but requires every field to be controlled. The
Formwrapper is 15 lines and covers every form. - i18n libraries (next-intl, lingui). Not needed yet —
copy.tsis a single-locale placeholder for when they are. The structure is i18n-ready; swapping in a library later is a refactor, not a rewrite. - Keeping `tailwind.config.ts` "in case". No. Tailwind v4's CSS-first approach is clearly the new model; the config file is vestigial.
The Impact
- Errors are codes, not strings — 4 tiny maps per domain instead of 400 inline messages
- Zero form flash on successful submits
- Every UI string is in `copy.ts` — i18n is a single-file swap per route group
- No `tailwind.config.ts` file exists — the entire design system is one CSS file
- Exhaustive typing everywhere —
Record<Enum, string>types force completeness at compile time - One import for all UI primitives, one for all routes, one for all error codes
- Pagination is middleware, not per-route code
- TRPCError construction is centralized — no raw error code strings in router files
Closing
Nothing on this list is revolutionary. None of it requires a library. What's unusual isn't the ideas — it's that they're all in the same codebase at the same time. Each one shaves a class of bug or a category of churn:
- Error-code architecture eliminates "works on my form" bugs where different components render the same error differently
- The
<Form>wrapper eliminates an entire category of React 19 footgun - Colocated
copy.tseliminates the i18n-readiness excavation - Token-only CSS eliminates the "config vs code" drift that every long-lived Tailwind project develops
Together, they change what the UI layer feels like: fewer decisions per component, fewer strings in the wrong place, fewer flashes, fewer config files. New contributors ramp fast because the patterns are consistent; code review gets shorter because the patterns are enforced.
The hardest part about patterns like these isn't writing them. It's holding the line when someone else's PR slips in an inline error string or a native <form>. They're not load-bearing individually — they're load-bearing in aggregate. Which means consistency is the feature, and code review is the enforcement mechanism.
Every codebase rhymes. The question is what it rhymes with. Decide early, and enforce it with the same seriousness you'd enforce a database migration.





