Spring Boot validation with react (part 2)

In the previous installment of this series we created a spring boot based backend for managing Club entities.

Here, we will create a React based frontend that allows us to edit individual Clubs.

Creating the client

We will scaffold an empty project with vite:

1
pnpm create vite 

In the options choose

  • React
  • TypeScript

Then we add the necessary dependencies

1
2
3
pnpm add primereact primeicons
pnpm add react-hook-form @hookform/resolvers zod react-query @tanstack/react-router@beta 
pnpm add -D sass

Now we can already start the dev server and see our generated application:

1
pnpm dev

We open the browser at http://127.0.0.1:5173/ and are greeted with the scaffolded sample app:

vite sample app

Integrating the router

First we integrate tanstack router by adding a root route in client-react/src/pages/root.tsx:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import { createRouteConfig, Link, Outlet } from '@tanstack/react-router'
import React, {FC} from 'react'

export const RootLayout : FC = () => {
    return <>
        <div>
            <Link to="/">Home</Link>
            <Link to="/about">About</Link>
        </div>
        <hr />
        <Outlet />
    </>
}

Let’s add a few pages:

  1. The Homepage src/pages/index.tsx

    1
    2
    3
    
    export const IndexPage: FC = () => {
    return <div><h3>Home</h3></div>
    }
    
  2. the club list src/pages/club/index.tsx

    1
    2
    3
    4
    5
    6
    7
    8
    
    export const ClubListPage: FC = () => {
        const router = useRouter()
        return (
            <div>
                <h3>Clubs List!</h3>
            </div>
        )
    }
    
  3. the individual Club page and src/pages/club/club.tsx with the content

    1
    2
    3
    4
    5
    
    export const ClubPage : FC = () => {
    return <div>
        <h3>Club Page</h3>
    </div>
    }
    
  4. src/pages/club/edit.tsx with content

    1
    2
    3
    4
    5
    
    export const ClubEditPage : FC = () => {
    return <div>
        <h3>Edit Club Page</h3>
    </div>
    }
    

Now we can hook those up in src/main.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.css'
import { createReactRouter, createRouteConfig, RouterProvider } from '@tanstack/react-router'
import { RootLayout } from './pages/root'
import { ClubListPage } from './pages/club'
import { ClubPage } from './pages/club/club'
import { ClubEditPage } from './pages/club/edit'


export const rootRoute = createRouteConfig({
    component: RootLayout,
})
const homeRoute = rootRoute.createRoute({
    path: '/',
    component: IndexPage,
})
const clubsRoute = rootRoute.createRoute({
    path: '/clubs',
    component: ClubListPage,
})
const clubRoute = rootRoute.createRoute({
    path: '/clubs/$clubId',
    component: ClubPage,
})
const clubEditRoute = rootRoute.createRoute({
    path: '/clubs/$clubId/edit',
    component: ClubEditPage,
})

const routeConfig = rootRoute.addChildren([
    clubEditRoute,
    clubRoute,
    clubsRoute,
    homeRoute,
])

const router = createReactRouter({ routeConfig })

declare module '@tanstack/react-router' {
  interface RegisterRouter {
    router: typeof router
  }
}

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <React.StrictMode>
      <RouterProvider router={router} />
  </React.StrictMode>,
)

Once we have all that we are greeted with a beautiful navigation header which allows us to switch between the pages:

navigation header

Fixing the styling

