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
TextInput- single-line or multiline text.label,placeholder,multiline, and the usual RN text input props.NumberInput- numeric input with locale-aware parsing.SelectInput- a dropdown that opens a themed actionsheet. Pass anoptions: SelectOption[]array.TextAreaInput- a taller multiline with auto-grow.Toggle- boolean switch.
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
- Using
useStatefor field values. Don’t. See above. - Importing
useFormfromreact-hook-formdirectly. Use the@tinycld/core/ui/formbarrel so the package stays in sync with@tinycld/core’s resolver and zod versions. - Calling
mutation.mutatewithouthandleSubmit. You’ll submit invalid data and skip validation entirely.
For the data-write side of this pattern, see Mutate data.