Form Management

Schema based Form management

import { useFormState } from '@green-stack/forms/useFormState'
      • Some.schema.ts
      • useSomeFormState.ts ←

A major goal of this starterkit is to use zod schemas as the ultimate source of truth for your app’s datastructure, validation and types. We’ve extended this concept to include form management as well:

useFormState()

useSomeFormState.ts
// Define a Zod schema for your form state (or use an existing one)
const SomeFormState = schema('SomeFormState', {
    name: z.string(),
    age: z.number().optional(),
    email: z.string().email(),
    birthDate: z.date().optional(),
})
 
// Create a set of form state utils to use in your components
const formState = useFormState(SomeFormState, {
    initialValues: { ... },
    // ... other options
})

formState.values is typed according to the Zod schema you provided as the first argument:

formState.values.name // string
formState.values.age // number | undefined
formState.values.email // string
formState.values.birthDate // Date | undefined

defaults and initialValues

You can provide initial values through the initialValues option:

const formState = useFormState(SomeFormSchema, {
    initialValues: {
        name: 'Thorr Codinsonn',
        email: 'thorr@codinsonn.dev',
    },
})
 
formState.values.name // 'Thorr Codinsonn'
formState.values.email // 'thorr@codinsonn.dev'

Alternatively, your schema can define default values as well:

const SomeFormSchema = schema('SomeFormSchema', {
    name: z.string().default('Thorr Codinsonn'),
    email: z.string().email().default('thorr@codinsonn.dev'),
})
 
const formState = useFormState(SomeFormSchema)
 
formState.values.name // 'Thorr Codinsonn'
formState.values.email // 'thorr@codinsonn.dev'

Retrieving and updating values

You can use formState.getValue('some-key') to get a specific value from the form state. The 'some-key' argument is any key in the Zod schema you provided as stateSchema and the available keys will he hinted by your IDE.

formState.getValue('name') // string
//                   ?^  Hinted keys: 'name' | 'email' | 'age' | 'birthDate'

Updating the formState values can similarly be done in two ways:

// Update a single value in the form state by its hinted key
formState.handleChange('email', 'thorr@fullproduct.dev') // OK
formState.handleChange('age', 'thirty two') // ERROR (non a number)
// Update multiple values in the form state by passing an object with keys and values
formState.setValues({
    name: 'Thorr', // OK
    email: 'thorr@fullproduct.dev', // OK
    age: 'some-string', // ERROR: Type 'string' is not assignable to type 'number'
})

Typescript and your IDE will help you out with the available keys and allowed values through hints and error markings if you try to set a value that doesn’t match the Zod schema.

Validation and error handling

Call formState.validate() to validate current state values against the Zod schema:

const isValid = formState.validate() // true | false

Validating the formstate will also update the formState.errors object with any validation errors.

formState.errors
// {
//    name: ['Required'],
//    email: ['Invalid email']
// }

You could also trigger validation on any change to the form state by passing the validateOnChange option:

const formState = useFormState(SomeFormSchema, {
    validateOnChange: true,
})

You can manually update or clear the errors object:

formState.updateErrors({ name: ['Required'] })
 
// Clear all errors
formState.clearErrors()

clearErrors() is essentially the same thing as clearing errors manually with:

// e.g. Clear all error messages by passing an empty object or empty arrays
formState.updateErrors({
    username: [],
    email: [],
    password: [],
    twoFactorCode: [],
})

Custom Errors

You can provide custom error messages by passing the message prop in your form schema:

age: z.number({ message: 'Please provide a number' })
// Throws => ZodError: [{ message: "Please provide a number", ... })

You can provide custom error messages for specific validations as well:

const SomeFormSchema = schema('SomeFormSchema', {
 
    age: z.number().min(10, { message: 'You must be at least 18 years old' }),
    // Throws => ZodError: [{ message: "You must be at least 18 years old", ... })
 
    password: z.string().len(12, { message: 'Passwords must be at least 12 characters long' }),
    // Throws => ZodError: [{ message: "Passwords must be at least 12 characters long", ... })
 
})

Universal Form Components

Our full-product.dev starterkit comes with some (minimally styled) universal form components out of the box.

You can see them in the sidebar under @app-core/forms:

These are built with useFormState in mind and based on Nativewind-compatible react-native-primitives.

Integrating with Components

formState exposes a few utility functions to make it easy to integrate with form components:

  • value props
  • onChange props
  • hasError prop

getInputProps(...)

You can use formState.getInputProps() to easily integrate your form state with React components that:

  • Accept a value prop
  • Accept an onChange prop
<NumberStepper
    placeholder="e.g. 32"
    min={18}
    max={150}
    step={1}
    {...formState.getInputProps('age')}
/>
 
<CheckList
    options={{
        'option-1': 'Option 1',
        'option-2': 'Option 2',
        'option-3': 'Option 3',
    }}
    {...formState.getInputProps('options')}
/>

getTextInputProps(...)

formState.getTextInputProps() is a replacement for getInputProps() with a TextInput component:

  • Uses onChangeText instead of onChange
<TextArea
    placeholder="How could we further improve your workflow?"
    {...formState.getTextInputProps('feedbackSuggestions')}
/>
 
<TextInput
    placeholder="e.g. thorr@fullproduct.dev"
    {...formState.getTextInputProps('email')}
/>

Best practices

formState as props

If a child component requires access to the form state, you’ll save a bunch of time and lines of code by passing the formState object as a prop:

<SomeChildComponent formState={formState} />

This is typically handy when:

  • ✅ You’re doing modal forms
  • ✅ You have a clear logic and view layer split
  • ✅ You’re trying to avoid using global state

Flags to track changes

Sometimes you need to execute code when your form state changes.

It’s a good idea to use the flags provided in formState for this:

// Whether the form is valid, even before calling .validate()
const isValid = formState.isValid
 
// Whether the form had changes since the last save
const isUnsaved = formState.isUnsaved
 
// Whether the form is in its default state (from initialValues or schema defaults)
const isDefaultState = formState.isDefaultState

Triggering effects based on form state changes

formState has a valuesKey property that changes every time the form state changes. This can be used to trigger effects when the form state changes:

useEffect(() => {
    // Do something when the form state changes
}, [formState.valuesKey])

API Reference

PropertyDescription
valuesThe current values of the form
setValuesSets the entire form state to the provided values
getValueGets the form value for the provided field key
setValue: handleChangeSets the form value for the provided field key
handleChangeSets the form value for the provided field key
getChangeHandlerGets the change handler for the provided field key
validateValidates the form state, sets errors if not, and returns whether it is valid or not
isValidWhether the form is valid
isUnsavedWhether the form is unsaved
isDefaultStateWhether the form is in its default state
errorsThe current errors of the form
updateErrorsSets the errors for the form
getErrorsGets the errors for the provided field key
hasErrorWhether the provided field key has an error
clearErrorsClears all errors until validated again
clearFormClears the form values, applying only the schema defaults
resetFormResets the form to its original state using initialValues & schema defaults
getInputPropsThe props to add to an input to manage its state
getTextInputPropsThe props to add to a text input to manage its state, uses onTextChange instead
getNumberTextInputPropsThe props to add to a number input to manage its state, uses onTextChange instead
valuesKeyThe key of the current form values, good for use in hook dependencies to trigger recalculations

Further reading

From our own docs:

Relevant external docs: