2.1.You may not need useState. You can just use the platform.

Published:

By Misael Cázares

[ 2.Forms ]

Forms have been one of the most important pieces of the web since we started using it as more than just a platform to publish documents with static content and links between them. Once we started using the web to build highly dynamic applications, forms became one of the most used and important building blocks. Users were no longer just reading information — they were creating it, and forms are the easiest and most efficient way to capture it.

In this article we will explore how the same APIs that allowed these types of sites to flourish in the early days of Web 2.0 are still very powerful, and how they allow us to build complex UX interactions with no overhead or heavy dependencies on libraries and frameworks, using only the browser's native Web Platform APIs.

What the platform already gives you

When we think of forms in React, we immediately think of how we are going to handle data and validation state with the help of hooks such as useState. We think about the data structure of our form and the validation and error flags that allow us to implement our validation UX. And while that's perfectly fine, it's important to note that we may already have everything we need natively provided by the browser.

We get out of the box: named inputs mapped to FormData objects, read-only validation state from HTMLInputElements, and access to form context by accessing the HTMLFormElement ancestor from our inputs. We also have the Constraint Validation API, which allows us to implement complex validation rules that sometimes require setting constraints based on several field values or that involve complex calculations. It also gives us access to the same methods the browser itself uses to trigger and report validation errors to the user, which means we get to decide when and how we want to report errors.

All of this is natively provided by the browser — no boilerplate state management, no validation handling with useState, and no multiple re-renders on every input event.

The name attribute and the submit event

In order to take full advantage of all these native features, we need to follow some rules and constraints. This won't only help us use the form element's native features, but it will also help improve the semantics of our code and UI.

One of these rules is to add the name attribute to our form elements. When we add name to an HTMLInputElement, the name makes the input accessible as an entry in the form element's HTMLFormElement.elements collection. With the name attribute, our input sends the name-value pair in the FormData upon submission, so we get our data from the form state itself without having to worry about mapping our elements to local state. The form element owns the state and is the only source of truth. As we will see in the following sections, this also gives us access to powerful APIs that simplify our validation logic, let us traverse the elements in the form, and give us more control over the UX for reporting errors to users.

By using the native submit handler on form elements, we get out of the box: the form state with all our fields' name-value pairs in a FormData object, automatic validation error reporting to the user, and the submit event being suppressed if there are any constraint errors on the form elements. Less data transformation, less validation logic, and less data management to write ourselves — which means less code to maintain and a few fewer bytes sent to the browser. This can be a small improvement in large applications, but a significant one in smaller ones. We are using React to run our examples, but if you pay attention, we are barely using it. If you need to write simple forms for web applications and don't have other complex UI on the same page, you can save a lot of resources by relying on the browser's native APIs.

Let's now break all of this down and see it in action by building a form with custom validation rules that reports invalid states to the user with custom messages and prevents submission when the form is invalid. We'll be using the web's Constraint Validation API and the Tamagui UI library to style our components.

If you want to know more about how Tamagui works and everything it has to offer, check the official documentation here — you may find it useful for your next project.

Defining the building blocks

Let's start by defining the building blocks for our form. We can visualize our form as a vertical stack of input elements (textfield, radios, select) and labels, and a button to allow the user to send the information to the server. We have the Input, Label, and Button elements as building blocks, within a Form element that wraps our component and works like a provider for all the state management and validation APIs.

const Form = styled(YStack, { render: 'form' }) as unknown as FC<
  Omit<GetProps<typeof YStack>, keyof FormHTMLAttributes<HTMLFormElement>> &
    FormHTMLAttributes<HTMLFormElement>
>
 
const FormLabel = styled(Label, { size: '$3', lineHeight: '$7' })
 
const StyledInput = styled(Input, {
  rounded: '$6',
  placeholderTextColor: '$color04',
})

We will also add an extra component to help us share the layout we want for our Label and Input elements:

const FormGroup = styled(YStack)

It's a simple YStack to help us lay out the label and input fields vertically. We could just use YStack directly in the form, but by extending it into a separate component renamed to FormGroup, we make our code more readable and semantic.

For our form, we are choosing a styled YStack component — configured to render a <form> tag in the browser — over Tamagui's built-in Form component, since the Tamagui version does not currently expose all browser-native APIs. Tamagui ships with a lot of reusable, styleable cross-platform components for React Native and React Native Web, but when targeting the web we can have full control over the elements rendered into the DOM by using the render property. This allows us to keep using Tamagui's powerful styling system and reuse core components without losing any HTML semantics or native features.

const Form = styled(YStack, { render: 'form' }) as FC<
  Omit<GetProps<typeof YStack>, keyof FormHTMLAttributes<HTMLFormElement>> &
    FormHTMLAttributes<HTMLFormElement>