While this is looking not too bad from a functionality perspective, it lacks visual style. So let’s integrate primereact a little bit better and fix some of the styling issues:

  1. rename index.css to index.scss and replace its content with
    1
    2
    3
    
    @use "primereact/resources/themes/lara-light-indigo/theme.css";  //theme
    @use "primereact/resources/primereact.min.css";                  //core css
    @use "primeicons/primeicons.css";
    
  2. create src/pages/root.module.scss with the following content
    1
    2
    3
    4
    
    .headerNav {
      display: flex;
      gap: 15px;
    }
    
  3. and import it into src/pages/root.tsx
    1
    2
    3
    4
    5
    6
    7
    
    import styles from './root.module.scss'
    
    export const RootLayout: FC = () => {
    return (
        <>
        <header>
            <nav className={styles.headerNav}>
    

Now it should look as follows:

improved navigation style

While this is not particularily pretty, its good enough for this tutorial and will also ensure that all the PrimeReact components have proper styling.

Integrating react-query

For data loading we will use react-query which needs to be set up in src/main.tsx.

we simply need to create a QueryClient and wrap the whole application in a QueryClientProvider like so:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
...
const queryClient = new QueryClient()

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
    </QueryClientProvider>
  </React.StrictMode>
)

Before we can load the list of clubs we first need to setup the loaders and the data model.

Since we are using zod for validation we can already define the REST models like that in src/model/model.ts

1
2
3
4
5
6
7
8
9
import { z } from 'zod'

export const ClubSchema = z.object({
  clubName: z.string().min(2).max(150),
  managerEmail: z.string().email().max(200),
  id: z.string().optional(),
})

export type Club = z.infer<typeof ClubSchema>

Then we define the api methods in src/api/api.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { Club } from '../model/model'

export async function fetchClubs() {
  const result = await fetch('/api/clubs')
  return (await extractJsonOrError<any>(result))._embedded.clubs as Club[]
}

async function extractJsonOrError<T>(result: Response): Promise<T> {
  let body: any
  try {
    body = await result.json()
  } catch (e) {
    console.error('error extracting error body', e)
  }
  if (!result.ok) {
    const error = new Error('Network response was not ok', { cause: body })
    throw error
  }
  return body as T
}

export async function fetchClub(id: string): Promise<Club> {
  const result = await fetch(`/api/clubs/${id}`)
  return await extractJsonOrError(result)
}

export async function updateClub(club: Club): Promise<Club> {
  const result = await fetch(`/api/clubs/${club.id}`, {
    body: JSON.stringify(club),
    method: 'PUT',
    headers: { 'content-type': 'application/json', accept: 'application/json' },
  })
  return extractJsonOrError(result)
}

This allows us to load the list of clubs, fetch a single club as well as update an existing club.

One thing missing is the proxy configuration in the frontend that will allow us to connect to the spring backend. Add the following lines to client-react/vite.config.ts

1
2
3
4
5
6
7
8
export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': 'http://localhost:8080',
    },
  },
})

Loading the club data

As we have all the basics out of the way we can finally load our club list in src/pages/club/index.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
export const ClubListPage: FC = () => {
  const { data, error, isLoading } = useQuery('clubs', fetchClubs)
    const [selectedClub, setSelectedClub] = useState<Club | undefined>(undefined)
  const router = useRouter()
  return (
    <div>
      <>
        <h3>Clubs List!</h3>
        {error && <Message severity={'error'}>{JSON.stringify(error)}</Message>}
        <DataTable
          value={data}
          responsiveLayout='scroll'
          loading={isLoading}
          selectionMode={'single'}
          selection={selectedClub}
          onSelectionChange={e => {
            setSelectedClub(e.value)
            router.navigate({ to: '/clubs/$clubId', params: { clubId: e.value.id } })
          }}
          dataKey='id'
        >
          <Column field='id' header='ID'></Column>
          <Column field='clubName' header='Club Name'></Column>
          <Column field='managerEmail' header='managerEmail'></Column>
        </DataTable>
      </>
    </div>
  )
}

Now browsing to http://localhost:5173/clubs produces the club list page

Clicking on a club line will cause the browser to navigate to http://localhost:5173/clubs/395152559210943000 which is currently still an empty page, let’s fix that by changing src/pages/club/edit.tsx to

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
export const ClubEditPage: FC = () => {
  const { clubId } = useParams()
  const { data } = useQuery(
    ['clubs', clubId],
    () => fetchClub(clubId ?? 'N/A'),
    { enabled: !!clubId }
  )

  const {
    formState: { errors },
    handleSubmit,
    register,
    setError,
  } = useForm({ defaultValues: data, resolver: zodResolver(ClubSchema) })

  const [serverErrors, setServerErrors] = useState<string | undefined>(
    undefined
  )

  const toastRef = useRef<Toast>()
  const mutation = useMutation(updateClub, {
    onMutate: _variables => {
      setServerErrors(undefined)
    },
    onSuccess: (result, _variables, _context) => {
      toastRef.current?.show({ severity: 'success', summary: 'Changes saved' })
    },
    onError: (error: any, _variables, _context) => {
      console.log('mutation error', error)
      const errorDetails = error.cause?.errors
      if (errorDetails) {
        Object.keys(errorDetails).forEach(key => {
          setError(key as any, errorDetails[key])
        })
        setServerErrors(errorDetails.message)
        toastRef.current?.show({
          severity: 'error',
          summary: 'Saving failed: ' + errorDetails.message,
        })
      } else {
        toastRef.current?.show({
          severity: 'error',
          summary: 'Saving failed: ' + error.message,
        })
      }
    },
  })

  const doSubmit = useCallback<(club: Club) => void>((vals: any) => {
    console.log('submit values', vals)
    mutation.mutate(vals)
  }, [])

  const getFormErrorMessage = useCallback<(n: string) => ReactNode | undefined>(
    name =>
      errors[name] && <small className="p-error">{errors[name].message}</small>,
    [errors]
  )

  const haveErrors = Object.keys(errors).length > 0
  return (
    <div>
      <>
        Club Edit {clubId}
        <Toast ref={toastRef} />
        {data && (
          <>
            <Card>
              {haveErrors && (
                <Message
                  severity={'warn'}
                  text={serverErrors ?? 'Please check your input!'}
                />
              )}
              <form
                method={'post'}
                onSubmit={handleSubmit(doSubmit)}
                className="p-fluid"
              >
                <input
                  type={'hidden'}
                  defaultValue={data.id}
                  {...register('id')}
                />
                <fieldset>
                  <div className="field">
                    <label
                      htmlFor={'clubName'}
                      className={classNames({ 'p-error': errors.clubName })}
                    >
                      ClubName
                    </label>
                    <InputText
                      id={'clubName'}
                      defaultValue={data.clubName}
                      {...register('clubName')}
                      className={classNames({ 'p-invalid': !!errors.clubName })}
                    />
                    {getFormErrorMessage('clubName')}
                  </div>
                </fieldset>
                <fieldset>
                  <div className="field">
                    <label
                      htmlFor={'managerEmail'}
                      className={classNames({ 'p-error': errors.managerEmail })}
                    >
                      managerEmail
                    </label>
                    <InputText
                      id={'managerEmail'}
                      defaultValue={data.managerEmail}
                      {...register('managerEmail')}
                      className={classNames({
                        'p-invalid': !!errors.managerEmail,
                      })}
                    />
                    {getFormErrorMessage('managerEmail')}
                  </div>
                </fieldset>
                <Button type={'submit'} disabled={mutation.isLoading}>
                  Save
                </Button>
              </form>
            </Card>
          </>
        )}
      </>
    </div>
  )
}

Wow, thats a lot to unpack here.

1
2
3
4
5
6
  const { clubId } = useParams()
  const { data } = useQuery(
    ['clubs', clubId],
    () => fetchClub(clubId ?? 'N/A'),
    { enabled: !!clubId }
  )

The first portion is the same as the club details page. We simply load the club if we don’t have it already.

Next, we create a react-hook-form instance

1
2
3
4
5
6
  const {
    formState: { errors },
    handleSubmit,
    register,
    setError,
  } = useForm({ defaultValues: data, resolver: zodResolver(ClubSchema) })

The errors variable will contain the client side form validation errors in the format

