diff --git a/apps/web/app/routes/examples.tsx b/apps/web/app/routes/examples.tsx
index f2313ef..23f35e9 100644
--- a/apps/web/app/routes/examples.tsx
+++ b/apps/web/app/routes/examples.tsx
@@ -94,6 +94,9 @@ export default function Component() {
useFormState
+
+ useField
+
Multiple forms
diff --git a/apps/web/app/routes/examples/forms/use-field.tsx b/apps/web/app/routes/examples/forms/use-field.tsx
new file mode 100644
index 0000000..5b23b8c
--- /dev/null
+++ b/apps/web/app/routes/examples/forms/use-field.tsx
@@ -0,0 +1,84 @@
+import type {
+ ActionFunction,
+ LoaderFunction,
+ MetaFunction,
+} from '@remix-run/node'
+import { makeDomainFunction } from 'domain-functions'
+import hljs from 'highlight.js/lib/common'
+import * as React from 'react'
+import { useField } from 'remix-forms'
+import { z } from 'zod'
+import { formAction } from '~/formAction'
+import { cx, metaTags } from '~/helpers'
+import Example from '~/ui/example'
+import Form from '~/ui/form'
+
+const title = 'useField'
+const description = `In this example, we use the useField hook to display error, dirty and required indicators in custom components.`
+
+export const meta: MetaFunction = () => metaTags({ title, description })
+
+const code = `const schema = z.object({
+ email: z.string().email(),
+})
+
+const Input = React.forwardRef<
+ HTMLInputElement,
+ JSX.IntrinsicElements['input']
+>(({ type = 'text', ...props }, ref) => {
+ const { errors } = useField()
+ return (
+
+ )
+})
+
+export default () => (
+
+)`
+
+const schema = z.object({
+ email: z.string().email(),
+})
+
+export const loader: LoaderFunction = () => ({
+ code: hljs.highlight(code, { language: 'ts' }).value,
+})
+
+const mutation = makeDomainFunction(schema)(async (values) => values)
+
+export const action: ActionFunction = async ({ request }) =>
+ formAction({ request, schema, mutation })
+
+const Input = React.forwardRef<
+ HTMLInputElement,
+ JSX.IntrinsicElements['input']
+>(({ type = 'text', ...props }, ref) => {
+ const { errors } = useField()
+ return (
+
+ )
+})
+
+export default () => (
+
+
+
+)
diff --git a/apps/web/tests/examples/forms/use-field.spec.ts b/apps/web/tests/examples/forms/use-field.spec.ts
new file mode 100644
index 0000000..722c2f4
--- /dev/null
+++ b/apps/web/tests/examples/forms/use-field.spec.ts
@@ -0,0 +1,31 @@
+import { test, expect } from 'tests/setup/tests'
+
+const route = '/examples/forms/use-field'
+
+test('With JS enabled', async ({ example }) => {
+ const { email, button, page } = example
+
+ await page.goto(route)
+
+ // Render
+ await example.expectField(email, {
+ label: 'Email',
+ })
+ await expect(button).toBeEnabled()
+
+ // Fill in an invalid email address
+ await email.input.fill('john@doe')
+
+ // Client-side validation
+ await button.click()
+
+ // Show fields that are invalid using class
+ const invalidClass = 'border-red-600 focus:border-red-600 focus:ring-red-600'
+
+ expect(await email.input.getAttribute('class')).toContain(invalidClass)
+
+ // Fill in a valid email
+ await email.input.fill('default@domain.tld')
+
+ expect(await email.input.getAttribute('class')).not.toContain(invalidClass)
+})
diff --git a/packages/remix-forms/src/createField.tsx b/packages/remix-forms/src/createField.tsx
index 90ea50d..c0ff735 100644
--- a/packages/remix-forms/src/createField.tsx
+++ b/packages/remix-forms/src/createField.tsx
@@ -140,6 +140,18 @@ type SmartInputProps = {
a11yProps?: Record<`aria-${string}`, string | boolean | undefined>
}
+const FieldContext = React.createContext<
+ Partial, 'name'>> | undefined
+>(undefined)
+
+export function useField() {
+ const context = React.useContext(FieldContext)
+
+ if (!context) throw new Error('useField used outside of field context')
+
+ return context
+}
+
const makeSelectOption = ({ name, value }: Option) => (