Forms

Every form in TinyCld uses react-hook-form for state, zod for validation, and the themed input components from @tinycld/core/ui/form (TextInput, SelectInput, and friends) for rendering. The three layer neatly: zod describes the shape, react-hook-form owns the state and submit lifecycle, and the inputs wire themselves to Controller without ceremony.

Why not useState

Form fields held in useState force you to reimplement the parts react-hook-form already solves: per-field error state, dirty tracking, submit-time validation, reset on success. Every hand-rolled form grows into a small buggy copy of react-hook-form. Use the real thing from the start.

The pattern

import { useForm, zodResolver, z, TextInput, FormErrorSummary } from '@tinycld/core/ui/form'
import { useStore } from '@tinycld/core/lib/pocketbase'
import { useMutation } from '@tinycld/core/lib/mutations'
import { handleMutationErrorsWithForm } from '@tinycld/core/lib/errors'
import { newRecordId } from 'pbtsdb'
import { useRouter } from 'expo-router'
import { Button } from '@tinycld/core/ui/button'

const schema = z.object({
    title: z.string().min(1, 'Title is required'),
    notes: z.string().max(1000).optional(),
})

type FormData = z.infer<typeof schema>

export default function NewExample() {
    const router = useRouter()
    const [exampleCollection] = useStore('example')

    const { control, handleSubmit, setError, getValues, formState } = useForm<FormData>({
        resolver: zodResolver(schema),
        defaultValues: { title: '', notes: '' },
    })

    const create = useMutation({
        mutationFn: function* (data: FormData) {
            yield exampleCollection.insert({ id: newRecordId(), ...data })
        },
        onSuccess: () => router.back(),
        onError: handleMutationErrorsWithForm({ setError, getValues }),
    })

    return (
        <>
            <FormErrorSummary errors={formState.errors} />
            <TextInput control={control} name="title" label="Title" />
            <TextInput control={control} name="notes" label="Notes" multiline />
            <Button onPress={handleSubmit(create.mutate)} loading={create.isPending}>
                Save
            </Button>
        </>
    )
}

Everything you need is re-exported from @tinycld/core/ui/form: useForm, Controller, zodResolver, z, and the input components. You don’t import from react-hook-form or @hookform/resolvers/zod directly - the @tinycld/core/ui/form barrel is the canonical entry point.

Input components

Every component takes control and name and wires itself through <Controller> internally. You don’t manage refs, state, or onChangeText by hand. They all render field-level errors from formState.errors[name] in a consistent style - no separate error markup needed.

FormErrorSummary

Render <FormErrorSummary errors={formState.errors} /> once at the top of the form for form-wide errors (anything setError('root', ...) produces) and cross-field zod errors. It renders nothing when there are no top-level errors, so it’s safe to always include.

Submitting through useMutation

Always pass handleSubmit(mutation.mutate) to your submit button. handleSubmit validates first and only calls your handler with typed, valid data; the mutation layer then handles the pbtsdb transactions and optimistic updates. The handleMutationErrorsWithForm helper mentioned above translates PocketBase validation errors back onto specific fields - you don’t have to wire field errors by hand.

Common mistakes

For the data-write side of this pattern, see Mutate data.