1
2
3
4
5
6
7
{
    "clubName": {
        "message": "String must contain at least 2 character(s)",
        "ref": "<reference of the ui element>",
        "type": "too_small"
    }
}

Next we also store server side errors in a serverErrors state hook.

1
2
3
4
5
  const [serverErrors, setServerErrors] = useState<string | undefined>(
    undefined
  )

  const toastRef = useRef<Toast>()

In addition we set up a reference to the Toast component which we will need to show server side errors to the user.

Then we define the mutation to update the Club

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
  const mutation = useMutation(updateClub, {
    onMutate: _variables => {
      setServerErrors(undefined)
    },
    onSuccess: (result, _variables, _context) => {
      toastRef.current?.show({ severity: 'success', summary: 'Changes saved' })
    },
    onError: (error: any, _variables, _context) => {
      const errorDetails = error.cause?.errors
      if (errorDetails) {
        Object.keys(errorDetails).forEach(key => {
          setError(key as any, errorDetails[key])
        })
        setServerErrors(errorDetails.message)
        toastRef.current?.show({
          severity: 'error',
          summary: 'Saving failed: ' + errorDetails.message,
        })
      } else {
        toastRef.current?.show({
          severity: 'error',
          summary: 'Saving failed: ' + error.message,
        })
      }
    },
  })

Here, in onMutate we clear any previous serverErrors, then in onSuccess we simply show a Toast to the user that saving was successful. The onError portion is the most interesting, we parse the server error response and if it has the correct format, we directly map the per-attribute server errors into the client form.

So this is where the work from part 1 finally pays off. setError allows us to programmatically set validation errors into the form.

1
2
3
4
  const doSubmit = useCallback<(club: Club) => void>((vals: any) => {
    console.log('submit values', vals)
    mutation.mutate(vals)
  }, [])

This is the form submit handler which simply fires off the mutation.

1
2
3
4
5
6
7
  const getFormErrorMessage = useCallback<(n: string) => ReactNode | undefined>(
    name =>
      errors[name] && <small className="p-error">{errors[name].message}</small>,
    [errors]
  )

  const haveErrors = Object.keys(errors).length > 0

The getFormErrorMessage helper is used to render validation errors directly into the form.

Finally, we add the form via

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<form
                method={'post'}
                onSubmit={handleSubmit(doSubmit)}
                className="p-fluid"
              >
                ...
                <fieldset>
                  <div className="field">
                    <label
                      htmlFor={'clubName'}
                      className={classNames({ 'p-error': errors.clubName })}
                    >
                      ClubName
                    </label>
                    <InputText
                      id={'clubName'}
                      defaultValue={data.clubName}
                      {...register('clubName')}
                      className={classNames({ 'p-invalid': !!errors.clubName })}
                    />
                    {getFormErrorMessage('clubName')}
                  </div>
                </fieldset>

Here we create the form which calls handleSubmit(doSubmit) upon sending the form.

The handleSubmit function will first validate the form and only if it is valid, it will invoke the inner doSubmit method that will trigger the AJAX request.

Testing server side validation errors

Now if you remember the zod validation we created way back when, you might notice that there is a discrepancy between the minimum length in typescript

1
2
export const ClubSchema = z.object({
  clubName: z.string().min(2).max(150),

and the corresponding java code

1
2
3
4
5
6
public class Club {
    ...
    @Column
    @NotBlank
    @Length(min = 3, max = 150)
    String clubName;

To be able to produce a server side validation error all we need to do is to update a club and assign it a clubName that is only 2 characters long. The server side validation errors are rendered in-line just like client side validation errors would. server-side validation errors

This concludes part 2 of the tutorial. In part 3 we will create the same application in Svelte to compare the client side implementation and find out why the hype around Svelte is justified.

You can find the code to this tutorial at https://github.com/rburgst/spring-boot-validation/tree/part-2

About the author
Rainer Burgstaller is an architect, full stack developer on the endless quest to maximise developer productivity.

read more from Rainer