</> useLens
React Hook Form Lenses is a powerful TypeScript-first library that brings the elegance of functional lenses to form development. It provides type-safe manipulation of nested structures, enabling developers to precisely control and transform complex data with ease.
useLens
is a custom hook that creates a lens instance connected to your React Hook Form control, enabling type-safe focusing, transformation, and manipulation of deeply nested form data structures through functional programming concepts.
Installation
npm install @hookform/lenses
Features
- Type-Safe Form State: Focus on specific parts of your data with full TypeScript support and precise type inference
- Functional Lenses: Build complex transformations through composable lens operations
- Deep Structure Support: Handle deeply nested structures and arrays elegantly with specialized operations
- Seamless Integration: Work smoothly with React Hook Form's Control API and existing functionality
- Optimized Performance: Each lens is cached and reused for optimal efficiency
- Array Handling: Specialized support for dynamic fields with type-safe mapping
- Composable API: Build complex transformations through elegant lens composition
Props
The useLens
hook accepts the following configuration:
control
: Control<TFieldValues>
Required. The control object from React Hook Form's useForm
hook. This connects your lens to the form management system.
const { control } = useForm<MyFormData>()const lens = useLens({ control })
Dependencies Array (Optional)
You can optionally pass a dependency array as the second parameter to clear the lens cache and re-create all lenses when dependencies change:
const lens = useLens({ control }, [dependencies])
This is useful when you need to reset the entire lens cache based on external state changes.
Return
The following table contains information about the main types and operations available on lens instances:
Core Types:
Lens<T>
- The main lens type that provides different operations based on the field type you're working with:
type LensWithArray = Lens<string[]>type LensWithObject = Lens<{ name: string; age: number }>type LensWithPrimitive = Lens<string>
Main Operations:
These are the core methods available on every lens instance:
Method | Description | Returns |
---|---|---|
focus | Focuses on a specific field path | Lens<PathValue> |
reflect | Transform and reshape lens structure | Lens<NewStructure> |
map | Iterate over array fields (with useFieldArray) | R[] |
interop | Connect to React Hook Form's control system | { control, name } |
narrow | Type-safe narrowing of union types | Lens<SubType> |
assert | Runtime type assertion for type narrowing | void |
defined | Exclude null and undefined from lens type | Lens<NonNullable> |
cast | Force type change (unsafe) | Lens<NewType> |
focus
Creates a new lens focused on a specific path. This is the primary method for drilling down into your data structure.
// Type-safe path focusingconst profileLens = lens.focus("profile")const emailLens = lens.focus("profile.email")const arrayItemLens = lens.focus("users.0.name")
Array focusing:
function ContactsList({ lens }: { lens: Lens<Contact[]> }) {// Focus on specific array indexconst firstContact = lens.focus("0")const secondContactName = lens.focus("1.name")return (<div><ContactForm lens={firstContact} /><input{...secondContactName.interop((ctrl, name) => ctrl.register(name))}/></div>)}
The focus
method provides full TypeScript support with autocompletion and type checking:
- Autocomplete available field paths
- Type errors for non-existent paths
- Inferred return types based on focused field
reflect
Transforms the lens structure with complete type inference. This is useful when you want to create a new lens from an existing one with a different shape to pass to a shared component.
The first argument is a proxy with a dictionary of lenses. Note that lens instantiation happens only on property access. The second argument is the original lens.
Object Reflection
const contactLens = lens.reflect(({ profile }) => ({name: profile.focus("contact.firstName"),phoneNumber: profile.focus("contact.phone"),}))<SharedComponent lens={contactLens} />function SharedComponent({lens,}: {lens: Lens<{ name: string; phoneNumber: string }>}) {return (<div><input{...lens.focus("name").interop((ctrl, name) => ctrl.register(name))}/><input{...lens.focus("phoneNumber").interop((ctrl, name) => ctrl.register(name))}/></div>)}
Alternative syntax using the lens parameter:
You can also use the second parameter (the original lens) directly:
const contactLens = lens.reflect((_, l) => ({name: l.focus("profile.contact.firstName"),phoneNumber: l.focus("profile.contact.phone"),}))<SharedComponent lens={contactLens} />function SharedComponent({lens,}: {lens: Lens<{ name: string; phoneNumber: string }>}) {// ...}
Array Reflection
You can restructure array lenses:
function ArrayComponent({ lens }: { lens: Lens<{ value: string }[]> }) {return (<AnotherComponent lens={lens.reflect(({ value }) => [{ data: value }])} />)}function AnotherComponent({ lens }: { lens: Lens<{ data: string }[]> }) {// ...}
Note that for array reflection, you must pass an array with a single item as the template.
Merging Lenses
You can use reflect
to merge two lenses into one:
function Component({lensA,lensB,}: {lensA: Lens<{ firstName: string }>lensB: Lens<{ lastName: string }>}) {const combined = lensA.reflect((_, l) => ({firstName: l.focus("firstName"),lastName: lensB.focus("lastName"),}))return <PersonForm lens={combined} />}
Keep in mind that in such cases, the function passed to reflect
is no longer pure.
Spread Operator Support
You can use spread in reflect if you want to leave other properties as is. At runtime, the first argument is just a proxy that calls focus
on the original lens. This is useful for proper typing when you need to change the property names for only a few fields and leave the rest unchanged:
function Component({lens,}: {lens: Lens<{ firstName: string; lastName: string; age: number }>}) {return (<PersonFormlens={lens.reflect(({ firstName, lastName, ...rest }) => ({...rest,name: firstName,surname: lastName,}))}/>)}
map
Maps over array fields with useFieldArray
integration. This method requires the fields
property from useFieldArray
.
import { useFieldArray } from "@hookform/lenses/rhf"function ContactsList({ lens }: { lens: Lens<Contact[]> }) {const { fields, append, remove } = useFieldArray(lens.interop())return (<div><button onClick={() => append({ name: "", email: "" })}>Add Contact</button>{lens.map(fields, (value, l, index) => (<div key={value.id}><button onClick={() => remove(index)}>Remove</button><ContactForm lens={l} /></div>))}</div>)}function ContactForm({lens,}: {lens: Lens<{ name: string; email: string }>}) {return (<div><input{...lens.focus("name").interop((ctrl, name) => ctrl.register(name))}/><input{...lens.focus("email").interop((ctrl, name) => ctrl.register(name))}/></div>)}
Map callback parameters:
Parameter | Type | Description |
---|---|---|
value | T | The current field value with id |
lens | Lens<T> | Lens focused on the current array item |
index | number | Current array index |
array | T[] | The complete array |
originLens | Lens<T[]> | The original array lens |
interop
The interop
method provides seamless integration with React Hook Form by exposing the underlying control
and name
properties. This allows you to connect your lens to React Hook Form's control API.
First Variant: Object Return
The first variant involves calling interop()
without arguments, which returns an object containing the control
and name
properties for React Hook Form:
const { control, name } = lens.interop()return <input {...control.register(name)} />
Second Variant: Callback Function
The second variant passes a callback function to interop
which receives the control
and name
properties as arguments. This allows you to work with these properties directly within the callback scope:
return (<form onSubmit={handleSubmit(console.log)}><input {...lens.interop((ctrl, name) => ctrl.register(name))} /><input type="submit" /></form>)
Integration with useController
The interop
method's return value can be passed directly to the useController
hook from React Hook Form, providing seamless integration:
import { useController } from "react-hook-form"function ControlledInput({ lens }: { lens: Lens<string> }) {const { field, fieldState } = useController(lens.interop())return (<div><input {...field} />{fieldState.error && <p>{fieldState.error.message}</p>}</div>)}
The narrow
, assert
, defined
, and cast
methods serve as escape hatches for current TypeScript limitations with lens type compatibility. These methods address scenarios where you need to pass lenses with wider types to components expecting narrower types.
These workarounds will become less necessary once issue #38 is resolved, which aims to improve lens type variance to allow more natural type narrowing and component composition.
narrow
The narrow
method provides type-safe narrowing of union types, allowing you to tell the type system which branch of a union you want to work with. This is particularly useful when working with discriminated unions or optional values.
Manual Type Narrowing
Use the single generic parameter to manually narrow the type when you know (by external logic) what the value should be:
// Lens<string | number>const unionLens = lens.focus("optionalField")// Narrow to string when you know it's a stringconst stringLens = unionLens.narrow<string>()// Now: Lens<string>
Discriminated Union Narrowing
Use the discriminant overload to narrow based on a specific property value:
type Animal = { type: "dog"; breed: string } | { type: "cat"; indoor: boolean }const animalLens: Lens<Animal> = lens.focus("pet")// Narrow to Dog type using discriminantconst dogLens = animalLens.narrow("type", "dog")// Now: Lens<{ type: 'dog'; breed: string }>const breedLens = dogLens.focus("breed")// Type-safe access to dog-specific properties
The narrow
method performs type-level operations only. It doesn't validate the runtime value - use it when you have external guarantees about the value's type (e.g., from validation, conditional rendering, or runtime checks).
assert
The assert
method provides runtime type assertions that convince TypeScript the current lens is already the desired subtype. Unlike narrow
, this is a type assertion that modifies the current lens instance.
Manual Type Assertion
Use the generic parameter to assert the lens is already the desired type:
function processString(lens: Lens<string>) {// Work with string lens}const maybeLens: Lens<string | undefined> = lens.focus("optional")// After your runtime checkif (value !== undefined) {maybeLens.assert<string>()processString(maybeLens) // Now TypeScript knows it's Lens<string>}
Discriminant-Based Assertion
Use the discriminant overload when you're in a conditional branch:
type Status =| { type: "loading" }| { type: "success"; data: string }| { type: "error"; message: string }const statusLens: Lens<Status> = lens.focus("status")// In a conditional branchif (selected.type === "success") {statusLens.assert("type", "success")// Within this block, statusLens is Lens<{ type: 'success'; data: string }>const dataLens = statusLens.focus("data") // Type-safe access}
assert
is a type-only operation that doesn't perform runtime validation. Ensure your assertions are backed by proper runtime checks to avoid type safety violations.
defined
The defined
method is a convenience function that narrows the lens type to exclude null
and undefined
values. This is equivalent to using narrow<NonNullable<T>>()
but provides a more expressive API.
const optionalLens: Lens<string | null | undefined> = lens.focus("optional")// Remove null and undefined from the typeconst definedLens = optionalLens.defined()// Now: Lens<string>// Use after validationif (value != null) {const safeLens = optionalLens.defined()// Work with guaranteed non-null value}
Common use cases:
// Form validationconst emailLens = lens.focus("email") // Lens<string | undefined>function validateEmail(email: string) {// validation logic}// After confirming value existsif (formState.isValid) {const validEmailLens = emailLens.defined()// Pass to functions expecting non-null valuesvalidateEmail(validEmailLens.interop().control.getValues())}
cast
The cast
method forcefully changes the lens type to a new type, regardless of compatibility with the original type. This is a powerful but potentially unsafe operation that should be used with extreme caution.
// Cast from unknown/any to specific typeconst unknownLens: Lens<unknown> = lens.focus("dynamicData")const stringLens = unknownLens.cast<string>()// Now: Lens<string>// Cast between incompatible types (dangerous!)const numberLens: Lens<number> = lens.focus("count")const stringLens = numberLens.cast<string>()// Type system now thinks it's Lens<string>, but runtime value is still number
Safe usage patterns:
// Working with external APIs returning 'any'function processApiData(data: any) {const apiLens = LensCore.create(data)// Cast after runtime validationif (typeof data.user === "object" && data.user !== null) {const userLens = apiLens.focus("user").cast<User>()return <UserProfile lens={userLens} />}}// Type narrowing when you have more informationinterface BaseConfig {type: string}interface DatabaseConfig extends BaseConfig {type: "database"connectionString: string}const configLens: Lens<BaseConfig> = lens.focus("config")// After checking the type at runtimeif (config.type === "database") {const dbConfigLens = configLens.cast<DatabaseConfig>()// Now can access database-specific properties}
cast
bypasses TypeScript's type system entirely. It can lead to runtime errors if the underlying data doesn't match the asserted type. Always validate data at runtime before using cast
, or prefer safer alternatives like narrow
when possible.
useFieldArray
Import the enhanced useFieldArray
from @hookform/lenses/rhf
for seamless array handling with lenses.
import { useFieldArray } from "@hookform/lenses/rhf"function DynamicForm({lens,}: {lens: Lens<{ items: { name: string; value: number }[] }>}) {const itemsLens = lens.focus("items")const { fields, append, remove, move } = useFieldArray(itemsLens.interop())return (<div><button onClick={() => append({ name: "", value: 0 })}>Add Item</button>{itemsLens.map(fields, (field, itemLens, index) => (<div key={field.id}><input{...itemLens.focus("name").interop((ctrl, name) => ctrl.register(name))}/><inputtype="number"{...itemLens.focus("value").interop((ctrl, name) =>ctrl.register(name, { valueAsNumber: true }))}/><button onClick={() => remove(index)}>Remove</button>{index > 0 && (<button onClick={() => move(index, index - 1)}>Move Up</button>)}</div>))}</div>)}
- The
control
parameter is required and must be from React Hook Form'suseForm
hook - Each lens is cached and reused for optimal performance - focusing on the same path multiple times returns the identical lens instance
- When using functions with methods like
reflect
, memoize the function to maintain caching benefits - Dependencies array is optional but useful for clearing lens cache based on external state changes
- All lens operations maintain full TypeScript type safety and inference
Examples
Basic Usage
import { useForm } from "react-hook-form"import { Lens, useLens } from "@hookform/lenses"import { useFieldArray } from "@hookform/lenses/rhf"function FormComponent() {const { handleSubmit, control } = useForm<{firstName: stringlastName: stringchildren: {name: stringsurname: string}[]}>({})const lens = useLens({ control })return (<form onSubmit={handleSubmit(console.log)}><PersonFormlens={lens.reflect(({ firstName, lastName }) => ({name: firstName,surname: lastName,}))}/><ChildForm lens={lens.focus("children")} /><input type="submit" /></form>)}function ChildForm({lens,}: {lens: Lens<{ name: string; surname: string }[]>}) {const { fields, append } = useFieldArray(lens.interop())return (<><button type="button" onClick={() => append({ name: "", surname: "" })}>Add child</button>{lens.map(fields, (value, l) => (<PersonForm key={value.id} lens={l} />))}</>)}// PersonForm is used twice with different sourcesfunction PersonForm({lens,}: {lens: Lens<{ name: string; surname: string }>}) {return (<div><StringInput lens={lens.focus("name")} /><StringInput lens={lens.focus("surname")} /></div>)}function StringInput({ lens }: { lens: Lens<string> }) {return <input {...lens.interop((ctrl, name) => ctrl.register(name))} />}
Motivation
Working with complex, deeply nested forms in React Hook Form can quickly become challenging. Traditional approaches often lead to common problems that make development more difficult and error-prone:
1. Type-Safe Name Props Are Nearly Impossible
Creating reusable form components requires accepting a name
prop to specify which field to control. However, making this type-safe in TypeScript is extremely challenging:
// ❌ Loses type safety - no way to ensure name matches the form schemainterface InputProps<T> {name: string // Could be any string, even invalid field pathscontrol: Control<T>}// ❌ Attempting proper typing leads to complex, unmaintainable genericsinterface InputProps<T, TName extends Path<T>> {name: TNamecontrol: Control<T>}// This becomes unwieldy and breaks down with nested objects
2. useFormContext()
Creates Tight Coupling
Using useFormContext()
in reusable components tightly couples them to specific form schemas, making them less portable and harder to share:
// ❌ Tightly coupled to parent form structurefunction AddressForm() {const { control } = useFormContext<UserForm>() // Locked to UserForm typereturn (<div><input {...control.register("address.street")} />{" "}{/* Fixed field paths */}<input {...control.register("address.city")} /></div>)}// Can't reuse this component with different form schemas
3. String-Based Field Paths Are Error-Prone
Building reusable components with string concatenation for field paths is fragile and difficult to maintain:
// ❌ String concatenation is error-prone and hard to refactorfunction PersonForm({ basePath }: { basePath: string }) {const { register } = useForm();return (<div>{/* No type safety, prone to typos */}<input {...register(`${basePath}.firstName`)} /><input {...register(`${basePath}.lastName`)} /><input {...register(`${basePath}.email`)} /></div>);}// Usage becomes unwieldy and error-prone<PersonForm basePath="user.profile.owner" /><PersonForm basePath="user.profile.emergency_contact" />
Performance Optimization
Built-in Caching System
Lenses are automatically cached to prevent unnecessary component re-renders when using React.memo
. This means that focusing on the same path multiple times will return the identical lens instance:
assert(lens.focus("firstName") === lens.focus("firstName"))
Function Memoization
When using functions with methods like reflect
, you need to be careful about function identity to maintain caching benefits:
// ❌ Creates a new function on every render, breaking the cachelens.reflect((l) => l.focus("firstName"))
To maintain caching, memoize the function you pass:
// ✅ Memoized function preserves the cachelens.reflect(useCallback((l) => l.focus("firstName"), []))
React Compiler can automatically optimize these functions for you! Since functions passed to reflect
have no side effects, React Compiler will automatically hoist them to module scope, ensuring lens caching works perfectly without manual memoization.
Advanced Usage
Manual Lens Creation
For advanced use cases or when you need more control, you can create lenses manually without the useLens
hook using the LensCore
class:
import { useMemo } from "react"import { useForm } from "react-hook-form"import { LensCore, LensesStorage } from "@hookform/lenses"function App() {const { control } = useForm<{ firstName: string; lastName: string }>()const lens = useMemo(() => {const cache = new LensesStorage(control)return LensCore.create(control, cache)}, [control])return (<div><input{...lens.focus("firstName").interop((ctrl, name) => ctrl.register(name))}/><input{...lens.focus("lastName").interop((ctrl, name) => ctrl.register(name))}/></div>)}
Extending lenses
You can extend the basic lens functionality by adding custom methods to the LensBase
interface. This is useful when you need additional methods that aren't available in the default lens API.
For example, let's add a getValue
method to the lens that allows you to easily retrieve the current form values.
Step 1: Create the type declarations file
Create a lenses.d.ts
file to extend the basic interface with the methods you want:
declare module "@hookform/lenses" {interface LensBase<T> {getValue(): T}}export {}
Step 2: Create the custom lens core implementation
Create a MyLensCore.ts
file with the actual runtime implementation:
import type { FieldValues } from "react-hook-form"import { LensCore } from "@hookform/lenses"export class MyLensCore<T extends FieldValues> extends LensCore<T> {public getValue() {return this.control._formValues}}
Step 3: Create the custom hook
Create a useMyLens.ts
file that accepts control and returns the lens as usual:
import { type DependencyList, useMemo } from "react"import type { FieldValues } from "react-hook-form"import { LensesStorage, type Lens, type UseLensProps } from "@hookform/lenses"import { MyLensCore } from "./MyLensCore"export function useMyLens<TFieldValues extends FieldValues = FieldValues>(props: UseLensProps<TFieldValues>,deps: DependencyList = []): Lens<TFieldValues> {return useMemo(() => {const cache = new LensesStorage(props.control)const lens = new MyLensCore<TFieldValues>(props.control,"",cache) as unknown as Lens<TFieldValues>return lens}, [props.control, ...deps])}
Step 4: Use your extended lens
Now you can use this hook as usual and you have the new method with correct TypeScript support:
const lens = useMyLens(form)lens.getValue() // Your custom method is now available with full type support
This pattern allows you to add any custom functionality to lenses while maintaining full type safety and compatibility with the existing lens API.
Found a bug or have a feature request? Check out the GitHub repository to report issues or contribute to the project.
Thank you for your support
If you find React Hook Form to be useful in your project, please consider to star and support it.