Server-side Validation

typescript
1import { Form, useFormContext } from '@flint-ui/core'
2
3interface ServerError {
4 field: string
5 code: string
6 message: string
7}
8
9function mapServerErrors(errors: ServerError[]) {
10 return Object.fromEntries(
11 errors.map((e) => [e.field, e.message])
12 )
13}
14
15function ServerValidatedForm() {
16 const handleSubmit = async (data: FormData) => {
17 const res = await fetch('/api/register', {
18 method: 'POST',
19 body: JSON.stringify(data),
20 })
21
22 if (res.status === 422) {
23 const { errors } = await res.json()
24 // Return field errors to set them on the form
25 return { fieldErrors: mapServerErrors(errors) }
26 }
27
28 if (!res.ok) throw new Error('Request failed')
29 }
30
31 return (
32 <Form onSubmit={handleSubmit} validateOn="onSubmit">
33 <Input name="username" label="Username" />
34 <Input name="email" label="Email" />
35 <Button type="submit">Register</Button>
36 </Form>
37 )
38}

Mapping API 422 errors back to individual form fields

Overview

Server-side validation catches errors that client-side checks cannot: duplicate emails, expired tokens, database constraints, and business logic rules. Flint UI's Input component integrates cleanly with server responses.

Handling Server Errors

Map server error responses to individual field errors. This pattern works with any API that returns field-level error messages.

typescript
1import { useState } from 'react'
2import { Input, Button } from '@flint-ui/core'
3
4type FieldErrors = Record<string, string>
5
6function RegistrationForm() {
7 const [errors, setErrors] = useState<FieldErrors>({})
8 const [loading, setLoading] = useState(false)
9
10 const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
11 e.preventDefault()
12 setErrors({})
13 setLoading(true)
14
15 const formData = new FormData(e.currentTarget)
16
17 try {
18 const res = await fetch('/api/register', {
19 method: 'POST',
20 body: formData,
21 })
22
23 if (!res.ok) {
24 const data = await res.json()
25 // Server returns: { errors: { email: "Already taken", password: "Too weak" } }
26 setErrors(data.errors ?? {})
27 }
28 } finally {
29 setLoading(false)
30 }
31 }
32
33 return (
34 <form onSubmit={handleSubmit} noValidate>
35 <Input
36 name="email"
37 label="Email"
38 type="email"
39 error={errors.email}
40 aria-invalid={!!errors.email}
41 />
42 <Input
43 name="password"
44 label="Password"
45 type="password"
46 error={errors.password}
47 aria-invalid={!!errors.password}
48 />
49 <Button type="submit" loading={loading} tone="primary">
50 Register
51 </Button>
52 </form>
53 )
54}

Inline Validation with Debounce

For fields like username or email, check availability as the user types. Debounce the API call to avoid excessive requests.

typescript
1import { useCallback, useEffect, useState } from 'react'
2
3function useFieldValidation(value: string, endpoint: string, delay = 500) {
4 const [error, setError] = useState<string | null>(null)
5 const [checking, setChecking] = useState(false)
6
7 useEffect(() => {
8 if (!value) { setError(null); return }
9
10 const timer = setTimeout(async () => {
11 setChecking(true)
12 try {
13 const res = await fetch(`\${endpoint}?value=\${encodeURIComponent(value)}`)
14 const data = await res.json()
15 setError(data.available ? null : data.message)
16 } finally {
17 setChecking(false)
18 }
19 }, delay)
20
21 return () => clearTimeout(timer)
22 }, [value, endpoint, delay])
23
24 return { error, checking }
25}

Error Display Patterns

  • Show field-level errors inline, directly below the relevant input
  • Use aria-invalid and aria-describedby for screen reader support
  • Clear field errors when the user starts editing that field
  • Show a global error banner for non-field errors (network failures, rate limits)