>

This is what our form composition will look like:

<Form>
  <FormGroup>
    <FormLabel />
    <FormInput />
  </FormGroup>
  <FormGroup>
    <FormLabel />
    <FormInput />
  </FormGroup>
  <Button />
</Form>

Building a sign-up form

As an example for this article, we will be building a basic sign-up form with name, email, password, and password confirmation fields. We will validate that every field is filled, that the email field is a valid email format, and that the password and passwordConfirmation fields match.

'use client'
import {
  Button,
  Label,
  Input,
  YStack,
  styled,
  type GetProps,
  type TamaguiElement,
} from 'tamagui'
import {
  type FormEvent,
  type FormHTMLAttributes,
  type FC,
} from 'react'
 
const FormLabel = styled(Label, { size: '$3', lineHeight: '$7' })
 
const FormGroup = styled(YStack)
 
const Form = styled(YStack, { render: 'form' }) as FC<
  Omit<GetProps<typeof YStack>, keyof FormHTMLAttributes<HTMLFormElement>> &
    FormHTMLAttributes<HTMLFormElement>
>
 
const StyledInput = styled(Input, {
  rounded: '$6',
  placeholderTextColor: '$color04',
})
 
const FormInput = (props) => <StyledInput {...props} />
 
const PASSWORD_PLACEHOLDER_TEXT = '••••••••••••••'
 
export default function HTMLFormDemo() {
  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const formData = new FormData(e.currentTarget)
    const data = Object.fromEntries(formData.entries())
    // Send FormData or JSON to server
  }
 
  return (
	  <Form onSubmit={handleSubmit} id="signupForm" gap="$3">
		<FormGroup>
		  <FormLabel htmlFor="name">Name</FormLabel>
		  <FormInput
			id="name"
			name="name"
			placeholder="Enter your name..."
			required
			autoComplete="name"
		  />
		</FormGroup>
		<FormGroup>
		  <FormLabel htmlFor="email">Email</FormLabel>
		  <FormInput
			id="email"
			name="email"
			placeholder="name@example.com"
			required
			type="email"
			autoComplete="email"
		  />
		</FormGroup>
		<FormGroup>
		  <FormLabel htmlFor="password">Password</FormLabel>
		  <FormInput
			id="password"
			name="password"
			placeholder={PASSWORD_PLACEHOLDER_TEXT}
			required
			type="password"
		  />
		</FormGroup>
		<FormGroup>
		  <FormLabel htmlFor="passwordConfirmation">Confirm your password</FormLabel>
		  <FormInput
			id="passwordConfirmation"
			name="passwordConfirmation"
			placeholder={PASSWORD_PLACEHOLDER_TEXT}
			required
			type="password"
		  />
		</FormGroup>
		<Button rounded="$6" mt="$4" theme="surface2" type="submit">
		  Sign up
		</Button>
	  </Form>
  )
}

That was pretty straightforward. A simple form with a type="submit" button, all form fields with a name attribute, and we get state management and validation logic out of the way. The data will be available as name-value pairs in the submit event's currentTarget, and the event will not fire if the fields do not meet the constraints we set via the required attribute and type="email". The browser will render message bubbles over the invalid fields when the submit button is clicked. If all fields have valid values, the submit event will fire.

We can even style the fields by using the :user-invalid pseudo-class on the input elements. The browser will apply the styles once the user has interacted with an input and its value is invalid.

Adding cross-field validation

That covers the basic case, but we are still missing the validation to make sure our password and passwordConfirmation fields match. This validation differs from the name and email validations because we don't only need to check the field itself — we also need to compare it to the value of another field. We also want to provide the user with a meaningful error message. This is the right moment to extend the input element to accept custom validation functions by creating a custom component to which we can assign unique validation logic.

We want to support validation functions that provide enough context to implement basic rules against the existing form state, while remaining flexible enough to handle edge cases that may need access to data outside the form elements.

We define the interface of our validation functions:

type CustomValidation = (
  name: string,
  value: string | undefined | null,
  element: HTMLInputElement,
  ...args: any[]
) => string

We pass the name, value, and HTML element as required parameters for basic validation rules, and additionally accept any other arguments to allow more complex scenarios with external dependencies. The function returns an error string — if the string is empty, we can assume the value is valid.

Extending FormInput with custom validation

Now we can create our custom Input component and pass in custom validation functions that will update the validation state of the input element on user interaction.

import { mergeRefs } from 'app/hooks/mergeRefs'
 
type FormInputProps = GetProps<typeof StyledInput> & {
  validation?: CustomValidation
  ref?: RefObject<HTMLInputElement | null>
}
 
const FormInput = (props: FormInputProps) => {
  const { name, ref, validation, onChange, onInput, ...rest } = props
  const inputRef = useRef<HTMLInputElement>(null)
 
  const runCustomValidation = (e: FormEvent<HTMLInputElement>) => {
    if (name && validation) {
      const { currentTarget } = e
      const { value } = currentTarget
      currentTarget?.setCustomValidity(validation(name, value, currentTarget))
    }
  }
  const handleChange = (e: FormEvent<HTMLInputElement>) => {
    runCustomValidation(e)
    onChange?.(e)
  }
  const handleInput = (e: FormEvent<HTMLInputElement>) => {
    runCustomValidation(e)
    onInput?.(e)
  }
  const ownProps = { name }
  const overrides = { onChange: handleChange, onInput: handleInput }
  return (
    <StyledInput
      ref={mergeRefs<HTMLInputElement>([inputRef, ref]) as Ref<TamaguiElement>}
      {...ownProps}
      {...rest}
      {...overrides}
    />
  )
}

There are two important things happening here.

mergeRefs: We need to keep a reference to the underlying input element in the DOM in case we need it, but we also need to provide the same access to anything consuming the component.

We can create a simple utility to merge both refs:

export function mergeRefs<T>(refs: Array<Ref<T> | undefined>): RefCallback<T> {
  return (node) => {
    for (const ref of refs) {
      if (!ref) continue
      if (typeof ref === 'function') {
        ref(node)
      } else {
        ;(ref as { current: T | null }).current = node
      }
    }
  }
}

setCustomValidity: We also have setCustomValidity, which we can call on user input to change the validation state of the element. This is a method that exists on the HTMLInputElement interface. It accepts an error message string. If the string is empty, it sets the validity state to valid; otherwise it sets it to invalid. The same string is what the browser will display when reporting the validity status in its message bubbles.

Now we can write our custom validation functions for the password and passwordConfirmation fields:

const passwordConfirmationValidation: CustomValidation = (_name, value, element) => {
  const form = element.form
  if (!form) return ''
  const passwordElement = form.elements.namedItem('password') as HTMLInputElement
  if (!passwordElement) return ''
  const passwordValue = passwordElement.value || ''
  return value === passwordValue ? '' : 'Passwords do not match'
}
 
const passwordValidation: CustomValidation = (_name, _value, element) => {
  const form = element.form
  if (!form) return ''
  const passwordConfirmationElement = form.elements.namedItem(
    'passwordConfirmation'
  ) as HTMLInputElement
  if (!passwordConfirmationElement) return ''
  const passwordConfirmationValue = passwordConfirmationElement?.value
  if (!passwordConfirmationValue) return ''
  passwordConfirmationElement.setCustomValidity(
    passwordConfirmationValidation(
      'passwordConfirmation',
      passwordConfirmationValue,
      passwordConfirmationElement
    )
  )
  return ''
}

Here's another important thing to note:

form.elements.namedItem('password')

This is one of the benefits we get simply by using the name attribute on our fields. We have access to other field elements and the parent form element, and we can look up a specific control with namedItem — which returns the matching Element, a RadioNodeList if multiple elements share that name, or null if none match — without needing any other query selectors. Without the name attribute this wouldn't be possible as easily, since the form wouldn't have the field in the elements collection.

Wrapping up

And that's it. With just a few browser APIs, we have a fully working form with custom validations, error messages, and data access upon submission — all without custom state management to track input values, error states and flags, or custom disabled states and error message rendering.

A basic Sign-up form

While all this may look like we are overthinking forms here, it's important to note that not all applications or UIs have complex requirements, lots of state management or a super stylish UX. By keeping things simple and our toolbox lightweight, we can really improve performance, avoid unnecessary UX issues by re-doing patterns that already work and have been proved to be successful when it comes to user usability, and saves us time and technical debt. Even If we start simple, we can always support more complex scenarios with progressive enhancement. And if we need more UX rich features, or handle complex state management and dynamic forms, we can always use existing libraries to support our most demanding use cases.

The message here is to always look for what the Web Platform already has to offer to help us build future-proof web applications.

You can take a look at the complete code for this exercise here:

Files
  • src
  • hooks
  • mergeRefs.ts
  • screens
  • ProgressiveFormScreen.tsx
  • ProgressiveFormScreen.tsx
    'use client'
    import {
      Button,
      Label,
      Input,
      YStack,
      Heading,
      XStack,
      styled,
      type GetProps,
      type TamaguiElement,
    } from 'tamagui'
    import {
      type FormEvent,
      type FormHTMLAttributes,
      type FC,
      type Ref,
      useRef,
      RefObject,
    } from 'react'
    import { mergeRefs } from 'app/hooks/mergeRefs'
     
    const FormLabel = styled(Label, { size: '$3', lineHeight: '$7' })
     
    const FormGroup = styled(YStack)
     
    const Form = styled(YStack, { render: 'form' }) as FC<
      Omit<GetProps<typeof YStack>, keyof FormHTMLAttributes<HTMLFormElement>> &
        FormHTMLAttributes<HTMLFormElement>
    >
     
    const StyledInput = styled(Input, {
      rounded: '$6',
      placeholderTextColor: '$color04',
    })
     
    type FormInputProps = GetProps<typeof StyledInput> & {
      validation?: CustomValidation
      ref?: RefObject<HTMLInputElement | null>
    }
     
    type CustomValidation = (
      name: string,
      value: string | undefined | null,
      element: HTMLInputElement,
      ...args: any[]
    ) => string
     
    const FormInput = (props: FormInputProps) => {
      const { name, ref, validation, onChange, onInput, ...rest } = props
      const inputRef = useRef<HTMLInputElement>(null)
     
      const runCustomValidation = (e: FormEvent<HTMLInputElement>) => {
        if (name && validation) {
          const { currentTarget } = e
          const { value } = currentTarget
          currentTarget?.setCustomValidity(validation(name, value, currentTarget))
        }
      }
      const handleChange = (e: FormEvent<HTMLInputElement>) => {
        runCustomValidation(e)
        onChange?.(e)
      }
      const handleInput = (e: FormEvent<HTMLInputElement>) => {
        runCustomValidation(e)
        onInput?.(e)
      }
      const ownProps = { name }
      const overrides = { onChange: handleChange, onInput: handleInput }
      return (
        <StyledInput
          ref={mergeRefs<HTMLInputElement>([inputRef, ref]) as Ref<TamaguiElement>}
          {...ownProps}
          {...rest}
          {...overrides}
        />
      )
    }
     
    const passwordConfirmationValidation: CustomValidation = (_name, value, element) => {
      const form = element.form
      if (!form) return ''
      const passwordElement = form.elements.namedItem('password') as HTMLInputElement
      if (!passwordElement) return ''
      const passwordValue = passwordElement.value || ''
      return value === passwordValue ? '' : 'Passwords do not match'
    }
     
    const passwordValidation: CustomValidation = (_name, _value, element) => {
      const form = element.form
      if (!form) return ''
      const passwordConfirmationElement = form.elements.namedItem(
        'passwordConfirmation'
      ) as HTMLInputElement
      if (!passwordConfirmationElement) return ''
      const passwordConfirmationValue = passwordConfirmationElement?.value
      if (!passwordConfirmationValue) return ''
      passwordConfirmationElement.setCustomValidity(
        passwordConfirmationValidation(
          'passwordConfirmation',
          passwordConfirmationValue,
          passwordConfirmationElement
        )
      )
      return ''
    }
     
    const PASSWORD_PLACEHOLDER_TEXT = '••••••••••••••'
     
    export default function ProgressiveFormScreen() {
      const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
        e.preventDefault()
        const formData = new FormData(e.currentTarget)
        const data = Object.fromEntries(formData.entries())
        // Send FormData or JSON to server
      }
      return (
          <YStack gap="$4" flex={1} maxW={450}>
            <Heading size="$4">Sign-up</Heading>
            <YStack
              p="$8"
              rounded="$8"
              borderWidth={1}
              backgroundColor="$background"
              borderColor="$borderColor"
            >
              <Form onSubmit={handleSubmit} id="signupForm" gap="$3">
                <FormGroup>
                  <FormLabel htmlFor="name">Name</FormLabel>
                  <FormInput
                    id="name"
                    name="name"
                    placeholder="Enter your name..."
                    required
                    autoComplete="name"
                  />
                </FormGroup>
                <FormGroup>
                  <FormLabel htmlFor="email">Email</FormLabel>
                  <FormInput
                    id="email"
                    name="email"
                    placeholder="name@example.com"
                    required
                    type="email"
                    autoComplete="email"
                  />
                </FormGroup>
                <FormGroup>
                  <FormLabel htmlFor="password">Password</FormLabel>
                  <FormInput
                    id="password"
                    name="password"
                    placeholder={PASSWORD_PLACEHOLDER_TEXT}
                    required
                    type="password"
                    validation={passwordValidation}
                  />
                </FormGroup>
                <FormGroup>
                  <FormLabel htmlFor="passwordConfirmation">Confirm your password</FormLabel>
                  <FormInput
                    id="passwordConfirmation"
                    name="passwordConfirmation"
                    placeholder={PASSWORD_PLACEHOLDER_TEXT}
                    required
                    type="password"
                    validation={passwordConfirmationValidation}
                  />
                </FormGroup>
                <Button rounded="$6" mt="$4" theme="surface2" type="submit">
                  Sign up
                </Button>
              </Form>
            </YStack>
          </YStack>
      )
    }