├── .bun-version ├── src ├── rhf │ ├── index.ts │ └── useFieldArray.ts ├── index.ts ├── types │ ├── index.ts │ ├── PrimitiveLens.ts │ ├── Lens.ts │ ├── ObjectLens.ts │ ├── Interop.ts │ ├── toolkit.ts │ └── ArrayLens.ts ├── useLens.ts ├── tsconfig.json ├── LensesStorage.ts └── LensCore.ts ├── vitest-unit.setup.ts ├── examples ├── components │ ├── index.ts │ ├── Checkbox.tsx │ ├── StringInput.tsx │ ├── NumberInput.tsx │ └── Select.tsx ├── hook-form │ ├── Register.story.tsx │ ├── UseController.story.tsx │ └── Unregister.story.tsx ├── Quickstart.story.tsx ├── typing │ ├── Optional.story.tsx │ ├── Union.story.tsx │ └── Toolkit.story.tsx ├── Demo.story.tsx ├── reflect │ ├── object │ │ ├── TopLevel.story.tsx │ │ ├── Nested.story.tsx │ │ └── Spread.story.tsx │ └── array │ │ └── TopLevel.story.tsx ├── map │ └── array │ │ ├── Iterate.story.tsx │ │ └── Append.story.tsx ├── focus │ └── array │ │ ├── ByString.story.tsx │ │ └── ByIndex.story.tsx └── Complex.story.tsx ├── .prettierrc ├── .storybook ├── preview.ts └── main.ts ├── .vscode └── settings.json ├── vite.config.ts ├── .gitignore ├── tsup.config.ts ├── vitest-e2e.setup.ts ├── tsconfig.json ├── tests ├── primitive-reflect.test.ts ├── primitive-interop.test.ts ├── object-focus.test.ts ├── array-interop.test.ts ├── array-reflect.test.ts ├── array-focus.test.ts ├── array-map.test.ts ├── lens.test.ts ├── cache.test.ts ├── object-interop.test.ts ├── lens-extension.test.ts ├── types.test-d.ts └── object-reflect.test.ts ├── .github ├── actions │ └── setup │ │ └── action.yml └── workflows │ ├── ci.yml │ └── release.yml ├── LICENSE ├── vitest.config.ts ├── eslint.config.ts ├── package.json └── README.md /.bun-version: -------------------------------------------------------------------------------- 1 | 1.2 2 | -------------------------------------------------------------------------------- /src/rhf/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useFieldArray'; 2 | -------------------------------------------------------------------------------- /vitest-unit.setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/vitest'; 2 | -------------------------------------------------------------------------------- /examples/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './NumberInput'; 2 | export * from './StringInput'; 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 140, 4 | "tabWidth": 2, 5 | "trailingComma": "all", 6 | "semi": true 7 | } 8 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from '@storybook/react'; 2 | 3 | const preview: Preview = {}; 4 | 5 | export default preview; 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "eslint.workingDirectories": [{ "mode": "auto" }] 4 | } 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './LensCore'; 2 | export * from './LensesStorage'; 3 | export type * from './types'; 4 | export * from './useLens'; 5 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export type * from './ArrayLens'; 2 | export type * from './Interop'; 3 | export type * from './Lens'; 4 | export type * from './ObjectLens'; 5 | export type * from './PrimitiveLens'; 6 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, type UserConfig } from 'vite'; 2 | import tsconfigPaths from 'vite-tsconfig-paths'; 3 | 4 | const config: UserConfig = defineConfig({ 5 | plugins: [tsconfigPaths()], 6 | }); 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # Build outputs 5 | dist 6 | storybook-static 7 | 8 | # Testing and coverage 9 | coverage 10 | **/*.vitest-temp.json 11 | 12 | # Logs and debug files 13 | logs 14 | *.log 15 | *storybook.log 16 | 17 | # Cache and temporary files 18 | .eslintcache 19 | temp 20 | .cache 21 | 22 | # System files 23 | .DS_Store 24 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | tsconfig: './src/tsconfig.json', 5 | entry: ['src/index.ts', 'src/rhf/index.ts'], 6 | format: ['esm', 'cjs'], 7 | dts: { 8 | resolve: true, 9 | entry: ['src/index.ts', 'src/rhf/index.ts'], 10 | }, 11 | clean: true, 12 | sourcemap: true, 13 | external: ['react', 'react-hook-form'], 14 | }); 15 | -------------------------------------------------------------------------------- /vitest-e2e.setup.ts: -------------------------------------------------------------------------------- 1 | import { setProjectAnnotations } from '@storybook/react'; 2 | import { beforeAll } from 'vitest'; 3 | 4 | import * as projectAnnotations from './.storybook/preview'; 5 | 6 | // This is an important step to apply the right configuration when testing your stories. 7 | // More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations 8 | const project = setProjectAnnotations([projectAnnotations]); 9 | 10 | beforeAll(project.beforeAll); 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./src/tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ESNext", 5 | "lib": ["ESNext", "DOM"], 6 | "module": "ESNext", 7 | "rootDir": ".", 8 | "baseUrl": ".", 9 | "moduleResolution": "bundler", 10 | "paths": { 11 | "@hookform/lenses": ["./src"], 12 | "@hookform/lenses/rhf": ["./src/rhf"] 13 | }, 14 | "types": ["vitest/globals"], 15 | "declaration": false, 16 | "declarationMap": false, 17 | "isolatedModules": false, 18 | "isolatedDeclarations": false 19 | }, 20 | "include": ["**/*.ts", "**/*.tsx"] 21 | } 22 | -------------------------------------------------------------------------------- /examples/components/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import { type RegisterOptions } from 'react-hook-form'; 2 | import { type HookFormControlShim, type Lens } from '@hookform/lenses'; 3 | 4 | export interface CheckboxProps 5 | extends Omit< 6 | RegisterOptions>, 7 | 'valueAsNumber' | 'valueAsDate' | 'setValueAs' | 'onBlur' | 'onChange' | 'value' 8 | > { 9 | label: string; 10 | lens: Lens; 11 | } 12 | 13 | export function Checkbox({ label, lens, ...rules }: CheckboxProps) { 14 | const { control, name } = lens.interop(); 15 | 16 | return ( 17 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /examples/components/StringInput.tsx: -------------------------------------------------------------------------------- 1 | import { type RegisterOptions } from 'react-hook-form'; 2 | import { type HookFormControlShim, type Lens } from '@hookform/lenses'; 3 | 4 | export interface StringInputProps 5 | extends Omit< 6 | RegisterOptions>, 7 | 'valueAsNumber' | 'valueAsDate' | 'setValueAs' | 'onBlur' | 'onChange' | 'value' 8 | > { 9 | label: string; 10 | lens: Lens; 11 | } 12 | 13 | export function StringInput({ label, lens, ...rules }: StringInputProps) { 14 | const { control, name } = lens.interop(); 15 | 16 | return ( 17 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /examples/components/NumberInput.tsx: -------------------------------------------------------------------------------- 1 | import { type RegisterOptions } from 'react-hook-form'; 2 | import { type HookFormControlShim, type Lens } from '@hookform/lenses'; 3 | 4 | export interface NumberInputProps 5 | extends Omit< 6 | RegisterOptions>, 7 | 'valueAsNumber' | 'valueAsDate' | 'setValueAs' | 'onBlur' | 'onChange' | 'value' | 'pattern' 8 | > { 9 | label: string; 10 | lens: Lens; 11 | } 12 | 13 | export function NumberInput({ label, lens, ...rules }: NumberInputProps) { 14 | const { control, name } = lens.interop(); 15 | 16 | return ( 17 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /tests/primitive-reflect.test.ts: -------------------------------------------------------------------------------- 1 | import { useForm } from 'react-hook-form'; 2 | import { type Lens, useLens } from '@hookform/lenses'; 3 | import { renderHook } from '@testing-library/react'; 4 | import { expectTypeOf } from 'vitest'; 5 | 6 | test('reflect can create a new lens from a field array item', () => { 7 | const { result } = renderHook(() => { 8 | const form = useForm<{ a: string }>(); 9 | const lens = useLens({ control: form.control }); 10 | return lens; 11 | }); 12 | 13 | const reflectedLens = result.current.focus('a').reflect((d, l) => { 14 | expectTypeOf(d).toEqualTypeOf(); 15 | expectTypeOf(l).toEqualTypeOf>(); 16 | 17 | return { b: l }; 18 | }); 19 | 20 | expectTypeOf(reflectedLens).toEqualTypeOf>(); 21 | }); 22 | -------------------------------------------------------------------------------- /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Setup Environment' 2 | description: 'Sets up Bun and installs dependencies' 3 | 4 | runs: 5 | using: 'composite' 6 | steps: 7 | - name: Setup Bun 8 | uses: oven-sh/setup-bun@v2 9 | with: 10 | bun-version-file: '.bun-version' 11 | 12 | - name: Get Bun cache directory 13 | id: bun-cache-dir 14 | shell: bash 15 | run: | 16 | echo "DIR=$(bun pm cache dir)" >> $GITHUB_OUTPUT 17 | 18 | - name: Setup Bun cache 19 | uses: actions/cache@v3 20 | with: 21 | path: ${{ steps.bun-cache-dir.outputs.DIR }} 22 | key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} 23 | restore-keys: | 24 | ${{ runner.os }}-bun- 25 | 26 | - name: Install dependencies 27 | run: bun install 28 | shell: bash 29 | -------------------------------------------------------------------------------- /tests/primitive-interop.test.ts: -------------------------------------------------------------------------------- 1 | import { type Control, useForm } from 'react-hook-form'; 2 | import { type HookFormControlShim, type ShimKeyName, useLens } from '@hookform/lenses'; 3 | import { renderHook } from '@testing-library/react'; 4 | import { expectTypeOf } from 'vitest'; 5 | 6 | test('interop returns name and control', () => { 7 | const { result } = renderHook(() => { 8 | const form = useForm<{ a: { b: string } }>(); 9 | const lens = useLens({ control: form.control }); 10 | return { lens, form }; 11 | }); 12 | 13 | const interop = result.current.lens.focus('a.b').interop(); 14 | 15 | expectTypeOf(interop).toEqualTypeOf<{ 16 | name: ShimKeyName; 17 | control: Control>; 18 | }>(); 19 | 20 | expect(interop.name).toBe('a.b'); 21 | expect(interop.control).toBe(result.current.form.control); 22 | }); 23 | -------------------------------------------------------------------------------- /tests/object-focus.test.ts: -------------------------------------------------------------------------------- 1 | import { useForm } from 'react-hook-form'; 2 | import { type Lens, useLens } from '@hookform/lenses'; 3 | import { renderHook } from '@testing-library/react'; 4 | import { expectTypeOf } from 'vitest'; 5 | 6 | test('lens can focus on a field', () => { 7 | const { result } = renderHook(() => { 8 | const form = useForm<{ a: string }>(); 9 | const lens = useLens({ control: form.control }); 10 | return lens; 11 | }); 12 | 13 | expectTypeOf(result.current.focus('a')).toEqualTypeOf>(); 14 | }); 15 | 16 | test('lens cannot focus on a field that does not exist', () => { 17 | const { result } = renderHook(() => { 18 | const form = useForm<{ a: string }>(); 19 | const lens = useLens({ control: form.control }); 20 | return lens; 21 | }); 22 | 23 | // @ts-expect-error property does not exist 24 | assertType(result.current.focus('b')); 25 | }); 26 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/react-vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import { mergeConfig } from 'vite'; 4 | import checker from 'vite-plugin-checker'; 5 | 6 | const config: StorybookConfig = { 7 | stories: ['../examples/**/*.story.@(ts|tsx)', '../examples/**/*.stories.@(ts|tsx)'], 8 | addons: ['@storybook/addon-essentials', '@storybook/experimental-addon-test'], 9 | framework: { 10 | name: '@storybook/react-vite', 11 | options: { 12 | builder: { viteConfigPath: './vite.config.ts' }, 13 | }, 14 | }, 15 | 16 | viteFinal(config) { 17 | return mergeConfig(config, { 18 | plugins: [ 19 | react({ 20 | babel: { 21 | plugins: ['babel-plugin-react-compiler'], 22 | }, 23 | }), 24 | checker({ typescript: true, overlay: false }), 25 | ], 26 | }); 27 | }, 28 | }; 29 | 30 | export default config; 31 | -------------------------------------------------------------------------------- /examples/components/Select.tsx: -------------------------------------------------------------------------------- 1 | import { type RegisterOptions } from 'react-hook-form'; 2 | import { type HookFormControlShim, type Lens } from '@hookform/lenses'; 3 | 4 | export interface SelectOption { 5 | value: T; 6 | label: string; 7 | } 8 | 9 | export interface SelectProps 10 | extends Omit>, 'valueAsNumber' | 'valueAsDate' | 'setValueAs' | 'onBlur' | 'onChange' | 'value'> { 11 | label: string; 12 | lens: Lens; 13 | options: SelectOption[]; 14 | } 15 | 16 | export function Select({ label, lens, options, ...rules }: SelectProps) { 17 | const { control, name } = lens.interop(); 18 | 19 | return ( 20 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/useLens.ts: -------------------------------------------------------------------------------- 1 | import { type DependencyList, useMemo } from 'react'; 2 | import type { Control, FieldValues } from 'react-hook-form'; 3 | 4 | import { LensCore } from './LensCore'; 5 | import { LensesStorage } from './LensesStorage'; 6 | import type { Lens } from './types'; 7 | 8 | export interface UseLensProps { 9 | control: Control; 10 | } 11 | 12 | /** 13 | * Creates lens from react-hook-form control. 14 | * 15 | * @example 16 | * ```tsx 17 | * function App() { 18 | * const { control } = useForm<{ 19 | * firstName: string; 20 | * }>(); 21 | * 22 | * const lens = useLens({ control }); 23 | * } 24 | * ``` 25 | */ 26 | export function useLens( 27 | props: UseLensProps, 28 | deps: DependencyList = [], 29 | ): Lens { 30 | return useMemo(() => { 31 | const cache = new LensesStorage(props.control); 32 | const lens = LensCore.create(props.control, cache); 33 | 34 | return lens; 35 | }, [props.control, ...deps]); 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 React Hook Form 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/types/PrimitiveLens.ts: -------------------------------------------------------------------------------- 1 | import type { LensInterop } from './Interop'; 2 | import type { Lens, LensesGetter, UnwrapLens } from './Lens'; 3 | 4 | export interface PrimitiveLensGetter { 5 | /** 6 | * A primitive lens restructures function. 7 | * 8 | * @param dictionary - Since primitive values has no key-value pairs, the dictionary is always `never`. 9 | * @param lens - Current primitive lens. 10 | */ 11 | (dictionary: never, lens: Lens): LensesGetter; 12 | } 13 | 14 | export interface PrimitiveLens extends LensInterop { 15 | /** 16 | * This method allows you to create a new lens with different shape. 17 | * 18 | * @param getter - A function that returns an object where each field is a lens. 19 | * 20 | * @example 21 | * ```tsx 22 | * function Component({ lens }: { lens: Lens }) { 23 | * return ({ data: l }))} />; 24 | * } 25 | * 26 | * function Inside({ lens }: { lens: Lens<{ data: string }> }) { 27 | * return ; 28 | * } 29 | * ``` 30 | */ 31 | reflect(getter: PrimitiveLensGetter): Lens>; 32 | } 33 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "lib": ["ES2018"], 5 | "jsx": "react-jsx", 6 | "module": "ES2015", 7 | "rootDir": ".", 8 | "baseUrl": ".", 9 | "moduleResolution": "node", 10 | "declaration": true, 11 | "declarationMap": true, 12 | "sourceMap": true, 13 | "noEmit": true, 14 | "isolatedModules": true, 15 | "verbatimModuleSyntax": true, 16 | "isolatedDeclarations": true, 17 | "erasableSyntaxOnly": true, 18 | "allowSyntheticDefaultImports": true, 19 | "esModuleInterop": true, 20 | "forceConsistentCasingInFileNames": true, 21 | "strict": true, 22 | "noImplicitAny": true, 23 | "strictNullChecks": true, 24 | "strictFunctionTypes": true, 25 | "strictBindCallApply": true, 26 | "strictPropertyInitialization": true, 27 | "noImplicitThis": true, 28 | "useUnknownInCatchVariables": true, 29 | "alwaysStrict": true, 30 | "noUnusedLocals": true, 31 | "noUnusedParameters": true, 32 | "exactOptionalPropertyTypes": true, 33 | "noImplicitReturns": true, 34 | "noFallthroughCasesInSwitch": true, 35 | "noUncheckedIndexedAccess": true, 36 | "noImplicitOverride": true, 37 | "noPropertyAccessFromIndexSignature": true, 38 | "allowUnusedLabels": true, 39 | "allowUnreachableCode": true, 40 | "skipLibCheck": true 41 | }, 42 | "include": ["**/*.ts", "**/*.tsx"] 43 | } 44 | -------------------------------------------------------------------------------- /tests/array-interop.test.ts: -------------------------------------------------------------------------------- 1 | import { useForm, useWatch } from 'react-hook-form'; 2 | import { type HookFormControlShim, type LensInteropTransformerBinding, useLens } from '@hookform/lenses'; 3 | import { renderHook } from '@testing-library/react'; 4 | import { expectTypeOf } from 'vitest'; 5 | 6 | test('interop returns name and control', () => { 7 | const { result } = renderHook(() => { 8 | const form = useForm<{ a: { b: string }[] }>(); 9 | const lens = useLens({ control: form.control }); 10 | return { lens, form }; 11 | }); 12 | 13 | const interop = result.current.lens.focus('a').interop(); 14 | 15 | expectTypeOf(interop).toEqualTypeOf< 16 | LensInteropTransformerBinding< 17 | HookFormControlShim< 18 | { 19 | b: string; 20 | }[] 21 | >, 22 | '__DO_NOT_USE_HOOK_FORM_CONTROL_SHIM__', 23 | 'id' 24 | > 25 | >(); 26 | 27 | expect(interop.name).toBe('a'); 28 | expect(interop.control).toBe(result.current.form.control); 29 | }); 30 | 31 | test('interop return can be used in watch', () => { 32 | const { result } = renderHook(() => { 33 | const form = useForm<{ a: { b: string }[] }>(); 34 | const lens = useLens({ control: form.control }); 35 | const interop = lens.focus('a').interop(); 36 | const value = useWatch(interop); 37 | return { lens, form, value }; 38 | }); 39 | 40 | expectTypeOf(result.current.value).toEqualTypeOf< 41 | { 42 | b: string; 43 | }[] 44 | >(); 45 | }); 46 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | lint: 11 | name: Lint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v4 16 | 17 | - name: Setup environment 18 | uses: ./.github/actions/setup 19 | 20 | - name: Lint 21 | run: bun lint 22 | 23 | - name: Prettier 24 | run: bun prettier 25 | 26 | typecheck: 27 | name: Typecheck 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Checkout repository 31 | uses: actions/checkout@v4 32 | 33 | - name: Setup environment 34 | uses: ./.github/actions/setup 35 | 36 | - name: Run typecheck 37 | run: bun typecheck 38 | 39 | test-unit: 40 | name: Test Unit 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: Checkout repository 44 | uses: actions/checkout@v4 45 | 46 | - name: Setup environment 47 | uses: ./.github/actions/setup 48 | 49 | - name: Run unit tests 50 | run: bun test:unit 51 | 52 | test-e2e: 53 | name: Test E2E 54 | runs-on: ubuntu-latest 55 | steps: 56 | - name: Checkout repository 57 | uses: actions/checkout@v4 58 | 59 | - name: Setup environment 60 | uses: ./.github/actions/setup 61 | 62 | - name: Install playwright 63 | run: bun playwright install 64 | 65 | - name: Run e2e tests 66 | run: bun test:e2e 67 | -------------------------------------------------------------------------------- /tests/array-reflect.test.ts: -------------------------------------------------------------------------------- 1 | import { useForm } from 'react-hook-form'; 2 | import { type Lens, useLens } from '@hookform/lenses'; 3 | import { useFieldArray } from '@hookform/lenses/rhf'; 4 | import { renderHook } from '@testing-library/react'; 5 | import { expectTypeOf } from 'vitest'; 6 | 7 | test('reflect can create a new lens from a field array item', () => { 8 | const { result } = renderHook(() => { 9 | const form = useForm<{ a: string[] }>(); 10 | const lens = useLens({ control: form.control }); 11 | return lens; 12 | }); 13 | 14 | expectTypeOf(result.current.reflect((l) => ({ b: l.a.focus('0') }))).toEqualTypeOf>(); 15 | }); 16 | 17 | test('reflect can work with array', () => { 18 | const { result } = renderHook(() => { 19 | const form = useForm<{ items: { value: string }[] }>({ defaultValues: { items: [{ value: 'one' }, { value: 'two' }] } }); 20 | 21 | const lens = useLens({ control: form.control }) 22 | .focus('items') 23 | .reflect((l) => [{ another: l.value }]); 24 | 25 | const arr = useFieldArray(lens.interop()); 26 | 27 | return { form, lens, arr }; 28 | }); 29 | 30 | expectTypeOf(result.current.lens).toEqualTypeOf>(); 31 | 32 | const [one, two] = result.current.lens.map(result.current.arr.fields, (_, l) => l.focus('another').interop()); 33 | 34 | expect(one).toEqual({ 35 | name: 'items.0.value', 36 | control: result.current.form.control, 37 | }); 38 | expect(two).toEqual({ 39 | name: 'items.1.value', 40 | control: result.current.form.control, 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { storybookTest } from '@storybook/experimental-addon-test/vitest-plugin'; 2 | import path from 'node:path'; 3 | import { fileURLToPath } from 'node:url'; 4 | import { defineConfig } from 'vitest/config'; 5 | 6 | const dirname = path.dirname(fileURLToPath(import.meta.url)); 7 | 8 | export default defineConfig({ 9 | test: { 10 | reporters: 'verbose', 11 | coverage: { 12 | allowExternal: true, 13 | provider: 'v8', 14 | reporter: ['text', 'html'], 15 | include: [path.resolve(dirname, 'src')], 16 | }, 17 | workspace: [ 18 | { 19 | extends: 'vite.config.ts', 20 | test: { 21 | name: 'unit', 22 | typecheck: { enabled: true, tsconfig: path.resolve(dirname, 'src/tsconfig.json') }, 23 | globals: true, 24 | environment: 'jsdom', 25 | setupFiles: ['./vitest-unit.setup.ts'], 26 | include: ['tests/**/*.test.+(ts|tsx)'], 27 | clearMocks: true, 28 | restoreMocks: true, 29 | }, 30 | }, 31 | { 32 | extends: 'vite.config.ts', 33 | plugins: [ 34 | // The plugin will run tests for the stories defined in your Storybook config 35 | // See options at: https://storybook.js.org/docs/writing-tests/test-addon#storybooktest 36 | storybookTest({ configDir: path.join(dirname, '.storybook') }), 37 | ], 38 | test: { 39 | name: 'e2e', 40 | setupFiles: ['./vitest-e2e.setup.ts'], 41 | clearMocks: true, 42 | restoreMocks: true, 43 | browser: { 44 | enabled: true, 45 | headless: true, 46 | provider: 'playwright', 47 | instances: [{ browser: 'chromium' }], 48 | }, 49 | }, 50 | }, 51 | ], 52 | }, 53 | }); 54 | -------------------------------------------------------------------------------- /examples/hook-form/Register.story.tsx: -------------------------------------------------------------------------------- 1 | import { type SubmitHandler, useForm } from 'react-hook-form'; 2 | import { useLens } from '@hookform/lenses'; 3 | import { action } from '@storybook/addon-actions'; 4 | import type { Meta, StoryObj } from '@storybook/react'; 5 | import { expect, fn, userEvent, within } from '@storybook/test'; 6 | 7 | import { NumberInput, StringInput } from '../components'; 8 | 9 | const meta = { 10 | title: 'Hook-Form', 11 | component: Playground, 12 | } satisfies Meta; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | interface PlaygroundData { 19 | username: string; 20 | age: number; 21 | } 22 | 23 | interface PlaygroundProps { 24 | onSubmit: SubmitHandler; 25 | } 26 | 27 | function Playground({ onSubmit = action('submit') }: PlaygroundProps) { 28 | const { handleSubmit, control } = useForm({}); 29 | const lens = useLens({ control }); 30 | 31 | return ( 32 |
33 | 34 | 35 |
36 | 37 |
38 | 39 | ); 40 | } 41 | 42 | export const Register: Story = { 43 | args: { 44 | onSubmit: fn(), 45 | }, 46 | play: async ({ canvasElement, args }) => { 47 | const canvas = within(canvasElement); 48 | 49 | await userEvent.type(canvas.getByPlaceholderText(/username/i), 'joe'); 50 | await userEvent.type(canvas.getByPlaceholderText(/age/i), '20'); 51 | 52 | await userEvent.click(canvas.getByRole('button', { name: /submit/i })); 53 | 54 | expect(args.onSubmit).toHaveBeenCalledWith( 55 | { 56 | username: 'joe', 57 | age: 20, 58 | }, 59 | expect.anything(), 60 | ); 61 | }, 62 | }; 63 | -------------------------------------------------------------------------------- /examples/Quickstart.story.tsx: -------------------------------------------------------------------------------- 1 | import { useForm } from 'react-hook-form'; 2 | import { type Lens, useLens } from '@hookform/lenses'; 3 | import { useFieldArray } from '@hookform/lenses/rhf'; 4 | import { action } from '@storybook/addon-actions'; 5 | import { type Meta } from '@storybook/react'; 6 | 7 | export default { 8 | title: 'Quickstart', 9 | } satisfies Meta; 10 | 11 | export function Quickstart() { 12 | const { handleSubmit, control } = useForm<{ 13 | firstName: string; 14 | lastName: string; 15 | children: { 16 | name: string; 17 | surname: string; 18 | }[]; 19 | }>({}); 20 | 21 | const lens = useLens({ control }); 22 | 23 | return ( 24 |
25 | ({ 27 | name: firstName, 28 | surname: lastName, 29 | }))} 30 | /> 31 | 32 | 33 | 34 | ); 35 | } 36 | 37 | function ChildForm({ lens }: { lens: Lens<{ name: string; surname: string }[]> }) { 38 | const { fields, append } = useFieldArray(lens.interop()); 39 | 40 | return ( 41 | <> 42 | 45 | {lens.map(fields, (value, l) => ( 46 | 47 | ))} 48 | 49 | ); 50 | } 51 | 52 | function PersonForm({ lens }: { lens: Lens<{ name: string; surname: string }> }) { 53 | return ( 54 |
55 | 56 | 57 |
58 | ); 59 | } 60 | 61 | function StringInput({ lens }: { lens: Lens }) { 62 | return ctrl.register(name))} />; 63 | } 64 | -------------------------------------------------------------------------------- /examples/typing/Optional.story.tsx: -------------------------------------------------------------------------------- 1 | import { type SubmitHandler, useForm, useWatch } from 'react-hook-form'; 2 | import { type Lens, useLens } from '@hookform/lenses'; 3 | import { action } from '@storybook/addon-actions'; 4 | import type { Meta, StoryObj } from '@storybook/react'; 5 | import { fn } from '@storybook/test'; 6 | 7 | import { StringInput } from '../components/StringInput'; 8 | 9 | const meta = { 10 | title: 'Typing', 11 | component: Playground, 12 | } satisfies Meta; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | interface FormValues { 19 | name?: string; 20 | surname?: string; 21 | } 22 | 23 | interface PlaygroundProps { 24 | onSubmit: SubmitHandler; 25 | } 26 | 27 | function Playground({ onSubmit = action('submit') }: PlaygroundProps) { 28 | const { handleSubmit, control } = useForm(); 29 | const lens = useLens({ control }); 30 | 31 | return ( 32 |
33 |
34 | ()} 39 | /> 40 | 41 | 42 |
43 | ); 44 | } 45 | 46 | export const Optional: Story = { 47 | args: { 48 | onSubmit: fn(), 49 | }, 50 | }; 51 | 52 | function UserProfile({ 53 | lens, 54 | }: { 55 | lens: Lens<{ 56 | name: string; 57 | surname: string; 58 | }>; 59 | }) { 60 | return ( 61 |
62 | 63 | 64 |
65 | ); 66 | } 67 | 68 | function UserName({ lens }: { lens: Lens }) { 69 | const value = useWatch(lens.interop()); 70 | 71 | if (value === undefined || value === null) { 72 | return null; 73 | } 74 | 75 | return ; 76 | } 77 | -------------------------------------------------------------------------------- /tests/array-focus.test.ts: -------------------------------------------------------------------------------- 1 | import { useForm } from 'react-hook-form'; 2 | import { type Lens, useLens } from '@hookform/lenses'; 3 | import { renderHook } from '@testing-library/react'; 4 | import { expectTypeOf } from 'vitest'; 5 | 6 | test('lens can focus on a field array', () => { 7 | const { result } = renderHook(() => { 8 | const form = useForm<{ a: string[] }>(); 9 | const lens = useLens({ control: form.control }); 10 | return lens; 11 | }); 12 | 13 | expectTypeOf(result.current.focus('a')).toEqualTypeOf>(); 14 | }); 15 | 16 | test('lens cannot focus on a field array that does not exist', () => { 17 | const { result } = renderHook(() => { 18 | const form = useForm<{ a: string[] }>(); 19 | const lens = useLens({ control: form.control }); 20 | return lens; 21 | }); 22 | 23 | // @ts-expect-error property does not exist 24 | assertType(result.current.focus('b')); 25 | }); 26 | 27 | test('lens can focus on a field array item', () => { 28 | const { result } = renderHook(() => { 29 | const form = useForm<{ a: string[] }>(); 30 | const lens = useLens({ control: form.control }); 31 | return lens; 32 | }); 33 | 34 | expectTypeOf(result.current.focus('a').focus('0')).toEqualTypeOf>(); 35 | assertType(result.current.focus('a').focus(0)); 36 | }); 37 | 38 | test('lens can focus on a field array item', () => { 39 | const { result } = renderHook(() => { 40 | const form = useForm<{ a: string[] }>(); 41 | const lens = useLens({ control: form.control }); 42 | return lens; 43 | }); 44 | 45 | expectTypeOf(result.current.focus('a').focus('0')).toEqualTypeOf>(); 46 | }); 47 | 48 | test('lens can focus on a field array item by index', () => { 49 | const { result } = renderHook(() => { 50 | const form = useForm<{ a: string[] }>(); 51 | const lens = useLens({ control: form.control }); 52 | return lens; 53 | }); 54 | 55 | expectTypeOf(result.current.focus('a').focus(0)).toEqualTypeOf>(); 56 | }); 57 | -------------------------------------------------------------------------------- /tests/array-map.test.ts: -------------------------------------------------------------------------------- 1 | import { useForm } from 'react-hook-form'; 2 | import { type Lens, useLens } from '@hookform/lenses'; 3 | import { renderHook } from '@testing-library/react'; 4 | import { expectTypeOf } from 'vitest'; 5 | 6 | test('map can create a new lens', () => { 7 | const { result } = renderHook(() => { 8 | const form = useForm<{ items: { a: string }[] }>(); 9 | const lens = useLens({ control: form.control }); 10 | return { lens, form }; 11 | }); 12 | 13 | expectTypeOf(result.current.lens).toEqualTypeOf>(); 14 | 15 | const itemLenses = result.current.lens.focus('items').map([{ a: '1' }, { a: '2' }], (_, item) => item.focus('a')); 16 | 17 | expectTypeOf(itemLenses).toEqualTypeOf[]>(); 18 | 19 | expect(itemLenses[0]?.interop()).toEqual({ name: 'items.0.a', control: result.current.form.control }); 20 | expect(itemLenses[1]?.interop()).toEqual({ name: 'items.1.a', control: result.current.form.control }); 21 | }); 22 | 23 | test('map callback accepts a value and index', () => { 24 | const { result } = renderHook(() => { 25 | const form = useForm<{ items: { a: string; myId: string }[] }>(); 26 | const lens = useLens({ control: form.control }); 27 | return { lens, form }; 28 | }); 29 | 30 | expectTypeOf(result.current.lens).toEqualTypeOf>(); 31 | 32 | const items = result.current.lens.focus('items'); 33 | const itemLenses = items.map( 34 | [ 35 | { a: '1', myId: 'one' }, 36 | { a: '2', myId: 'two' }, 37 | ], 38 | (value, l, index) => ({ lens: l, interop: l.interop(), id: value.myId, index }), 39 | ); 40 | 41 | expect(itemLenses[0]).toMatchObject({ 42 | interop: { name: 'items.0', control: result.current.form.control }, 43 | id: 'one', 44 | index: 0, 45 | }); 46 | 47 | expect(itemLenses[1]).toMatchObject({ 48 | interop: { name: 'items.1', control: result.current.form.control }, 49 | id: 'two', 50 | index: 1, 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /tests/lens.test.ts: -------------------------------------------------------------------------------- 1 | import { useForm, useWatch } from 'react-hook-form'; 2 | import { type Lens, useLens } from '@hookform/lenses'; 3 | import { renderHook } from '@testing-library/react'; 4 | import { expectTypeOf } from 'vitest'; 5 | 6 | test('lens can be created', () => { 7 | const { result } = renderHook(() => { 8 | const form = useForm<{ a: string }>(); 9 | const lens = useLens({ control: form.control }); 10 | return { lens, form }; 11 | }); 12 | 13 | expectTypeOf(result.current.lens).toEqualTypeOf>(); 14 | expect(result.current.lens.interop()).toEqual({ name: undefined, control: result.current.form.control }); 15 | }); 16 | 17 | test('lenses are different when created with different forms', () => { 18 | const { result } = renderHook(() => { 19 | const form = useForm<{ a: string }>(); 20 | const lens1 = useLens({ control: form.control }); 21 | const lens2 = useLens({ control: form.control }); 22 | return { lens1, lens2, form }; 23 | }); 24 | 25 | expect(result.current.lens1).not.toBe(result.current.lens2); 26 | expect(result.current.lens1.interop()).toEqual(result.current.lens2.interop()); 27 | expect(result.current.form.control).toBe(result.current.lens1.interop().control); 28 | expect(result.current.form.control).toBe(result.current.lens2.interop().control); 29 | }); 30 | 31 | test('root lens should be watchable when name is an empty string in LensCore', () => { 32 | const { result } = renderHook(() => { 33 | const form = useForm({ 34 | defaultValues: { 35 | a: 3, 36 | b: 'hello', 37 | }, 38 | }); 39 | 40 | const lens = useLens({ control: form.control }); 41 | const interop = lens.interop(); 42 | const value = useWatch(interop); 43 | 44 | return { form, interop, value }; 45 | }); 46 | 47 | expect(result.current.interop).toEqual({ name: undefined, control: result.current.form.control }); 48 | expect(result.current.value).toEqual({ a: 3, b: 'hello' }); 49 | expect(result.current.value.a).toEqual(3); 50 | expect(result.current.value.b).toEqual('hello'); 51 | }); 52 | -------------------------------------------------------------------------------- /tests/cache.test.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { useForm } from 'react-hook-form'; 3 | import { useLens } from '@hookform/lenses'; 4 | import { renderHook } from '@testing-library/react'; 5 | 6 | test('lens focus is equal to itself', () => { 7 | const { result } = renderHook(() => { 8 | const form = useForm<{ a: string }>(); 9 | const lens = useLens({ control: form.control }); 10 | return { lens, form }; 11 | }); 12 | 13 | expect(result.current.lens.focus('a')).toBe(result.current.lens.focus('a')); 14 | }); 15 | 16 | test('lens can cache by function with useCallback', () => { 17 | const { result, rerender } = renderHook(() => { 18 | const form = useForm<{ a: string }>(); 19 | const lens = useLens({ control: form.control }); 20 | 21 | const reflectedLens = lens.reflect(useCallback((l) => ({ b: l.a }), [lens])); 22 | return { reflectedLens }; 23 | }); 24 | 25 | const a = result.current.reflectedLens; 26 | rerender(); 27 | const b = result.current.reflectedLens; 28 | 29 | expect(a).toBe(b); 30 | }); 31 | 32 | test('lens cannot be cache by function without useCallback', () => { 33 | const { result, rerender } = renderHook(() => { 34 | const form = useForm<{ a: string }>(); 35 | const lens = useLens({ control: form.control }); 36 | 37 | const reflectedLens = lens.reflect((l) => ({ b: l.a })); 38 | return { reflectedLens }; 39 | }); 40 | 41 | const a = result.current.reflectedLens; 42 | rerender(); 43 | const b = result.current.reflectedLens; 44 | 45 | expect(a).not.toBe(b); 46 | }); 47 | 48 | test('interop return non cached name when override', () => { 49 | const { result } = renderHook(() => { 50 | const form = useForm<{ a: string; b: number }>(); 51 | const lens = useLens({ control: form.control }); 52 | 53 | return { lens }; 54 | }); 55 | 56 | const aLens = result.current.lens.focus('a'); 57 | const bLens = result.current.lens.focus('b'); 58 | 59 | expect(aLens.interop().name).toEqual('a'); 60 | expect(bLens.interop().name).toEqual('b'); 61 | 62 | const reflect = result.current.lens.reflect((l) => ({ a: l.b })); 63 | 64 | expect(reflect.focus('a').interop().name).toEqual('b'); 65 | }); 66 | -------------------------------------------------------------------------------- /src/LensesStorage.ts: -------------------------------------------------------------------------------- 1 | import type { Control, FieldValues } from 'react-hook-form'; 2 | 3 | import type { LensCore } from './LensCore'; 4 | 5 | export type LensesStorageComplexKey = (...args: any[]) => any; 6 | 7 | export interface LensesStorageValue { 8 | plain?: LensCore; 9 | complex: WeakMap>; 10 | } 11 | 12 | export type LensCache = Map>; 13 | 14 | /** 15 | * Cache storage for lenses. 16 | */ 17 | export class LensesStorage { 18 | protected cache: LensCache; 19 | 20 | constructor(control: Control) { 21 | this.cache = new Map(); 22 | control._subscribe({ 23 | formState: { 24 | values: true, 25 | }, 26 | exact: true, 27 | callback: () => { 28 | control._names.unMount.forEach((name) => { 29 | this.delete(name); 30 | }); 31 | }, 32 | }); 33 | } 34 | 35 | public get(path: string, complexKey?: LensesStorageComplexKey): LensCore | undefined { 36 | const cached = this.cache.get(path); 37 | 38 | if (cached) { 39 | if (complexKey) { 40 | return cached.complex.get(complexKey); 41 | } 42 | 43 | return cached.plain; 44 | } 45 | 46 | return undefined; 47 | } 48 | 49 | public set(lens: LensCore, path: string, complexKey?: LensesStorageComplexKey): void { 50 | let cached = this.cache.get(path); 51 | 52 | if (!cached) { 53 | cached = { 54 | complex: new WeakMap(), 55 | }; 56 | 57 | this.cache.set(path, cached); 58 | } 59 | 60 | if (complexKey) { 61 | cached.complex.set(complexKey, lens); 62 | } else { 63 | cached.plain = lens; 64 | } 65 | } 66 | 67 | public has(path: string, complexKey?: LensesStorageComplexKey): boolean { 68 | if (complexKey) { 69 | return this.cache.get(path)?.complex.has(complexKey) ?? false; 70 | } 71 | 72 | return this.cache.has(path); 73 | } 74 | 75 | public delete(path: string): void { 76 | for (const key of this.cache.keys()) { 77 | if (key.startsWith(path)) { 78 | this.cache.delete(key); 79 | } 80 | } 81 | } 82 | 83 | public clear(): void { 84 | this.cache.clear(); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /examples/Demo.story.tsx: -------------------------------------------------------------------------------- 1 | import { type SubmitHandler, useForm } from 'react-hook-form'; 2 | import { type Lens, useLens } from '@hookform/lenses'; 3 | import { useFieldArray } from '@hookform/lenses/rhf'; 4 | import { action } from '@storybook/addon-actions'; 5 | import { type Meta } from '@storybook/react'; 6 | 7 | export default { 8 | title: 'Demo', 9 | } satisfies Meta; 10 | 11 | export interface Person { 12 | name: string; 13 | surname: string; 14 | } 15 | 16 | export interface DemoData { 17 | firstName: string; 18 | lastName: string; 19 | age: number; 20 | children: Person[]; 21 | } 22 | 23 | export interface DemoProps { 24 | onSubmit: SubmitHandler; 25 | } 26 | 27 | export function Demo({ onSubmit = action('submit') }: DemoProps) { 28 | const { handleSubmit, control } = useForm({}); 29 | const lens = useLens({ control }); 30 | 31 | return ( 32 |
33 | ({ 35 | name: firstName, 36 | surname: lastName, 37 | }))} 38 | /> 39 | 40 | 41 | 42 |
43 | 44 |
45 | 46 | ); 47 | } 48 | 49 | function ChildForm({ lens }: { lens: Lens }) { 50 | const { fields, append } = useFieldArray(lens.interop()); 51 | 52 | return ( 53 |
54 | {lens.map(fields, (value, l) => ( 55 | 56 | ))} 57 | 60 |
61 | ); 62 | } 63 | 64 | function PersonForm({ lens }: { lens: Lens }) { 65 | return ( 66 | <> 67 | 68 | 69 | 70 | ); 71 | } 72 | 73 | function StringInput({ lens }: { lens: Lens }) { 74 | return ctrl.register(name))} />; 75 | } 76 | 77 | function NumberInput({ lens }: { lens: Lens }) { 78 | return ctrl.register(name))} />; 79 | } 80 | -------------------------------------------------------------------------------- /examples/reflect/object/TopLevel.story.tsx: -------------------------------------------------------------------------------- 1 | import { type SubmitHandler, useForm } from 'react-hook-form'; 2 | import { type Lens, useLens } from '@hookform/lenses'; 3 | import { action } from '@storybook/addon-actions'; 4 | import type { Meta, StoryObj } from '@storybook/react'; 5 | import { expect, fn, userEvent, within } from '@storybook/test'; 6 | 7 | import { StringInput } from '../../components'; 8 | 9 | const meta = { 10 | title: 'Reflect/Object', 11 | component: Playground, 12 | } satisfies Meta; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | interface PlaygroundData { 19 | firstName: string; 20 | lastName: { value: string }; 21 | } 22 | 23 | interface PlaygroundProps { 24 | onSubmit: SubmitHandler; 25 | } 26 | 27 | function Playground({ onSubmit = action('submit') }: PlaygroundProps) { 28 | const { handleSubmit, control } = useForm(); 29 | const lens = useLens({ control }); 30 | 31 | return ( 32 |
33 | ({ 35 | name: firstName, 36 | surname: lastName.focus('value'), 37 | }))} 38 | /> 39 |
40 | 41 |
42 | 43 | ); 44 | } 45 | 46 | interface PersonFormData { 47 | name: string; 48 | surname: string; 49 | } 50 | 51 | function PersonForm({ lens }: { lens: Lens }) { 52 | return ( 53 |
54 | 55 | 56 |
57 | ); 58 | } 59 | 60 | export const TopLevel: Story = { 61 | args: { 62 | onSubmit: fn(), 63 | }, 64 | play: async ({ canvasElement, args }) => { 65 | const canvas = within(canvasElement); 66 | 67 | await userEvent.type(canvas.getByPlaceholderText(/firstName/i), 'joe'); 68 | await userEvent.type(canvas.getByPlaceholderText(/lastName/i), 'doe'); 69 | 70 | await userEvent.click(canvas.getByRole('button', { name: /submit/i })); 71 | 72 | expect(args.onSubmit).toHaveBeenCalledWith( 73 | { 74 | firstName: 'joe', 75 | lastName: { value: 'doe' }, 76 | }, 77 | expect.anything(), 78 | ); 79 | }, 80 | }; 81 | -------------------------------------------------------------------------------- /examples/reflect/object/Nested.story.tsx: -------------------------------------------------------------------------------- 1 | import { type SubmitHandler, useForm } from 'react-hook-form'; 2 | import { type Lens, useLens } from '@hookform/lenses'; 3 | import { action } from '@storybook/addon-actions'; 4 | import type { Meta, StoryObj } from '@storybook/react'; 5 | import { expect, fn, userEvent, within } from '@storybook/test'; 6 | 7 | import { StringInput } from '../../components'; 8 | 9 | const meta = { 10 | title: 'Reflect/Object', 11 | component: Playground, 12 | } satisfies Meta; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | interface PlaygroundData { 19 | firstName: string; 20 | lastName: string; 21 | } 22 | 23 | interface PlaygroundProps { 24 | onSubmit: SubmitHandler; 25 | } 26 | 27 | function Playground({ onSubmit = action('submit') }: PlaygroundProps) { 28 | const { handleSubmit, control } = useForm(); 29 | const lens = useLens({ control }); 30 | 31 | return ( 32 |
33 | ({ 35 | name: firstName.reflect((_, l) => ({ nestedName: l })).focus('nestedName'), 36 | surname: lens.focus('lastName'), 37 | }))} 38 | /> 39 |
40 | 41 |
42 | 43 | ); 44 | } 45 | 46 | interface PersonFormData { 47 | name: string; 48 | surname: string; 49 | } 50 | 51 | function PersonForm({ lens }: { lens: Lens }) { 52 | return ( 53 |
54 | 55 | 56 |
57 | ); 58 | } 59 | 60 | export const Nested: Story = { 61 | args: { 62 | onSubmit: fn(), 63 | }, 64 | play: async ({ canvasElement, args }) => { 65 | const canvas = within(canvasElement); 66 | 67 | await userEvent.type(canvas.getByPlaceholderText(/firstName/i), 'joe'); 68 | await userEvent.type(canvas.getByPlaceholderText(/lastName/i), 'doe'); 69 | 70 | await userEvent.click(canvas.getByRole('button', { name: /submit/i })); 71 | 72 | expect(args.onSubmit).toHaveBeenCalledWith( 73 | { 74 | firstName: 'joe', 75 | lastName: 'doe', 76 | }, 77 | expect.anything(), 78 | ); 79 | }, 80 | }; 81 | -------------------------------------------------------------------------------- /src/types/Lens.ts: -------------------------------------------------------------------------------- 1 | import type { BrowserNativeObject, FieldValues } from 'react-hook-form'; 2 | 3 | import type { ArrayLens } from './ArrayLens'; 4 | import type { HookFormControlShim } from './Interop'; 5 | import type { ObjectLens } from './ObjectLens'; 6 | import type { PrimitiveLens } from './PrimitiveLens'; 7 | import type { Toolkit } from './toolkit'; 8 | 9 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 10 | export interface LensBase extends Toolkit {} 11 | 12 | /** 13 | * This is a type that allows you to hold the type of a form element. 14 | * 15 | * ```ts 16 | * type LensWithArray = Lens; 17 | * type LensWithObject = Lens<{ name: string; age: number }>; 18 | * type LensWithPrimitive = Lens; 19 | * ``` 20 | * 21 | * In runtime it has `control` and `name` to use latter in react-hook-form. 22 | * Each time you do `lens.focus('propPath')` it creates a lens that keeps nesting of paths. 23 | */ 24 | export type Lens = LensBase & LensSelector; 25 | 26 | /** 27 | * Why not use `T extends any[] ...`, instead of `[T] extends [any[]]`?: 28 | * Because of @see {@link https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html#distributive-conditional-types} 29 | * 30 | * This allows using discriminated unions for T. 31 | * For an example of what problem this solves: 32 | * @see {@link https://github.com/react-hook-form/lenses/issues/16} 33 | */ 34 | export type LensSelector = [T] extends [any[]] 35 | ? ArrayLens 36 | : [T] extends [FieldValues] 37 | ? ObjectLens 38 | : [T] extends [boolean | true | false] 39 | ? PrimitiveLens 40 | : [T] extends [null | undefined] 41 | ? never 42 | : PrimitiveLens; 43 | 44 | export type LensesDictionary = { 45 | [P in keyof T]: Lens; 46 | }; 47 | 48 | export type RecursiveLensesDictionary = { 49 | [P in keyof T]: Lens | RecursiveLensesDictionary; 50 | }; 51 | 52 | export type LensesGetter = RecursiveLensesDictionary | Lens; 53 | 54 | export type UnwrapLens = T extends BrowserNativeObject 55 | ? T 56 | : T extends HookFormControlShim 57 | ? unknown 58 | : T extends (infer U)[] 59 | ? UnwrapLens[] 60 | : T extends Lens 61 | ? UnwrapLens 62 | : T extends object 63 | ? { [P in keyof T]: UnwrapLens } 64 | : T; 65 | -------------------------------------------------------------------------------- /examples/reflect/object/Spread.story.tsx: -------------------------------------------------------------------------------- 1 | import { type SubmitHandler, useForm } from 'react-hook-form'; 2 | import { type Lens, useLens } from '@hookform/lenses'; 3 | import { action } from '@storybook/addon-actions'; 4 | import type { Meta, StoryObj } from '@storybook/react'; 5 | import { expect, fn, userEvent, within } from '@storybook/test'; 6 | 7 | import { NumberInput, StringInput } from '../../components'; 8 | 9 | const meta = { 10 | title: 'Reflect/Object', 11 | component: Playground, 12 | } satisfies Meta; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | interface PlaygroundData { 19 | firstName: string; 20 | lastName: string; 21 | age: number; 22 | } 23 | 24 | interface PlaygroundProps { 25 | onSubmit: SubmitHandler; 26 | } 27 | function Playground({ onSubmit = action('submit') }: PlaygroundProps) { 28 | const { handleSubmit, control } = useForm(); 29 | 30 | const lens = useLens({ control }); 31 | 32 | return ( 33 |
34 | ({ 36 | ...rest, 37 | name: firstName, 38 | surname: lastName, 39 | }))} 40 | /> 41 |
42 | 43 |
44 | 45 | ); 46 | } 47 | 48 | function PersonForm({ lens }: { lens: Lens<{ name: string; surname: string; age: number }> }) { 49 | return ( 50 |
51 | 52 | 53 | 54 |
55 | ); 56 | } 57 | 58 | export const Spread: Story = { 59 | args: { 60 | onSubmit: fn(), 61 | }, 62 | play: async ({ canvasElement, args }) => { 63 | const canvas = within(canvasElement); 64 | 65 | await userEvent.type(canvas.getByPlaceholderText(/firstName/i), 'joe'); 66 | await userEvent.type(canvas.getByPlaceholderText(/lastName/i), 'doe'); 67 | await userEvent.type(canvas.getByPlaceholderText(/age/i), '20'); 68 | await userEvent.click(canvas.getByRole('button', { name: /submit/i })); 69 | 70 | expect(args.onSubmit).toHaveBeenCalledWith( 71 | { 72 | firstName: 'joe', 73 | lastName: 'doe', 74 | age: 20, 75 | }, 76 | expect.anything(), 77 | ); 78 | }, 79 | }; 80 | -------------------------------------------------------------------------------- /examples/map/array/Iterate.story.tsx: -------------------------------------------------------------------------------- 1 | import { type SubmitHandler, useForm } from 'react-hook-form'; 2 | import { type Lens, useLens } from '@hookform/lenses'; 3 | import { useFieldArray } from '@hookform/lenses/rhf'; 4 | import { action } from '@storybook/addon-actions'; 5 | import type { Meta, StoryObj } from '@storybook/react'; 6 | import { expect, fn, userEvent, within } from '@storybook/test'; 7 | 8 | import { StringInput } from '../../components'; 9 | 10 | const meta = { 11 | title: 'Map/Array', 12 | component: Playground, 13 | } satisfies Meta; 14 | 15 | type Story = StoryObj; 16 | 17 | export default meta; 18 | 19 | interface Item { 20 | data: string; 21 | } 22 | 23 | interface PlaygroundData { 24 | items: Item[]; 25 | } 26 | 27 | interface PlaygroundProps { 28 | onSubmit: SubmitHandler; 29 | } 30 | 31 | function Playground({ onSubmit = action('submit') }: PlaygroundProps) { 32 | const { handleSubmit, control } = useForm({ 33 | defaultValues: { 34 | items: [{ data: 'one' }, { data: 'two' }, { data: 'three' }], 35 | }, 36 | }); 37 | const lens = useLens({ control }); 38 | 39 | return ( 40 |
41 | 42 |
43 | 44 |
45 | 46 | ); 47 | } 48 | 49 | function Items({ lens }: { lens: Lens }) { 50 | const { fields } = useFieldArray(lens.interop()); 51 | 52 | return ( 53 |
54 | {lens.map(fields, (value, l) => ( 55 |
56 | 57 |
58 | ))} 59 |
60 | ); 61 | } 62 | 63 | export const Iterate: Story = { 64 | args: { 65 | onSubmit: fn(), 66 | }, 67 | play: async ({ canvasElement, args }) => { 68 | const canvas = within(canvasElement); 69 | 70 | expect(canvas.getByPlaceholderText(/items\.0\.data/i)).toBeInTheDocument(); 71 | expect(canvas.getByPlaceholderText(/items\.1\.data/i)).toBeInTheDocument(); 72 | expect(canvas.getByPlaceholderText(/items\.2\.data/i)).toBeInTheDocument(); 73 | 74 | await userEvent.click(canvas.getByRole('button', { name: /submit/i })); 75 | 76 | expect(args.onSubmit).toHaveBeenCalledWith( 77 | { 78 | items: [{ data: 'one' }, { data: 'two' }, { data: 'three' }], 79 | }, 80 | expect.anything(), 81 | ); 82 | }, 83 | }; 84 | -------------------------------------------------------------------------------- /examples/focus/array/ByString.story.tsx: -------------------------------------------------------------------------------- 1 | import { type SubmitHandler, useForm } from 'react-hook-form'; 2 | import { useLens } from '@hookform/lenses'; 3 | import { action } from '@storybook/addon-actions'; 4 | import type { Meta, StoryObj } from '@storybook/react'; 5 | import { expect, fn, userEvent, within } from '@storybook/test'; 6 | 7 | import { StringInput } from '../../components'; 8 | 9 | const meta = { 10 | title: 'Focus/Array', 11 | component: Playground, 12 | } satisfies Meta; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | interface Item { 19 | id: string; 20 | value: string; 21 | } 22 | 23 | interface PlaygroundData { 24 | items: Item[]; 25 | } 26 | 27 | interface PlaygroundProps { 28 | onSubmit: SubmitHandler; 29 | } 30 | 31 | function Playground({ onSubmit = action('submit') }: PlaygroundProps) { 32 | const { handleSubmit, control } = useForm({}); 33 | const lens = useLens({ control }); 34 | 35 | return ( 36 |
37 |
38 | 39 | 40 |
41 | 42 |
43 | 44 | 45 |
46 |
47 | 48 |
49 |
50 | ); 51 | } 52 | 53 | export const ByString: Story = { 54 | args: { 55 | onSubmit: fn(), 56 | }, 57 | play: async ({ canvasElement, args }) => { 58 | const canvas = within(canvasElement); 59 | 60 | await userEvent.type(canvas.getByPlaceholderText(/items.0.id/i), 'one'); 61 | await userEvent.type(canvas.getByPlaceholderText(/items.0.value/i), 'one value'); 62 | await userEvent.type(canvas.getByPlaceholderText(/items.1.id/i), 'two'); 63 | await userEvent.type(canvas.getByPlaceholderText(/items.1.value/i), 'two value'); 64 | 65 | await userEvent.click(canvas.getByRole('button', { name: /submit/i })); 66 | 67 | expect(args.onSubmit).toHaveBeenCalledWith( 68 | { 69 | items: [ 70 | { 71 | id: 'one', 72 | value: 'one value', 73 | }, 74 | { 75 | id: 'two', 76 | value: 'two value', 77 | }, 78 | ], 79 | }, 80 | expect.anything(), 81 | ); 82 | }, 83 | }; 84 | -------------------------------------------------------------------------------- /examples/focus/array/ByIndex.story.tsx: -------------------------------------------------------------------------------- 1 | import { type SubmitHandler, useForm } from 'react-hook-form'; 2 | import { useLens } from '@hookform/lenses'; 3 | import { action } from '@storybook/addon-actions'; 4 | import type { Meta, StoryObj } from '@storybook/react'; 5 | import { expect, fn, userEvent, within } from '@storybook/test'; 6 | 7 | import { StringInput } from '../../components'; 8 | 9 | const meta = { 10 | title: 'Focus/Array', 11 | component: Playground, 12 | } satisfies Meta; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | interface Item { 19 | id: string; 20 | value: string; 21 | } 22 | 23 | interface PlaygroundData { 24 | items: Item[]; 25 | } 26 | 27 | interface PlaygroundProps { 28 | onSubmit: SubmitHandler; 29 | } 30 | 31 | function Playground({ onSubmit = action('submit') }: PlaygroundProps) { 32 | const { handleSubmit, control } = useForm({}); 33 | const lens = useLens({ control }); 34 | const items = lens.focus('items'); 35 | 36 | return ( 37 |
38 |
39 | 40 | 41 |
42 | 43 |
44 | 45 | 46 |
47 |
48 | 49 |
50 |
51 | ); 52 | } 53 | 54 | export const ByIndex: Story = { 55 | args: { 56 | onSubmit: fn(), 57 | }, 58 | play: async ({ canvasElement, args }) => { 59 | const canvas = within(canvasElement); 60 | 61 | await userEvent.type(canvas.getByPlaceholderText(/items.0.id/i), 'one'); 62 | await userEvent.type(canvas.getByPlaceholderText(/items.0.value/i), 'one value'); 63 | await userEvent.type(canvas.getByPlaceholderText(/items.1.id/i), 'two'); 64 | await userEvent.type(canvas.getByPlaceholderText(/items.1.value/i), 'two value'); 65 | 66 | await userEvent.click(canvas.getByRole('button', { name: /submit/i })); 67 | 68 | expect(args.onSubmit).toHaveBeenCalledWith( 69 | { 70 | items: [ 71 | { 72 | id: 'one', 73 | value: 'one value', 74 | }, 75 | { 76 | id: 'two', 77 | value: 'two value', 78 | }, 79 | ], 80 | }, 81 | expect.anything(), 82 | ); 83 | }, 84 | }; 85 | -------------------------------------------------------------------------------- /eslint.config.ts: -------------------------------------------------------------------------------- 1 | import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; 2 | import reactPlugin from 'eslint-plugin-react'; 3 | import simpleImportSort from 'eslint-plugin-simple-import-sort'; 4 | import tseslint from 'typescript-eslint'; 5 | 6 | export default tseslint.config( 7 | { 8 | ignores: ['.history', 'dist', 'node_modules'], 9 | }, 10 | // @ts-expect-error - looks like wrong types 11 | reactPlugin.configs.flat['recommended'], 12 | ...tseslint.configs.recommended, 13 | eslintPluginPrettierRecommended, 14 | { 15 | plugins: { 16 | 'simple-import-sort': simpleImportSort, 17 | }, 18 | languageOptions: { 19 | ecmaVersion: 2020, 20 | sourceType: 'module', 21 | parserOptions: { 22 | ecmaFeatures: { 23 | jsx: true, 24 | }, 25 | }, 26 | }, 27 | settings: { 28 | react: { 29 | pragma: 'React', 30 | version: 'detect', 31 | }, 32 | }, 33 | rules: { 34 | curly: 'error', 35 | 'no-extra-boolean-cast': 'error', 36 | 'cypress/unsafe-to-chain-command': 'off', 37 | '@typescript-eslint/no-non-null-assertion': 'off', 38 | '@typescript-eslint/no-empty-function': 'off', 39 | '@typescript-eslint/ban-ts-comment': 'warn', 40 | '@typescript-eslint/ban-types': 'off', 41 | '@typescript-eslint/no-explicit-any': 'off', 42 | '@typescript-eslint/explicit-module-boundary-types': 'off', 43 | '@typescript-eslint/no-unused-vars': ['warn', { ignoreRestSiblings: true }], 44 | 'cypress/no-unnecessary-waiting': 'off', 45 | 'react/display-name': 'warn', 46 | 'react/prop-types': 'off', 47 | 'no-console': ['error'], 48 | 'simple-import-sort/imports': [ 49 | 'error', 50 | { 51 | groups: [ 52 | // Side effect imports. 53 | ['^\\u0000'], 54 | // Packages. `react` related packages come first. 55 | ['^react', '^@?\\w'], 56 | // Parent imports. Put `..` last. 57 | ['^\\.\\.(?!/?$)', '^\\.\\./?$'], 58 | // Other relative imports. Put same-folder imports and `.` last. 59 | ['^\\./(?=.*/)(?!/?$)', '^\\.(?!/?$)', '^\\./?$'], 60 | ], 61 | }, 62 | ], 63 | 'simple-import-sort/exports': 'error', 64 | }, 65 | }, 66 | { 67 | files: ['**/*.test.ts', '**/*.test.tsx', 'examples/**/*.ts', 'examples/**/*.tsx'], 68 | rules: { 69 | // Allow testing runtime errors to suppress TS errors 70 | '@typescript-eslint/ban-ts-comment': 'off', 71 | 'react/react-in-jsx-scope': 'off', 72 | }, 73 | }, 74 | ); 75 | -------------------------------------------------------------------------------- /examples/map/array/Append.story.tsx: -------------------------------------------------------------------------------- 1 | import { type SubmitHandler, useForm } from 'react-hook-form'; 2 | import { type Lens, useLens } from '@hookform/lenses'; 3 | import { useFieldArray } from '@hookform/lenses/rhf'; 4 | import { action } from '@storybook/addon-actions'; 5 | import type { Meta, StoryObj } from '@storybook/react'; 6 | import { expect, fn, userEvent, within } from '@storybook/test'; 7 | 8 | import { StringInput } from '../../components'; 9 | 10 | const meta = { 11 | title: 'Map/Array', 12 | component: Playground, 13 | } satisfies Meta; 14 | 15 | export default meta; 16 | 17 | type Story = StoryObj; 18 | 19 | interface Item { 20 | value: string; 21 | } 22 | 23 | interface PlaygroundData { 24 | items: Item[]; 25 | } 26 | 27 | interface PlaygroundProps { 28 | onSubmit: SubmitHandler; 29 | } 30 | 31 | function Playground({ onSubmit = action('submit') }: PlaygroundProps) { 32 | const { handleSubmit, control } = useForm({}); 33 | const lens = useLens({ control }); 34 | 35 | return ( 36 |
37 | 38 |
39 | 40 |
41 | 42 | ); 43 | } 44 | 45 | function Items({ lens }: { lens: Lens }) { 46 | const { fields, append } = useFieldArray(lens.interop()); 47 | 48 | return ( 49 |
50 | {lens.map(fields, (value, l) => ( 51 |
52 | 53 |
54 | ))} 55 | 58 |
59 | ); 60 | } 61 | 62 | export const Append: Story = { 63 | args: { 64 | onSubmit: fn(), 65 | }, 66 | play: async ({ canvasElement, args }) => { 67 | const canvas = within(canvasElement); 68 | 69 | await userEvent.click(canvas.getByRole('button', { name: /add item/i })); 70 | await userEvent.type(canvas.getByPlaceholderText(/items\.0\.value/i), 'one'); 71 | 72 | await userEvent.click(canvas.getByRole('button', { name: /add item/i })); 73 | await userEvent.type(canvas.getByPlaceholderText(/items\.1\.value/i), 'two'); 74 | 75 | await userEvent.click(canvas.getByRole('button', { name: /add item/i })); 76 | await userEvent.type(canvas.getByPlaceholderText(/items\.2\.value/i), 'three'); 77 | 78 | await userEvent.click(canvas.getByRole('button', { name: /submit/i })); 79 | 80 | expect(args.onSubmit).toHaveBeenCalledWith( 81 | { 82 | items: [{ value: 'one' }, { value: 'two' }, { value: 'three' }], 83 | }, 84 | expect.anything(), 85 | ); 86 | }, 87 | }; 88 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: [opened, synchronize, labeled] 9 | paths-ignore: 10 | - '.gitignore' 11 | - '.npmignore' 12 | - '*.md' 13 | 14 | jobs: 15 | release: 16 | name: Release 17 | if: github.repository == 'react-hook-form/lenses' && github.ref == 'refs/heads/main' 18 | runs-on: ubuntu-latest 19 | permissions: 20 | contents: write 21 | issues: write 22 | pull-requests: write 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 0 28 | persist-credentials: false 29 | 30 | - name: Setup environment 31 | uses: ./.github/actions/setup 32 | 33 | - name: Build 34 | run: bun run build 35 | 36 | - name: Publish 37 | run: bunx semantic-release --branches main 38 | env: 39 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 40 | GITHUB_TOKEN: '${{secrets.GITHUB_TOKEN}}' 41 | 42 | publish-next: 43 | name: Publish Next Version 44 | if: github.repository == 'react-hook-form/lenses' && github.event_name == 'pull_request' && github.event.action == 'labeled' && github.event.label.name == 'publish-next' 45 | runs-on: ubuntu-latest 46 | permissions: 47 | contents: write 48 | issues: write 49 | pull-requests: write 50 | steps: 51 | - name: Checkout repository 52 | uses: actions/checkout@v4 53 | with: 54 | fetch-depth: 0 55 | persist-credentials: false 56 | 57 | - name: Setup environment 58 | uses: ./.github/actions/setup 59 | 60 | - name: Build 61 | run: bun run build 62 | 63 | - name: Generate canary version and publish 64 | run: | 65 | # Generate unique canary version 66 | PR_NUMBER="${{ github.event.pull_request.number }}" 67 | CURRENT_DATE=$(date +%Y%m%d) 68 | SHORT_COMMIT=$(git rev-parse --short HEAD) 69 | CANARY_VERSION="0.0.0-canary-${PR_NUMBER}-${CURRENT_DATE}-${SHORT_COMMIT}" 70 | 71 | echo "Publishing canary version: ${CANARY_VERSION}" 72 | 73 | # Update package.json version using bun 74 | bun --bun -e " 75 | const pkg = JSON.parse(require('fs').readFileSync('./package.json', 'utf8')); 76 | pkg.version = '${CANARY_VERSION}'; 77 | require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\n'); 78 | " 79 | 80 | # Publish with bun (uses NPM_CONFIG_TOKEN env var automatically) 81 | bun publish --tag next --access public 82 | env: 83 | NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} 84 | -------------------------------------------------------------------------------- /src/types/ObjectLens.ts: -------------------------------------------------------------------------------- 1 | import type { Path, PathValue } from 'react-hook-form'; 2 | 3 | import type { LensInterop } from './Interop'; 4 | import type { Lens, LensesDictionary, LensesGetter, UnwrapLens } from './Lens'; 5 | 6 | export interface ObjectLensGetter { 7 | /** 8 | * An object lens restructures function. 9 | * 10 | * @param dictionary - It is a proxy which focuses a lens on property read. 11 | * @param lens - Current object lens. 12 | */ 13 | (dictionary: LensesDictionary, lens: Lens): LensesGetter; 14 | } 15 | 16 | export interface ObjectLens extends LensInterop { 17 | /** 18 | * This method allows you to create a new lens that focuses on a specific field in the form. 19 | * 20 | * @param path - The path to the field in the form. 21 | * 22 | * @example 23 | * ```tsx 24 | * function App() { 25 | * const { control, handleSubmit } = useForm<{ 26 | * firstName: string; 27 | * some: { nested: { field: string } }; 28 | * arr: { value: string }[]; 29 | * }>(); 30 | * 31 | * const lens = useLens({ control }); 32 | * 33 | * return ( 34 | *
35 | * 36 | * 37 | * 38 | * 39 | * 40 | * ); 41 | * } 42 | * 43 | * function StringInput({ lens }: { lens: Lens }) { 44 | * return ctrl.register(name))} />; 45 | * } 46 | * ``` 47 | */ 48 | focus

>(path: P): Lens>; 49 | 50 | /** 51 | * This method allows you to create a new lens with different shape. 52 | * 53 | * @param getter - A function that returns an object where each field is a lens. 54 | * 55 | * @example 56 | * ```tsx 57 | * function Component({ lens }: { lens: Lens<{ firstName: string; lastName: string; age: number }> }) { 58 | * return ( 59 | * ({ 61 | * ...rest, 62 | * name: firstName, 63 | * surname: lastName, 64 | * }))} 65 | * /> 66 | * ); 67 | * } 68 | * function SharedComponent({ lens }: { lens: Lens<{ name: string; surname: string; age: number }> }) { 69 | * return ( 70 | *

71 | * 72 | * 73 | * 74 | *
75 | * ); 76 | * } 77 | * ``` 78 | */ 79 | reflect(getter: ObjectLensGetter): Lens>; 80 | } 81 | -------------------------------------------------------------------------------- /examples/hook-form/UseController.story.tsx: -------------------------------------------------------------------------------- 1 | import { type RegisterOptions, type SubmitErrorHandler, type SubmitHandler, useController, useForm } from 'react-hook-form'; 2 | import { type HookFormControlShim, type Lens, useLens } from '@hookform/lenses'; 3 | import { action } from '@storybook/addon-actions'; 4 | import type { Meta, StoryObj } from '@storybook/react'; 5 | import { expect, fn, userEvent, within } from '@storybook/test'; 6 | 7 | import { StringInput } from '../components'; 8 | 9 | const meta = { 10 | title: 'Hook-Form', 11 | component: Playground, 12 | } satisfies Meta; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | interface PlaygroundData { 19 | firstName: string; 20 | password: string; 21 | } 22 | 23 | interface PlaygroundProps { 24 | onSubmit: SubmitHandler; 25 | onInvalid: SubmitErrorHandler; 26 | } 27 | 28 | function Playground({ onSubmit = action('submit'), onInvalid = action('invalid') }: PlaygroundProps) { 29 | const { handleSubmit, control } = useForm({ shouldUseNativeValidation: true }); 30 | const lens = useLens({ control }); 31 | 32 | return ( 33 |
34 | 35 | 36 | 37 |
38 | 39 |
40 | 41 | ); 42 | } 43 | 44 | interface PasswordInputProps { 45 | lens: Lens; 46 | rules?: RegisterOptions>; 47 | } 48 | 49 | function PasswordInput({ lens, rules = {} }: PasswordInputProps) { 50 | const { field, fieldState } = useController({ ...lens.interop(), rules }); 51 | 52 | return ( 53 | 57 | ); 58 | } 59 | 60 | export const UseController: Story = { 61 | args: { 62 | onSubmit: fn(), 63 | onInvalid: fn(), 64 | }, 65 | play: async ({ canvasElement, args }) => { 66 | const canvas = within(canvasElement); 67 | 68 | await userEvent.type(canvas.getByPlaceholderText(/firstName/i), 'joe'); 69 | await userEvent.click(canvas.getByRole('button', { name: /submit/i })); 70 | 71 | expect(args.onSubmit).not.toHaveBeenCalled(); 72 | expect(args.onInvalid).toHaveBeenCalledWith( 73 | { 74 | password: { 75 | message: 'password is required', 76 | ref: expect.anything(), 77 | type: 'required', 78 | }, 79 | }, 80 | expect.anything(), 81 | ); 82 | 83 | await userEvent.type(canvas.getByPlaceholderText(/password/i), 'password'); 84 | await userEvent.click(canvas.getByRole('button', { name: /submit/i })); 85 | 86 | expect(args.onSubmit).toHaveBeenCalledWith( 87 | { 88 | firstName: 'joe', 89 | password: 'password', 90 | }, 91 | expect.anything(), 92 | ); 93 | }, 94 | }; 95 | -------------------------------------------------------------------------------- /src/types/Interop.ts: -------------------------------------------------------------------------------- 1 | import type { Control, FieldValues } from 'react-hook-form'; 2 | 3 | /** 4 | * This is a trick to allow `control` to have typed `Control` type. 5 | * Because `Lens` doesn't have prop path in its types you can't use it with `Control` as is. 6 | * 7 | * For example this code is not valid: 8 | * 9 | * ```tsx 10 | * function Test({ control }: { control: Control }) {} 11 | * ``` 12 | * To provide type checking we can simulate that `T` is an object with one field and then immediately use it: 13 | * 14 | * ```tsx 15 | * function Test({ control }: { control: Control<{ __DO_NOT_USE_NON_OBJECT_FIELD_SHIM__: number }> }) { 16 | * const { field } = useController({ control, name: '__DO_NOT_USE_NON_OBJECT_FIELD_SHIM__' }); 17 | * console.log(field.value); // field.value is number 18 | * } 19 | * ``` 20 | * 21 | * This trick is needed for type checking. When you use `lens.interop()` it returns correct `name` in runtime. 22 | * 23 | * ```tsx 24 | * function Test({ lens }: { lens: Lens }) { 25 | * const { field } = useController(lens.interop()); 26 | * console.log(field.value); // field.value is number 27 | * } 28 | * ``` 29 | */ 30 | export interface HookFormControlShim { 31 | __DO_NOT_USE_HOOK_FORM_CONTROL_SHIM__: T; 32 | } 33 | 34 | export type ShimKeyName = keyof HookFormControlShim; 35 | 36 | export interface LensInteropBinding { 37 | control: Control; 38 | name: Name; 39 | } 40 | 41 | export interface LensInteropFunction { 42 | (control: LensInteropBinding['control'], name: LensInteropBinding['name']): R; 43 | } 44 | 45 | export interface LensInterop { 46 | /** 47 | * This method returns `name` and `control` properties from react-hook-form. 48 | * 49 | * @example 50 | * ```tsx 51 | * function App() { 52 | * const { control, handleSubmit } = useForm<{ 53 | * firstName: string; 54 | * }>(); 55 | * 56 | * const lens = useLens({ control }); 57 | * const interop = lens.focus('firstName').interop(); 58 | * 59 | * return ( 60 | *
61 | * 62 | * 63 | *
64 | * ); 65 | * } 66 | * ``` 67 | */ 68 | interop(): LensInteropBinding, ShimKeyName>; 69 | 70 | /** 71 | * This method allows you to use `control` and `name` properties from react-hook-form in a callback. 72 | * 73 | * @example 74 | * ```tsx 75 | * function App() { 76 | * const { control, handleSubmit } = useForm<{ 77 | * firstName: string; 78 | * }>(); 79 | * 80 | * const lens = useLens({ control }); 81 | * 82 | * return ( 83 | *
84 | * ctrl.register(name))} /> 85 | * 86 | *
87 | * ); 88 | * } 89 | * ``` 90 | */ 91 | interop(callback: LensInteropFunction, ShimKeyName, R>): R; 92 | } 93 | -------------------------------------------------------------------------------- /src/rhf/useFieldArray.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { 3 | type FieldArray, 4 | type FieldArrayPath, 5 | type FieldArrayWithId, 6 | type FieldValues, 7 | useFieldArray as useFieldArrayOriginal, 8 | type UseFieldArrayProps as UseFieldArrayPropsOriginal, 9 | type UseFieldArrayReturn, 10 | } from 'react-hook-form'; 11 | 12 | export interface UseFieldArrayProps< 13 | TFieldValues extends FieldValues = FieldValues, 14 | TFieldArrayName extends FieldArrayPath = FieldArrayPath, 15 | TKeyName extends string = 'id', 16 | > extends UseFieldArrayPropsOriginal { 17 | getTransformer?: >( 18 | value: FieldArrayWithId, 19 | ) => FieldArrayWithId; 20 | 21 | setTransformer?: >( 22 | value: FieldArray, 23 | ) => FieldArrayWithId; 24 | } 25 | 26 | export function useFieldArray< 27 | TFieldValues extends FieldValues = FieldValues, 28 | TFieldArrayName extends FieldArrayPath = FieldArrayPath, 29 | TKeyName extends string = 'id', 30 | >(props: UseFieldArrayProps): UseFieldArrayReturn { 31 | const original = useFieldArrayOriginal(props); 32 | const newFields = useMemo(() => { 33 | if (!props.getTransformer) { 34 | return original.fields; 35 | } 36 | 37 | return original.fields.map(props.getTransformer); 38 | }, [original.fields, props.getTransformer]); 39 | 40 | return { 41 | fields: newFields, 42 | move: original.move, 43 | remove: original.remove, 44 | swap: original.swap, 45 | prepend: (value, options) => { 46 | if (!props.setTransformer) { 47 | return original.prepend(value, options); 48 | } 49 | 50 | const newValue = Array.isArray(value) ? value.map(props.setTransformer) : props.setTransformer(value); 51 | original.prepend(newValue, options); 52 | }, 53 | append: (value, options) => { 54 | if (!props.setTransformer) { 55 | return original.append(value, options); 56 | } 57 | 58 | const newValue = Array.isArray(value) ? value.map(props.setTransformer) : props.setTransformer(value); 59 | original.append(newValue, options); 60 | }, 61 | insert: (index, value, options) => { 62 | if (!props.setTransformer) { 63 | return original.insert(index, value, options); 64 | } 65 | 66 | const newValue = Array.isArray(value) ? value.map(props.setTransformer) : props.setTransformer(value); 67 | original.insert(index, newValue, options); 68 | }, 69 | update: (index, value) => { 70 | if (!props.setTransformer) { 71 | return original.update(index, value); 72 | } 73 | 74 | const newValue = props.setTransformer(value); 75 | original.update(index, newValue); 76 | }, 77 | replace: (value) => { 78 | if (!props.setTransformer) { 79 | return original.replace(value); 80 | } 81 | 82 | const newValue = props.setTransformer(value); 83 | original.replace(newValue); 84 | }, 85 | }; 86 | } 87 | -------------------------------------------------------------------------------- /examples/reflect/array/TopLevel.story.tsx: -------------------------------------------------------------------------------- 1 | import { type SubmitHandler, useForm } from 'react-hook-form'; 2 | import { type Lens, useLens } from '@hookform/lenses'; 3 | import { useFieldArray } from '@hookform/lenses/rhf'; 4 | import { action } from '@storybook/addon-actions'; 5 | import type { Meta, StoryObj } from '@storybook/react'; 6 | import { expect, fn, userEvent, within } from '@storybook/test'; 7 | 8 | import { StringInput } from '../../components'; 9 | 10 | const meta = { 11 | title: 'Reflect/Array', 12 | component: Playground, 13 | } satisfies Meta; 14 | 15 | export default meta; 16 | 17 | type Story = StoryObj; 18 | 19 | interface Item { 20 | value: { inside: string }; 21 | } 22 | 23 | interface PlaygroundData { 24 | items: Item[]; 25 | } 26 | 27 | interface PlaygroundProps { 28 | onSubmit: SubmitHandler; 29 | } 30 | 31 | function Playground({ onSubmit = action('submit') }: PlaygroundProps) { 32 | const { handleSubmit, control } = useForm({ 33 | defaultValues: { items: [{ value: { inside: 'one' } }, { value: { inside: 'two' } }, { value: { inside: 'three' } }] }, 34 | }); 35 | const lens = useLens({ control }); 36 | 37 | return ( 38 |
39 | [{ data: l.value.focus('inside') }])} /> 40 |
41 | 42 |
43 | 44 | ); 45 | } 46 | 47 | function Items({ lens }: { lens: Lens<{ data: string }[]> }) { 48 | const { fields, append } = useFieldArray(lens.interop()); 49 | 50 | return ( 51 |
52 | {lens.map(fields, (value, l) => ( 53 |
54 |
Data: {value.data}
55 | 56 |
57 | ))} 58 | 66 |
67 | ); 68 | } 69 | 70 | export const TopLevel: Story = { 71 | args: { 72 | onSubmit: fn(), 73 | }, 74 | play: async ({ canvasElement, args }) => { 75 | const canvas = within(canvasElement); 76 | 77 | expect(canvas.getByPlaceholderText('items.0.value.inside')).toHaveValue('one'); 78 | expect(canvas.getByPlaceholderText('items.1.value.inside')).toHaveValue('two'); 79 | expect(canvas.getByPlaceholderText('items.2.value.inside')).toHaveValue('three'); 80 | 81 | await userEvent.click(canvas.getByText(/Add item/i)); 82 | 83 | await userEvent.type(canvas.getByPlaceholderText('items.3.value.inside'), ' four'); 84 | expect(canvas.getByPlaceholderText('items.3.value.inside')).toHaveValue('more four'); 85 | 86 | await userEvent.click(canvas.getByRole('button', { name: /submit/i })); 87 | 88 | expect(args.onSubmit).toHaveBeenCalledWith( 89 | { 90 | items: [ 91 | { value: { inside: 'one' } }, 92 | { value: { inside: 'two' } }, 93 | { value: { inside: 'three' } }, 94 | { value: { inside: 'more four' } }, 95 | ], 96 | }, 97 | expect.anything(), 98 | ); 99 | }, 100 | }; 101 | -------------------------------------------------------------------------------- /examples/Complex.story.tsx: -------------------------------------------------------------------------------- 1 | import { type SubmitHandler, useForm } from 'react-hook-form'; 2 | import { type Lens, useLens } from '@hookform/lenses'; 3 | import { useFieldArray } from '@hookform/lenses/rhf'; 4 | import { action } from '@storybook/addon-actions'; 5 | import { type Meta } from '@storybook/react'; 6 | 7 | import { NumberInput, StringInput } from './components'; 8 | 9 | export default { 10 | title: 'Complex', 11 | } satisfies Meta; 12 | 13 | export interface Actor { 14 | name: string; 15 | birthYear: number; 16 | } 17 | 18 | export interface Movie { 19 | title: string; 20 | summary: string; 21 | actors: Actor[]; 22 | } 23 | export interface Owner { 24 | personName: string; 25 | yearOfBirth: number; 26 | } 27 | 28 | export interface Studio { 29 | name: string; 30 | location: string; 31 | owner: Owner; 32 | } 33 | 34 | export interface MovieCollection { 35 | studio: Studio; 36 | movies: Movie[]; 37 | } 38 | 39 | export interface ComplexProps { 40 | onSubmit: SubmitHandler; 41 | } 42 | 43 | export function Complex({ onSubmit = action('submit') }: ComplexProps) { 44 | const { handleSubmit, control } = useForm(); 45 | const lens = useLens({ control }); 46 | 47 | return ( 48 |
49 | 50 | 51 | 52 |
53 | ({ name: personName, birthYear: yearOfBirth }))} 55 | /> 56 | 57 |
58 | 59 | 60 | 61 | 62 | ); 63 | } 64 | 65 | function MoviesForm({ lens }: { lens: Lens }) { 66 | const { fields, append, remove } = useFieldArray(lens.interop()); 67 | 68 | return ( 69 |
70 |
71 | 72 |
73 | 74 | {lens.map(fields, (value, l, index) => ( 75 |
76 | 77 | 78 | 79 | 80 |
81 |
82 | ))} 83 |
84 | ); 85 | } 86 | 87 | function ActorsForm({ lens }: { lens: Lens }) { 88 | const { fields, append, remove } = useFieldArray(lens.interop()); 89 | 90 | return ( 91 |
92 |
93 | 94 |
95 | {lens.map(fields, (value, l, index) => ( 96 |
97 | 98 | 99 |
100 | ))} 101 |
102 | ); 103 | } 104 | 105 | interface PersonFormData { 106 | name: string; 107 | birthYear: number; 108 | } 109 | 110 | function PersonForm({ lens }: { lens: Lens }) { 111 | return ( 112 |
113 | 114 |
115 | 116 |
117 | ); 118 | } 119 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hookform/lenses", 3 | "version": "0.0.0-semantically-released", 4 | "description": "Type-safe lenses for React Hook Form that enable precise control over nested form state. Build reusable form components with composable operations, array handling, and full TypeScript support.", 5 | "keywords": [ 6 | "react", 7 | "react-hook-form", 8 | "hooks", 9 | "form", 10 | "forms", 11 | "lenses", 12 | "reusable-form-components", 13 | "typescript", 14 | "react-hooks", 15 | "type-safe", 16 | "form-management", 17 | "react-components" 18 | ], 19 | "homepage": "https://react-hook-form.com/docs/uselens", 20 | "bugs": { 21 | "url": "https://github.com/react-hook-form/lenses/issues" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/react-hook-form/lenses.git" 26 | }, 27 | "license": "MIT", 28 | "sideEffects": false, 29 | "type": "module", 30 | "exports": { 31 | "./package.json": "./package.json", 32 | ".": { 33 | "types": "./dist/index.d.ts", 34 | "import": "./dist/index.js", 35 | "require": "./dist/index.cjs" 36 | }, 37 | "./rhf": { 38 | "types": "./dist/rhf/index.d.ts", 39 | "import": "./dist/rhf/index.js", 40 | "require": "./dist/rhf/index.cjs" 41 | } 42 | }, 43 | "main": "dist/index.cjs", 44 | "module": "dist/index.js", 45 | "types": "dist/index.d.ts", 46 | "files": [ 47 | "dist" 48 | ], 49 | "scripts": { 50 | "build": "tsup", 51 | "lint": "bun --bun eslint '**/*.{ts,tsx}'", 52 | "lint:fix": "bun lint --fix", 53 | "prettier": "prettier --check '**/*.md'", 54 | "prettier:fix": "prettier --write '**/*.md'", 55 | "storybook": "storybook dev -p 6006", 56 | "typecheck": "tsc --noEmit", 57 | "test": "vitest", 58 | "test:watch": "vitest watch", 59 | "test:e2e": "vitest run --project=e2e --coverage", 60 | "test:unit": "vitest run --project=unit --coverage" 61 | }, 62 | "peerDependencies": { 63 | "react": "^16.8.0 || ^17 || ^18 || ^19", 64 | "react-hook-form": "^7.55.0" 65 | }, 66 | "devDependencies": { 67 | "@hookform/resolvers": "^5.2.1", 68 | "@storybook/addon-essentials": "^8.6.12", 69 | "@storybook/experimental-addon-test": "^8.6.12", 70 | "@storybook/react": "^8.6.12", 71 | "@storybook/react-vite": "^8.6.12", 72 | "@storybook/test": "^8.6.12", 73 | "@testing-library/jest-dom": "^6.6.3", 74 | "@testing-library/react": "^16.3.0", 75 | "@types/node": "^22.15.17", 76 | "@types/react": "^19.1.3", 77 | "@types/react-dom": "^19.1.3", 78 | "@vitejs/plugin-react": "^4.4.1", 79 | "@vitest/browser": "3.1.3", 80 | "@vitest/coverage-v8": "^3.1.3", 81 | "babel-plugin-react-compiler": "^19.1.0-rc.1", 82 | "eslint": "^9.26.0", 83 | "eslint-config-prettier": "^10.1.5", 84 | "eslint-plugin-prettier": "^5.4.0", 85 | "eslint-plugin-react": "^7.37.5", 86 | "eslint-plugin-simple-import-sort": "^12.1.1", 87 | "eslint-plugin-storybook": "^0.12.0", 88 | "jiti": "^2.4.2", 89 | "jsdom": "^26.1.0", 90 | "playwright": "^1.52.0", 91 | "prettier": "^3.5.3", 92 | "storybook": "^8.6.12", 93 | "tsup": "^8.4.0", 94 | "typescript": "^5.8.3", 95 | "typescript-eslint": "^8.32.0", 96 | "vite": "^6.3.5", 97 | "vite-plugin-checker": "^0.9.3", 98 | "vite-tsconfig-paths": "^5.1.4", 99 | "vitest": "^3.1.3", 100 | "zod": "^4.0.14" 101 | }, 102 | "engines": { 103 | "bun": ">=1.2" 104 | }, 105 | "publishConfig": { 106 | "access": "public" 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /tests/object-interop.test.ts: -------------------------------------------------------------------------------- 1 | import { type Control, useController, useForm } from 'react-hook-form'; 2 | import { type HookFormControlShim, type ShimKeyName, useLens } from '@hookform/lenses'; 3 | import { renderHook } from '@testing-library/react'; 4 | import { expectTypeOf } from 'vitest'; 5 | 6 | test('interop returns name and control', () => { 7 | const { result } = renderHook(() => { 8 | const form = useForm<{ a: string }>(); 9 | const lens = useLens({ control: form.control }); 10 | return { lens, form }; 11 | }); 12 | 13 | const interop = result.current.lens.focus('a').interop(); 14 | 15 | expectTypeOf(interop).toEqualTypeOf<{ 16 | name: ShimKeyName; 17 | control: Control>; 18 | }>(); 19 | 20 | expect(interop.name).toBe('a'); 21 | expect(interop.control).toBe(result.current.form.control); 22 | }); 23 | 24 | test('interop can be used with a callback', () => { 25 | const { result } = renderHook(() => { 26 | const form = useForm<{ a: string }>(); 27 | const lens = useLens({ control: form.control }); 28 | return { lens, form }; 29 | }); 30 | 31 | const interop = result.current.lens.focus('a').interop((interopControl, interopName) => ({ interopName, interopControl })); 32 | 33 | expectTypeOf(interop).toEqualTypeOf<{ 34 | interopName: ShimKeyName; 35 | interopControl: Control>; 36 | }>(); 37 | 38 | expect(interop.interopName).toBe('a'); 39 | expect(interop.interopControl).toBe(result.current.form.control); 40 | }); 41 | 42 | test('interop can hold the value of the field', () => { 43 | const { result } = renderHook(() => { 44 | const form = useForm<{ a: { b: { c: string }; d: { e: string } } }>(); 45 | const lens = useLens({ control: form.control }); 46 | const ctrl = useController(lens.focus('a').interop()); 47 | return { lens, form, ctrl }; 48 | }); 49 | 50 | const interop = result.current.lens.focus('a').interop((interopControl, interopName) => ({ interopName, interopControl })); 51 | 52 | expectTypeOf(result.current.ctrl.field.value).toEqualTypeOf<{ 53 | b: { c: string }; 54 | d: { e: string }; 55 | }>(); 56 | 57 | expect(interop.interopName).toBe('a'); 58 | expect(interop.interopControl).toBe(result.current.form.control); 59 | }); 60 | 61 | test('interop can hold the union value of the field', () => { 62 | const { result } = renderHook(() => { 63 | const form = useForm<{ a: string | number }>(); 64 | const lens = useLens({ control: form.control }); 65 | const interop = lens.focus('a').interop(); 66 | const ctrl = useController(interop); 67 | return { lens, form, ctrl }; 68 | }); 69 | 70 | const interop = result.current.lens.focus('a').interop((interopControl, interopName) => ({ interopName, interopControl })); 71 | 72 | expectTypeOf(result.current.ctrl.field.value).toEqualTypeOf(); 73 | 74 | expect(interop.interopName).toBe('a'); 75 | expect(interop.interopControl).toBe(result.current.form.control); 76 | }); 77 | 78 | test('interop can hold the literal union value of the field', () => { 79 | const { result } = renderHook(() => { 80 | const form = useForm<{ type: 'admin' | 'general' }>(); 81 | const lens = useLens({ control: form.control }); 82 | const ctrl = useController(lens.focus('type').interop()); 83 | return { lens, form, ctrl }; 84 | }); 85 | 86 | const interop = result.current.lens.focus('type').interop((interopControl, interopName) => ({ interopName, interopControl })); 87 | 88 | expectTypeOf(result.current.ctrl.field.value).toEqualTypeOf<'admin' | 'general'>(); 89 | 90 | expect(interop.interopName).toBe('type'); 91 | expect(interop.interopControl).toBe(result.current.form.control); 92 | }); 93 | -------------------------------------------------------------------------------- /src/types/toolkit.ts: -------------------------------------------------------------------------------- 1 | import type { Lens } from './Lens'; 2 | 3 | export interface Toolkit { 4 | /** 5 | * Manual narrowing helper – lets you tell the type system what branch of the 6 | * union you want without providing a discriminant property/value. This is 7 | * effectively a *type-level cast* that narrows the lens type so you 8 | * can continue chaining: 9 | * 10 | * ```ts 11 | * const maybe = lens.focus('optional'); // Lens 12 | * const defined = maybe.narrow(); // Lens 13 | * ``` 14 | * 15 | * Use it when you already know (by external logic) that the value is of a 16 | * specific subtype – e.g. you validated it, or are inside a branch guarded by 17 | * your own runtime check. 18 | */ 19 | narrow(): Lens; 20 | 21 | /** 22 | * Narrow the lens type to the union variant identified by the 23 | * discriminant tuple `(key, value)`. 24 | * 25 | * Example 26 | * ```ts 27 | * // T is Dog | Cat 28 | * const dogLens = lens.narrow('type', 'dog'); // Lens 29 | * ``` 30 | * 31 | * @param key – name of the discriminant property (e.g. `'type'`) 32 | * @param value – literal value that selects the desired branch (e.g. `'dog'`) 33 | */ 34 | // Discriminant-based narrowing (objects & tagged unions) 35 | narrow(key: K, value: V): Lens>>; 36 | 37 | /** 38 | * Manual assertion counterpart – convinces TypeScript that **`this`** lens is 39 | * already the desired subtype `R`. 40 | * 41 | * Unlike the discriminant overload it takes **no arguments** – you just 42 | * specify the generic parameter: 43 | * 44 | * ```ts 45 | * function needsString(l: Lens) {} 46 | * maybeLens.assert(); 47 | * needsString(maybeLens); // now ok 48 | * ``` 49 | * 50 | * Prefer the zero-arg overload when you are in a code branch proven by custom 51 | * runtime checks or external guarantees. 52 | */ 53 | assert(): asserts this is Lens; 54 | 55 | /** 56 | * Assert that the current lens is already narrowed to the branch 57 | * `{ [key]: value }`. 58 | * 59 | * Useful in `if`/`switch` statements when you do not need a separate variable: 60 | * ```ts 61 | * if (selected.type === 'cat') { 62 | * lens.assert('type', 'cat'); 63 | * // ↳ within this block lens is Lens 64 | * } 65 | * ``` 66 | * 67 | * @param key – discriminant property name 68 | * @param value – discriminant literal value 69 | */ 70 | // Discriminant form 71 | assert(key: K, value: V): asserts this is Lens>>; 72 | 73 | /** 74 | * Narrow the lens type to exclude `null` and `undefined`. 75 | * 76 | * Example: 77 | * ```ts 78 | * const maybeLens = lens.focus('optional'); // Lens 79 | * const definedLens = maybeLens.defined(); // Lens 80 | * ``` 81 | * 82 | * This is equivalent to using `narrow>()` but provides a more 83 | * convenient and expressive API for the common case of excluding nullish values. 84 | * 85 | * Use this when you know (by external logic) that the value is not null or 86 | * undefined - e.g. you validated it, or are inside a branch guarded by your 87 | * own runtime check. 88 | */ 89 | defined(): Lens>; 90 | 91 | /** 92 | * Forcefully changes the lens type to a new type `R`, regardless of its 93 | * compatibility with the original type `T`. 94 | * 95 | * This is a powerful and potentially **unsafe** operation. Unlike `narrow`, 96 | * `cast` does not require the new type to be a subtype of the original. It's 97 | * a blunt tool for situations where you, the programmer, have more 98 | * information than the type system and need to force a type change. 99 | * 100 | * **Use with extreme caution.** It can lead to runtime errors if the 101 | * underlying data does not match the asserted type `R`. 102 | * 103 | * Example: 104 | * ```ts 105 | * // T is some type, e.g. `any` or `unknown` 106 | * declare const lens: Lens; 107 | * const stringLens = lens.cast(); // Now Lens 108 | * ``` 109 | * 110 | * @template R The new type to cast the lens to. 111 | */ 112 | cast(): Lens; 113 | } 114 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 | React Hook Form Logo - React hook custom hook for form validation 5 | 6 |

7 |
8 | 9 |

Performant, flexible and extensible forms with easy to use validation.

10 | 11 |
12 | 13 | [![npm downloads](https://img.shields.io/npm/dm/@hookform/lenses.svg?style=for-the-badge)](https://www.npmjs.com/package/@hookform/lenses) 14 | [![npm](https://img.shields.io/npm/dt/@hookform/lenses.svg?style=for-the-badge)](https://www.npmjs.com/package/@hookform/lenses) 15 | [![npm](https://img.shields.io/bundlephobia/minzip/@hookform/lenses?style=for-the-badge)](https://bundlephobia.com/result?p=@hookform/lenses) 16 | 17 |
18 | 19 |

20 | CodeSandbox | 21 | Examples | 22 | Docs 23 |

24 | 25 | ## React Hook Form Lenses 26 | 27 | React Hook Form Lenses is a powerful TypeScript-first library that brings the elegance of functional lenses to React Hook Form. By providing type-safe manipulation of nested form state, it enables developers to precisely control and transform complex form data with ease. The library's composable lens operations make it simple to work with deeply nested structures while maintaining type safety, leading to more maintainable and reusable form components. 28 | 29 | ### Installation 30 | 31 | ```bash 32 | npm install @hookform/lenses 33 | ``` 34 | 35 | ### Quickstart 36 | 37 | ```tsx 38 | import { useForm } from 'react-hook-form'; 39 | import { Lens, useLens } from '@hookform/lenses'; 40 | import { useFieldArray } from '@hookform/lenses/rhf'; 41 | 42 | function FormComponent() { 43 | const { handleSubmit, control } = useForm<{ 44 | firstName: string; 45 | lastName: string; 46 | children: { 47 | name: string; 48 | surname: string; 49 | }[]; 50 | }>({}); 51 | 52 | const lens = useLens({ control }); 53 | 54 | return ( 55 |
56 | ({ 58 | name: firstName, 59 | surname: lastName, 60 | }))} 61 | /> 62 | 63 | 64 | 65 | ); 66 | } 67 | 68 | function ChildForm({ lens }: { lens: Lens<{ name: string; surname: string }[]> }) { 69 | const { fields, append } = useFieldArray(lens.interop()); 70 | 71 | return ( 72 | <> 73 | 76 | {lens.map(fields, (value, l) => ( 77 | 78 | ))} 79 | 80 | ); 81 | } 82 | 83 | function PersonForm({ lens }: { lens: Lens<{ name: string; surname: string }> }) { 84 | return ( 85 |
86 | 87 | 88 |
89 | ); 90 | } 91 | 92 | function StringInput({ lens }: { lens: Lens }) { 93 | return ctrl.register(name))} />; 94 | } 95 | ``` 96 | 97 | ## No goals 98 | 99 | The aim of lenses is to serve as a carrier for control and name information. It should provide good approaches for manipulating types and describing form structure, but it is not supposed to manipulate actual values inside the form. 100 | 101 | ### No setters and getters 102 | 103 | This library intentionally does not provide setter and getter methods for lens values. While these might seem convenient, they can lead to unexpected bugs when developers read a value expecting it to be reactive, but it's not due to the nature of React Hook Form. Instead, use React Hook Form's standard hooks and patterns. 104 | 105 | ### Do not operate values directly 106 | 107 | Do not operate values directly from form state because components could get stuck since these values are not reactive. Instead, consume the actual values from appropriate hooks like `useWatch` and pass them into props. 108 | 109 | ## Known Issues 110 | 111 | Read more in the [React Hook Form compatibility issues](https://github.com/react-hook-form/react-hook-form/issues/13009). 112 | -------------------------------------------------------------------------------- /tests/lens-extension.test.ts: -------------------------------------------------------------------------------- 1 | import type { FieldValues } from 'react-hook-form'; 2 | import { useForm } from 'react-hook-form'; 3 | import { LensCore, LensesStorage } from '@hookform/lenses'; 4 | import { renderHook } from '@testing-library/react'; 5 | 6 | // Create a custom LensCore class that extends the base class 7 | class CustomLensCore extends LensCore { 8 | // Add a custom method 9 | public getValue(): string { 10 | return `Custom value at path: ${this.path}`; 11 | } 12 | 13 | // Add another custom method 14 | public getCustomInfo(): string { 15 | return `CustomLensCore instance at ${this.path}`; 16 | } 17 | } 18 | 19 | test('LensCore extension should preserve custom methods when using focus()', () => { 20 | const { result } = renderHook(() => 21 | useForm<{ 22 | user: { 23 | name: string; 24 | email: string; 25 | }; 26 | }>({ 27 | defaultValues: { 28 | user: { 29 | name: 'John', 30 | email: 'john@example.com', 31 | }, 32 | }, 33 | }), 34 | ); 35 | 36 | const control = result.current.control; 37 | const cache = new LensesStorage(control); 38 | const rootLens = new CustomLensCore(control, '', cache); 39 | 40 | // Focus on nested paths 41 | const userLens = rootLens.focus('user'); 42 | const nameLens = userLens.focus('name'); 43 | 44 | // Verify that the focused lenses are instances of CustomLensCore 45 | expect(userLens).toBeInstanceOf(CustomLensCore); 46 | expect(nameLens).toBeInstanceOf(CustomLensCore); 47 | 48 | // Verify that custom methods are available 49 | expect((userLens as CustomLensCore).getValue()).toBe('Custom value at path: user'); 50 | expect((userLens as CustomLensCore).getCustomInfo()).toBe('CustomLensCore instance at user'); 51 | 52 | expect((nameLens as CustomLensCore).getValue()).toBe('Custom value at path: user.name'); 53 | expect((nameLens as CustomLensCore).getCustomInfo()).toBe('CustomLensCore instance at user.name'); 54 | }); 55 | 56 | test('LensCore extension should preserve custom methods when using reflect()', () => { 57 | const { result } = renderHook(() => 58 | useForm<{ 59 | profile: { 60 | contact: { 61 | firstName: string; 62 | phone: string; 63 | }; 64 | }; 65 | }>({ 66 | defaultValues: { 67 | profile: { 68 | contact: { 69 | firstName: 'Jane', 70 | phone: '123-456-7890', 71 | }, 72 | }, 73 | }, 74 | }), 75 | ); 76 | 77 | const control = result.current.control; 78 | const cache = new LensesStorage(control); 79 | const rootLens = new CustomLensCore(control, '', cache); 80 | 81 | // Use reflect to restructure 82 | const contactLens = rootLens.reflect((_dictionary, l) => ({ 83 | name: l.focus('profile.contact.firstName'), 84 | phoneNumber: l.focus('profile.contact.phone'), 85 | })); 86 | 87 | // Verify that the reflected lens is an instance of CustomLensCore 88 | expect(contactLens).toBeInstanceOf(CustomLensCore); 89 | expect((contactLens as CustomLensCore).getValue()).toBe('Custom value at path: '); 90 | expect((contactLens as CustomLensCore).getCustomInfo()).toBe('CustomLensCore instance at '); 91 | 92 | // Focus on the reflected structure 93 | const nameLens = contactLens.focus('name'); 94 | expect(nameLens).toBeInstanceOf(CustomLensCore); 95 | }); 96 | 97 | test('LensCore extension should preserve custom methods when using static create()', () => { 98 | const { result } = renderHook(() => 99 | useForm<{ 100 | firstName: string; 101 | lastName: string; 102 | }>({ 103 | defaultValues: { 104 | firstName: 'Test', 105 | lastName: 'User', 106 | }, 107 | }), 108 | ); 109 | 110 | const control = result.current.control; 111 | const cache = new LensesStorage(control); 112 | 113 | // Use the static create method on the custom class 114 | const lens = CustomLensCore.create(control, cache); 115 | 116 | // Verify that it creates an instance of CustomLensCore 117 | expect(lens).toBeInstanceOf(CustomLensCore); 118 | expect((lens as unknown as CustomLensCore).getValue()).toBe('Custom value at path: '); 119 | expect((lens as unknown as CustomLensCore).getCustomInfo()).toBe('CustomLensCore instance at '); 120 | 121 | // Verify that focused lenses also preserve the custom class 122 | const firstNameLens = lens.focus('firstName'); 123 | expect(firstNameLens).toBeInstanceOf(CustomLensCore); 124 | expect((firstNameLens as unknown as CustomLensCore).getValue()).toBe('Custom value at path: firstName'); 125 | }); 126 | -------------------------------------------------------------------------------- /examples/hook-form/Unregister.story.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { type Path, type SubmitHandler, useForm } from 'react-hook-form'; 3 | import { type Lens, useLens } from '@hookform/lenses'; 4 | import { useFieldArray } from '@hookform/lenses/rhf'; 5 | import { action } from '@storybook/addon-actions'; 6 | import type { Meta, StoryObj } from '@storybook/react'; 7 | import { expect, fn, userEvent, within } from '@storybook/test'; 8 | 9 | import { NumberInput, StringInput } from '../components'; 10 | 11 | const meta = { 12 | title: 'Hook-Form', 13 | component: Playground, 14 | } satisfies Meta; 15 | 16 | export default meta; 17 | 18 | type Story = StoryObj; 19 | 20 | interface PlaygroundData { 21 | username: string; 22 | age: number; 23 | deep: { 24 | nested: { 25 | value: string; 26 | }; 27 | another: string; 28 | }; 29 | children: { 30 | name: string; 31 | surname: string; 32 | }[]; 33 | } 34 | 35 | interface PlaygroundProps { 36 | onSubmit: SubmitHandler; 37 | } 38 | 39 | function Playground({ onSubmit = action('submit') }: PlaygroundProps) { 40 | const [hidden, setHidden] = useState(() => new Set | 'form'>()); 41 | const { handleSubmit, control } = useForm({}); 42 | const lens = useLens({ control }); 43 | 44 | const toggleHidden = (path: Path | 'form') => { 45 | setHidden((prev) => prev.symmetricDifference(new Set([path]))); 46 | }; 47 | 48 | const formToggleButton = ( 49 | 52 | ); 53 | 54 | if (hidden.has('form')) { 55 | return formToggleButton; 56 | } 57 | 58 | return ( 59 | <> 60 | {formToggleButton} 61 |
62 |
63 | 66 | {!hidden.has('username') && } 67 |
68 | 69 |
70 | 73 | {!hidden.has('age') && } 74 |
75 | 76 |
77 | 80 | {!hidden.has('deep.nested.value') && ( 81 | 82 | )} 83 |
84 | 85 |
86 | 89 | {!hidden.has('deep.another') && } 90 |
91 | 92 |
93 | 96 | {!hidden.has('children') && } 97 |
98 | 99 |
100 | 101 |
102 |
103 | 104 | ); 105 | } 106 | 107 | function ChildForm({ lens }: { lens: Lens<{ name: string; surname: string }[]> }) { 108 | const { fields, append, remove } = useFieldArray({ ...lens.interop(), shouldUnregister: true }); 109 | 110 | return ( 111 |
112 | 115 | {lens.map(fields, (value, l, index) => ( 116 | remove(index)} /> 117 | ))} 118 |
119 | ); 120 | } 121 | 122 | function PersonForm({ lens, onRemove }: { lens: Lens<{ name: string; surname: string }>; onRemove: () => void }) { 123 | return ( 124 |
125 | 126 | 127 | 130 |
131 | ); 132 | } 133 | 134 | export const Unregister: Story = { 135 | args: { 136 | onSubmit: fn(), 137 | }, 138 | play: async ({ canvasElement, args }) => { 139 | const canvas = within(canvasElement); 140 | 141 | await userEvent.click(canvas.getByRole('button', { name: /submit/i })); 142 | 143 | expect(args.onSubmit).toHaveBeenCalledWith( 144 | { 145 | age: NaN, 146 | children: [], 147 | deep: { 148 | another: '', 149 | nested: { 150 | value: '', 151 | }, 152 | }, 153 | username: '', 154 | }, 155 | expect.anything(), 156 | ); 157 | }, 158 | }; 159 | -------------------------------------------------------------------------------- /src/types/ArrayLens.ts: -------------------------------------------------------------------------------- 1 | import type { FieldArray, FieldArrayPath, FieldArrayWithId, FieldValues, Path, PathValue } from 'react-hook-form'; 2 | 3 | import type { HookFormControlShim, LensInteropBinding, ShimKeyName } from './Interop'; 4 | import type { Lens, LensesDictionary, LensesGetter, UnwrapLens } from './Lens'; 5 | 6 | export interface ArrayLensGetter { 7 | (dictionary: LensesDictionary, lens: Lens): [LensesGetter]; 8 | } 9 | 10 | export interface ArrayLensMapper { 11 | (value: T, lens: Lens, index: number, array: T[], origin: this): R; 12 | } 13 | 14 | /** 15 | * Array item transformers for `useFieldArray` from @hookform/lenses. 16 | */ 17 | export interface LensInteropTransformerBinding< 18 | TFieldValues extends FieldValues = FieldValues, 19 | TFieldArrayName extends FieldArrayPath = FieldArrayPath, 20 | TKeyName extends string = 'id', 21 | > extends LensInteropBinding { 22 | getTransformer?: >( 23 | value: FieldArrayWithId, 24 | ) => FieldArrayWithId; 25 | 26 | setTransformer?: >( 27 | value: FieldArray, 28 | ) => FieldArrayWithId; 29 | } 30 | 31 | export interface ArrayLens { 32 | /** 33 | * This method allows you to create a new lens with specific path starting from the array item. 34 | * 35 | * @param path - The path to the field in the form. 36 | * 37 | * @example 38 | * ```tsx 39 | * function Component({ lens }: { lens: Lens<{ name: string }[]> }) { 40 | * const firstName = lens.focus('0.name'); 41 | * const secondItem = lens.focus('1'); 42 | * // ... 43 | * } 44 | * ``` 45 | */ 46 | focus

>(path: P): Lens>; 47 | 48 | /** This method allows you to create a new lens that focuses on a specific array item. 49 | * 50 | * @param index - Array index to focus on. 51 | * 52 | * @example 53 | * ```tsx 54 | * function Component({ lens }: { lens: Lens<{ name: string }[]> }) { 55 | * const thirdItem = lens.focus(2); 56 | * /// ... 57 | * } 58 | * ``` 59 | */ 60 | focus

(index: P): Lens; 61 | 62 | /** 63 | * This method allows you to restructure the array item. 64 | * Pay attention that this function must return an array with one item. 65 | * 66 | * @param getter - A function that returns an array with one object where each field is a lens. 67 | * 68 | * @example 69 | * ```tsx 70 | * function Component({ 71 | * lens, 72 | * }: { 73 | * lens: Lens<{ 74 | * items: { 75 | * value: { inside: string }; 76 | * }[]; 77 | * }>; 78 | * }) { 79 | * return [{ data: value.focus('inside') }])} />; 80 | * } 81 | * 82 | * function Items({ lens }: { lens: Lens<{ data: string }[]> }) { 83 | * const { fields } = useFieldArray(lens.interop()); 84 | * 85 | * return ( 86 | *

87 | * {lens.map(fields, (value, l) => ( 88 | *
89 | * 90 | *
91 | * ))} 92 | *
93 | * ); 94 | * } 95 | * ``` 96 | */ 97 | reflect(getter: ArrayLensGetter): Lens[]>; 98 | 99 | /** 100 | * This method allows you to map an array lens. 101 | * It requires the `fields` property from `useFieldArray`. 102 | * 103 | * @param fields - The `fields` property from `useFieldArray`. 104 | * @param mapper - A function that will be called on each `fields` item. 105 | * 106 | * @example 107 | * ```tsx 108 | * function Component({ lens }: { lens: Lens<{ data: string }[]> }) { 109 | * const { fields } = useFieldArray(lens.interop()); 110 | * 111 | * return ( 112 | *
113 | * {lens.map(fields, (value, l) => ( 114 | *
115 | * 116 | *
117 | * ))} 118 | *
119 | * ); 120 | * } 121 | * ``` 122 | */ 123 | map(fields: F, mapper: ArrayLensMapper): R[]; 124 | 125 | /** 126 | * This method returns `name` and `control` properties from react-hook-form. 127 | * The returned object must be passed to `useFieldArray` hook from `@hookform/lenses`. 128 | * 129 | * @example 130 | * ```tsx 131 | * import { useFieldArray } from '@hookform/lenses/rhf'; 132 | * 133 | * function Component({ lens }: { lens: Lens<{ data: string }[]> }) { 134 | * const { fields } = useFieldArray(lens.interop()); 135 | * 136 | * return ( 137 | *
138 | * {lens.map(fields, (value, l) => ( 139 | *
140 | * 141 | *
142 | * ))} 143 | *
144 | * ); 145 | * } 146 | * ``` 147 | */ 148 | interop(): LensInteropTransformerBinding< 149 | HookFormControlShim, 150 | ShimKeyName extends FieldArrayPath> ? ShimKeyName : never 151 | >; 152 | } 153 | -------------------------------------------------------------------------------- /examples/typing/Union.story.tsx: -------------------------------------------------------------------------------- 1 | import { type SubmitHandler, useForm } from 'react-hook-form'; 2 | import { useWatch } from 'react-hook-form'; 3 | import { type Lens, useLens } from '@hookform/lenses'; 4 | import { zodResolver } from '@hookform/resolvers/zod'; 5 | import { action } from '@storybook/addon-actions'; 6 | import type { Meta, StoryObj } from '@storybook/react'; 7 | import { expect, fn, userEvent, within } from '@storybook/test'; 8 | import * as z from 'zod'; 9 | 10 | import { Checkbox } from '../components/Checkbox'; 11 | import { Select } from '../components/Select'; 12 | import { StringInput } from '../components/StringInput'; 13 | 14 | const meta = { 15 | title: 'Typing', 16 | component: Playground, 17 | } satisfies Meta; 18 | 19 | export default meta; 20 | 21 | type Story = StoryObj; 22 | 23 | const dogSchema = z.object({ 24 | type: z.literal('dog'), 25 | value: z.object({ canBark: z.boolean() }), 26 | }); 27 | type DogValues = z.infer; 28 | 29 | const catSchema = z.object({ 30 | type: z.literal('cat'), 31 | value: z.object({ canMeow: z.boolean() }), 32 | }); 33 | type CatValues = z.infer; 34 | 35 | const animalSchema = z.discriminatedUnion('type', [dogSchema, catSchema]); 36 | type AnimalValues = z.infer; 37 | 38 | const formSchema = z.object({ 39 | name: z.string(), 40 | animal: animalSchema, 41 | }); 42 | 43 | type FormValues = z.infer; 44 | 45 | const defaultValues: FormValues = { 46 | name: 'Gregory', 47 | animal: { 48 | type: 'dog', 49 | value: { 50 | canBark: true, 51 | }, 52 | }, 53 | }; 54 | 55 | interface PlaygroundProps { 56 | onSubmit: SubmitHandler; 57 | } 58 | 59 | function Playground({ onSubmit = action('submit') }: PlaygroundProps) { 60 | const { handleSubmit, control } = useForm({ defaultValues, mode: 'all', resolver: zodResolver(formSchema) }); 61 | const lens = useLens({ control }); 62 | 63 | return ( 64 |
65 |
66 | 67 | 68 | 69 | 70 |
71 | ); 72 | } 73 | 74 | export const Union: Story = { 75 | args: { 76 | onSubmit: fn(), 77 | }, 78 | play: async ({ canvasElement, args }) => { 79 | const canvas = within(canvasElement); 80 | 81 | const nameInput = canvas.getByRole('textbox'); 82 | const animalSelect = canvas.getByRole('combobox'); 83 | const dogCheckbox = canvas.getByLabelText(/can bark/i); 84 | 85 | expect(dogCheckbox).toBeChecked(); 86 | expect(animalSelect).toHaveValue('dog'); 87 | 88 | await userEvent.clear(nameInput); 89 | await userEvent.type(nameInput, 'Max'); 90 | 91 | await userEvent.selectOptions(animalSelect, 'cat'); 92 | 93 | expect(canvas.queryByLabelText(/can bark/i)).not.toBeInTheDocument(); 94 | const catCheckbox = canvas.getByLabelText(/can meow/i); 95 | expect(catCheckbox).toBeInTheDocument(); 96 | expect(catCheckbox).not.toBeChecked(); 97 | 98 | await userEvent.click(catCheckbox); 99 | expect(catCheckbox).toBeChecked(); 100 | 101 | await userEvent.click(canvas.getByRole('button', { name: /submit/i })); 102 | 103 | expect(args.onSubmit).toHaveBeenCalledWith( 104 | { 105 | name: 'Max', 106 | animal: { 107 | type: 'cat', 108 | value: { 109 | canMeow: true, 110 | }, 111 | }, 112 | }, 113 | expect.anything(), 114 | ); 115 | 116 | await userEvent.selectOptions(animalSelect, 'dog'); 117 | 118 | expect(canvas.queryByLabelText(/can meow/i)).not.toBeInTheDocument(); 119 | const newDogCheckbox = canvas.getByLabelText(/can bark/i); 120 | expect(newDogCheckbox).toBeInTheDocument(); 121 | expect(newDogCheckbox).not.toBeChecked(); 122 | 123 | await userEvent.click(newDogCheckbox); 124 | await userEvent.click(canvas.getByRole('button', { name: /submit/i })); 125 | 126 | expect(args.onSubmit).toHaveBeenLastCalledWith( 127 | { 128 | name: 'Max', 129 | animal: { 130 | type: 'dog', 131 | value: { 132 | canBark: true, 133 | }, 134 | }, 135 | }, 136 | expect.anything(), 137 | ); 138 | }, 139 | }; 140 | 141 | function AnimalFields({ lens }: { lens: Lens }) { 142 | const interop = lens.interop(); 143 | const selectedType = useWatch(interop); 144 | 145 | const animalOptions = [ 146 | { value: 'dog' as const, label: 'Dog' }, 147 | { value: 'cat' as const, label: 'Cat' }, 148 | ]; 149 | 150 | return ( 151 |
152 | ().toEqualTypeOf<{ id: number }>(); 17 | expectTypeOf().toEqualTypeOf<{ id: string }>(); 18 | 19 | const form = useForm({ 20 | resolver: zodResolver(schema), 21 | }); 22 | 23 | const lens = useLens({ control: form.control }); 24 | expectTypeOf().toEqualTypeOf>(); 25 | }); 26 | 27 | test('lens unwraps to the original type', () => { 28 | expectTypeOf>>().toEqualTypeOf<{ a: string }>(); 29 | expectTypeOf>>().toEqualTypeOf<{ a: string[] }>(); 30 | expectTypeOf>>().toEqualTypeOf<{ a: { b: string[] } }>(); 31 | 32 | expectTypeOf< 33 | UnwrapLens< 34 | Lens<{ 35 | a: { b: { c: string }; e: number; f: { g: boolean }[] }; 36 | }> 37 | > 38 | >().toEqualTypeOf<{ 39 | a: { b: { c: string }; e: number; f: { g: boolean }[] }; 40 | }>(); 41 | }); 42 | 43 | test('unwrap can handle nested lenses', () => { 44 | expectTypeOf< 45 | UnwrapLens< 46 | Lens<{ 47 | a: Lens<{ b: Lens<{ c: Lens }>; e: Lens; f: Lens<{ g: Lens }[]> }>; 48 | }> 49 | > 50 | >().toEqualTypeOf<{ 51 | a: { 52 | b: { 53 | c: string; 54 | }; 55 | e: number; 56 | f: { 57 | g: boolean; 58 | }[]; 59 | }; 60 | }>(); 61 | }); 62 | 63 | test('lens can handle optional fields', () => { 64 | function stringOnly(props: { lens: Lens }) { 65 | return props; 66 | } 67 | 68 | function stringOrUndefined(props: { lens: Lens }) { 69 | return props; 70 | } 71 | 72 | const lens = {} as Lens<{ value: string; valueOrUndefined?: string }>; 73 | 74 | const value = lens.focus('value'); 75 | const valueOrUndefined = lens.focus('valueOrUndefined'); 76 | 77 | expectTypeOf>().toEqualTypeOf(); 78 | expectTypeOf>().toEqualTypeOf(); 79 | 80 | stringOnly({ lens: value }); 81 | stringOnly({ lens: valueOrUndefined.defined() }); 82 | // @ts-expect-error - stringOrUndefined can set undefined value, so passed value should be ready for such type 83 | stringOrUndefined({ lens: value }); 84 | stringOrUndefined({ lens: valueOrUndefined }); 85 | }); 86 | 87 | test('should support union types', () => { 88 | interface Dog { 89 | type: 'dog'; 90 | value: { canBark: boolean }; 91 | } 92 | 93 | interface Cat { 94 | type: 'cat'; 95 | value: { canMeow: boolean }; 96 | } 97 | 98 | const form = useForm(); 99 | const lens = useLens({ control: form.control }); 100 | 101 | expectTypeOf>().toEqualTypeOf(); 102 | 103 | function checkDog(props: { lens: Lens }) { 104 | const lensType = props.lens.focus('type'); 105 | expectTypeOf>().toEqualTypeOf<'dog'>(); 106 | 107 | return props; 108 | } 109 | 110 | function checkCat(props: { lens: Lens }) { 111 | const lensType = props.lens.focus('type'); 112 | expectTypeOf>().toEqualTypeOf<'cat'>(); 113 | 114 | return props; 115 | } 116 | 117 | checkDog({ lens: lens.narrow() }); 118 | checkCat({ lens: lens.narrow() }); 119 | }); 120 | 121 | test('should allow focusing all fields in union type', () => { 122 | interface Dog { 123 | type: 'dog'; 124 | value: { canBark: boolean }; 125 | } 126 | 127 | interface Cat { 128 | type: 'cat'; 129 | value: { canMeow: boolean }; 130 | } 131 | 132 | const form = useForm(); 133 | const lens = useLens({ control: form.control }); 134 | 135 | function check(props: { lens: Lens }) { 136 | const typeLens = props.lens.focus('type'); 137 | const valueLens = props.lens.focus('value'); 138 | const canBarkLens = props.lens.focus('value.canBark'); 139 | const canMeowLens = props.lens.focus('value.canMeow'); 140 | 141 | expectTypeOf>().toEqualTypeOf<'dog' | 'cat'>(); 142 | expectTypeOf>().toEqualTypeOf<{ canBark: boolean } | { canMeow: boolean }>(); 143 | expectTypeOf>().toEqualTypeOf(); 144 | expectTypeOf>().toEqualTypeOf(); 145 | 146 | return props; 147 | } 148 | 149 | check({ lens }); 150 | }); 151 | 152 | test('should preserver string literal types', () => { 153 | const form = useForm<{ value: 'a' | 'b' | 'c' }>(); 154 | const lens = useLens({ control: form.control }); 155 | 156 | expectTypeOf>().toEqualTypeOf<{ value: 'a' | 'b' | 'c' }>(); 157 | }); 158 | 159 | test('narrow should return a lens narrowed to chosen union member', () => { 160 | interface Dog { 161 | type: 'dog'; 162 | value: { canBark: boolean }; 163 | } 164 | interface Cat { 165 | type: 'cat'; 166 | value: { canMeow: boolean }; 167 | } 168 | 169 | const unionLens: Lens = {} as any; 170 | 171 | const dogLens = unionLens.narrow('type', 'dog'); 172 | expectTypeOf().toEqualTypeOf>(); 173 | 174 | const catLens = unionLens.narrow('type', 'cat'); 175 | expectTypeOf().toEqualTypeOf>(); 176 | }); 177 | 178 | test('assert should act as a type guard and narrow the current lens', () => { 179 | interface Dog { 180 | type: 'dog'; 181 | value: { canBark: boolean }; 182 | } 183 | interface Cat { 184 | type: 'cat'; 185 | value: { canMeow: boolean }; 186 | } 187 | 188 | const lens: Lens = {} as any; 189 | 190 | function treatCat(l: Lens) { 191 | return l; 192 | } 193 | 194 | // Branching runtime check to mimic real usage 195 | function handleUnion(u: 'dog' | 'cat') { 196 | if (u === 'cat') { 197 | lens.assert('type', 'cat'); 198 | treatCat(lens); // should compile 199 | } 200 | } 201 | 202 | handleUnion('cat'); 203 | }); 204 | 205 | test('generic narrow for primitive union', () => { 206 | const maybe: Lens = {} as any; 207 | const defined = maybe.narrow(); 208 | expectTypeOf().toEqualTypeOf>(); 209 | }); 210 | 211 | test('generic assert for primitive union', () => { 212 | const maybe: Lens = {} as any; 213 | function needsString(_l: Lens) {} 214 | maybe.assert(); 215 | needsString(maybe); // should compile when assert works 216 | }); 217 | -------------------------------------------------------------------------------- /src/LensCore.ts: -------------------------------------------------------------------------------- 1 | import { type Control, type FieldValues, get, set } from 'react-hook-form'; 2 | 3 | import { LensesStorage, type LensesStorageComplexKey } from './LensesStorage'; 4 | import type { Lens } from './types'; 5 | 6 | export interface LensCoreInteropBinding { 7 | control: Control; 8 | name: string | undefined; 9 | getTransformer?: (value: unknown) => unknown; 10 | setTransformer?: (value: unknown) => unknown; 11 | } 12 | 13 | /** 14 | * Runtime lens implementation. 15 | */ 16 | export class LensCore { 17 | public control: Control; 18 | public path: string; 19 | public cache?: LensesStorage | undefined; 20 | 21 | protected isArrayItemReflection?: boolean; 22 | protected override?: Record> | [Record>]; 23 | protected interopCache?: LensCoreInteropBinding; 24 | protected reflectedKey?: LensesStorageComplexKey; 25 | 26 | constructor(control: Control, path: string, cache?: LensesStorage | undefined) { 27 | this.control = control; 28 | this.path = path; 29 | this.cache = cache; 30 | } 31 | 32 | public static create( 33 | control: Control, 34 | cache?: LensesStorage, 35 | ): Lens { 36 | return new this(control, '', cache) as unknown as Lens; 37 | } 38 | 39 | public focus(prop: string | number): LensCore { 40 | const propString = prop.toString(); 41 | 42 | if (typeof prop === 'string' && prop.includes('.')) { 43 | const dotIndex = prop.indexOf('.'); 44 | const firstSegment = prop.slice(0, dotIndex); 45 | const remainingPath = prop.slice(dotIndex + 1); 46 | 47 | return this.focus(firstSegment).focus(remainingPath); 48 | } 49 | 50 | const nestedPath = this.path ? `${this.path}.${propString}` : propString; 51 | 52 | const fromCache = this.cache?.get(nestedPath, this.reflectedKey); 53 | 54 | if (fromCache) { 55 | return fromCache; 56 | } 57 | 58 | if (Array.isArray(this.override)) { 59 | const [template] = this.override; 60 | const result = new (this.constructor as typeof LensCore)(this.control, nestedPath, this.cache); 61 | result.isArrayItemReflection = true; 62 | result.override = template; 63 | 64 | this.cache?.set(result, nestedPath); 65 | 66 | return result; 67 | } else if (this.override) { 68 | const overriddenLensOrNested = get(this.override, propString); 69 | 70 | let overriddenLens: LensCore | undefined; 71 | 72 | if (typeof overriddenLensOrNested?.reflect === 'function') { 73 | overriddenLens = overriddenLensOrNested; 74 | } else if (overriddenLensOrNested) { 75 | overriddenLens = this.reflect(() => overriddenLensOrNested); 76 | } 77 | 78 | if (!overriddenLens) { 79 | const result = new (this.constructor as typeof LensCore)(this.control, nestedPath, this.cache); 80 | this.cache?.set(result, nestedPath); 81 | return result; 82 | } 83 | 84 | if (this.isArrayItemReflection) { 85 | const arrayItemNestedPath = `${this.path}.${overriddenLens.path}`; 86 | const result = new (this.constructor as typeof LensCore)(this.control, arrayItemNestedPath, this.cache); 87 | this.cache?.set(result, arrayItemNestedPath); 88 | return result; 89 | } else { 90 | this.cache?.set(overriddenLens, nestedPath); 91 | return overriddenLens; 92 | } 93 | } 94 | 95 | const result = new (this.constructor as typeof LensCore)(this.control, nestedPath, this.cache); 96 | this.cache?.set(result, nestedPath); 97 | return result; 98 | } 99 | 100 | public reflect( 101 | getter: ( 102 | dictionary: ProxyHandler>>, 103 | lens: LensCore, 104 | ) => Record> | [Record>], 105 | ): LensCore { 106 | const fromCache = this.cache?.get(this.path, getter); 107 | 108 | if (fromCache) { 109 | return fromCache; 110 | } 111 | 112 | const nestedCache = new LensesStorage(this.control); 113 | const template = new (this.constructor as typeof LensCore)(this.control, this.path, nestedCache); 114 | 115 | const dictionary = new Proxy( 116 | {}, 117 | { 118 | get: (target, prop) => { 119 | if (typeof prop === 'string') { 120 | return template.focus(prop); 121 | } 122 | 123 | return target; 124 | }, 125 | }, 126 | ); 127 | 128 | const override = getter(dictionary, template); 129 | 130 | if (Array.isArray(override)) { 131 | const result = new (this.constructor as typeof LensCore)(this.control, this.path, this.cache); 132 | template.path = ''; 133 | result.override = getter(dictionary, template); 134 | result.reflectedKey = getter; 135 | this.cache?.set(result, this.path, getter); 136 | return result; 137 | } else { 138 | template.override = override; 139 | template.path = this.path; 140 | template.reflectedKey = getter; 141 | this.cache?.set(template, this.path, getter); 142 | return template; 143 | } 144 | } 145 | 146 | public map( 147 | fields: Record[], 148 | mapper: (value: unknown, item: LensCore, index: number, array: unknown[], lens: this) => R, 149 | ): R[] { 150 | return fields.map((value, index, array) => { 151 | const item = this.focus(index.toString()); 152 | const res = mapper(value, item, index, array, this); 153 | return res; 154 | }); 155 | } 156 | 157 | public interop(cb?: (control: Control, name: string | undefined) => any): LensCoreInteropBinding | undefined { 158 | if (cb) { 159 | return cb(this.control, this.path); 160 | } 161 | 162 | this.interopCache ??= { 163 | control: this.control, 164 | name: this.path || undefined, 165 | ...(this.override ? { getTransformer: this.getTransformer.bind(this), setTransformer: this.setTransformer.bind(this) } : {}), 166 | }; 167 | 168 | return this.interopCache; 169 | } 170 | 171 | public narrow(): this { 172 | return this; 173 | } 174 | 175 | public assert(): this { 176 | return this; 177 | } 178 | 179 | public defined(): this { 180 | return this; 181 | } 182 | 183 | public cast(): this { 184 | return this; 185 | } 186 | 187 | protected getTransformer(value: unknown): unknown { 188 | const [template] = Array.isArray(this.override) ? this.override : [this.override]; 189 | 190 | if (!value || !template) { 191 | return value; 192 | } 193 | 194 | const newValue = {} as typeof value; 195 | 196 | Object.entries(template).forEach(([key, valueTemplate]) => { 197 | const restructuredLens = valueTemplate; 198 | 199 | if (!restructuredLens) { 200 | return; 201 | } 202 | 203 | const v = get(value, restructuredLens.path); 204 | set(newValue, key, v); 205 | }); 206 | 207 | return newValue; 208 | } 209 | 210 | protected setTransformer(value: unknown): unknown { 211 | const [template] = Array.isArray(this.override) ? this.override : [this.override]; 212 | 213 | if (!value || !template) { 214 | return value; 215 | } 216 | 217 | const newValue = {} as typeof value; 218 | 219 | Object.entries(value).forEach(([key, value]) => { 220 | const restructuredLens = template[key]; 221 | 222 | if (!restructuredLens) { 223 | return; 224 | } 225 | 226 | set(newValue, restructuredLens.path, value); 227 | }); 228 | 229 | return newValue; 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /tests/object-reflect.test.ts: -------------------------------------------------------------------------------- 1 | import { useForm } from 'react-hook-form'; 2 | import { type Lens, useLens } from '@hookform/lenses'; 3 | import { renderHook } from '@testing-library/react'; 4 | import { expectTypeOf } from 'vitest'; 5 | 6 | test('reflect can create a new lens', () => { 7 | const { result } = renderHook(() => { 8 | const form = useForm<{ a: string }>(); 9 | const lens = useLens({ control: form.control }); 10 | return lens; 11 | }); 12 | 13 | expectTypeOf(result.current.reflect((l) => ({ b: l.a }))).toEqualTypeOf>(); 14 | }); 15 | 16 | test('spread operator is allowed for lenses in reflect', () => { 17 | const { result } = renderHook(() => { 18 | const form = useForm<{ a: string; b: { c: number } }>(); 19 | const lens = useLens({ control: form.control }); 20 | return lens; 21 | }); 22 | 23 | expectTypeOf( 24 | result.current.reflect((l) => { 25 | expectTypeOf(l).toEqualTypeOf<{ 26 | a: Lens; 27 | b: Lens<{ 28 | c: number; 29 | }>; 30 | }>(); 31 | 32 | return { ...l }; 33 | }), 34 | ).toEqualTypeOf>(); 35 | }); 36 | 37 | test('non lens fields cannot returned from reflect', () => { 38 | const { result } = renderHook(() => { 39 | const form = useForm<{ a: string }>(); 40 | const lens = useLens({ control: form.control }); 41 | return lens; 42 | }); 43 | 44 | assertType(result.current.reflect((_, l) => ({ b: l.focus('a'), w: 'hello' }))); 45 | }); 46 | 47 | test('reflect can add props from another lens', () => { 48 | const { result: form1 } = renderHook(() => { 49 | const form = useForm<{ a: string }>(); 50 | const lens = useLens({ control: form.control }); 51 | return lens; 52 | }); 53 | 54 | const { result: form2 } = renderHook(() => { 55 | const form = useForm<{ b: number }>(); 56 | const lens = useLens({ control: form.control }); 57 | return lens; 58 | }); 59 | 60 | expectTypeOf(form1.current.reflect((l) => ({ c: l.a, d: form2.current.focus('b') }))).toEqualTypeOf>(); 61 | }); 62 | 63 | test('reflect return an object contains Date, File, FileList', () => { 64 | const { result } = renderHook(() => { 65 | const form = useForm<{ date: Date; file: File; fileList: FileList }>(); 66 | const lens = useLens({ control: form.control }); 67 | return lens; 68 | }); 69 | 70 | const reflectedLens = result.current.reflect((dic) => { 71 | expectTypeOf(dic.date).toEqualTypeOf>(); 72 | expectTypeOf(dic.file).toEqualTypeOf>(); 73 | expectTypeOf(dic.fileList).toEqualTypeOf>(); 74 | 75 | return { 76 | date: dic.date, 77 | file: dic.file, 78 | fileList: dic.fileList, 79 | }; 80 | }); 81 | 82 | expectTypeOf(reflectedLens).toEqualTypeOf< 83 | Lens<{ 84 | date: Date; 85 | file: File; 86 | fileList: FileList; 87 | }> 88 | >(); 89 | }); 90 | 91 | test('basic lens focus with dot notation works correctly', () => { 92 | const { result } = renderHook(() => { 93 | const form = useForm<{ 94 | password: { password: string; passwordConfirm: string }; 95 | usernameNest: { name: string }; 96 | }>(); 97 | const lens = useLens({ control: form.control }); 98 | return lens; 99 | }); 100 | 101 | const lens = result.current; 102 | 103 | expect(lens.focus('password.password').interop().name).toBe('password.password'); 104 | expect(lens.focus('usernameNest.name').interop().name).toBe('usernameNest.name'); 105 | }); 106 | 107 | test('reflected lens with chained focus calls works correctly', () => { 108 | type Input = { 109 | name: string; 110 | password: { 111 | password: string; 112 | passwordConfirm: string; 113 | }; 114 | usernameNest: { 115 | name: string; 116 | }; 117 | }; 118 | 119 | type DataLens = { 120 | userName: string; 121 | password: { 122 | password_base: string; 123 | password_confirm: string; 124 | }; 125 | nest2: { 126 | names: { 127 | name: string; 128 | }; 129 | }; 130 | }; 131 | 132 | const { result } = renderHook(() => { 133 | const form = useForm(); 134 | const lens = useLens({ control: form.control }); 135 | return lens; 136 | }); 137 | 138 | const lens = result.current; 139 | 140 | const reflected: Lens = lens.reflect((dic, l) => ({ 141 | userName: dic.name, 142 | password: lens.focus('password').reflect((pas) => ({ 143 | password_base: pas.password, 144 | password_confirm: pas.passwordConfirm, 145 | })), 146 | nest2: lens.reflect(() => ({ 147 | names: l.focus('usernameNest'), 148 | })), 149 | })); 150 | 151 | expect(reflected.focus('password').focus('password_base').interop().name).toBe('password.password'); 152 | expect(reflected.focus('password').focus('password_confirm').interop().name).toBe('password.passwordConfirm'); 153 | expect(reflected.focus('nest2').focus('names').focus('name').interop().name).toBe('usernameNest.name'); 154 | }); 155 | 156 | test('reflected lens with dot notation resolves correct paths', () => { 157 | type Input = { 158 | password: { 159 | password: string; 160 | passwordConfirm: string; 161 | }; 162 | usernameNest: { 163 | name: string; 164 | }; 165 | }; 166 | 167 | type DataLens = { 168 | password: { 169 | password_base: string; 170 | password_confirm: string; 171 | }; 172 | nest2: { 173 | names: { 174 | name: string; 175 | }; 176 | }; 177 | }; 178 | 179 | const { result } = renderHook(() => { 180 | const form = useForm(); 181 | const lens = useLens({ control: form.control }); 182 | return lens; 183 | }); 184 | 185 | const lens = result.current; 186 | 187 | const reflected: Lens = lens.reflect((_, l) => ({ 188 | password: lens.focus('password').reflect((pas) => ({ 189 | password_base: pas.password, 190 | password_confirm: pas.passwordConfirm, 191 | })), 192 | nest2: lens.reflect(() => ({ 193 | names: l.focus('usernameNest'), 194 | })), 195 | })); 196 | 197 | expect(reflected.focus('password.password_base').interop().name).toBe('password.password'); 198 | expect(reflected.focus('password.password_confirm').interop().name).toBe('password.passwordConfirm'); 199 | expect(reflected.focus('nest2.names.name').interop().name).toBe('usernameNest.name'); 200 | }); 201 | 202 | test('reflected lens handles duplicate key names in different nesting levels', () => { 203 | type Input = { 204 | id: string; 205 | nest: { 206 | id: string; 207 | }; 208 | }; 209 | 210 | const { result } = renderHook(() => { 211 | const form = useForm(); 212 | const lens = useLens({ control: form.control }); 213 | return lens; 214 | }); 215 | 216 | const lens = result.current; 217 | 218 | const reflectedWithDuplicateKeys = lens.reflect((_, l) => ({ 219 | id: l.focus('id'), 220 | nest_id: l.focus('nest').focus('id'), 221 | })); 222 | 223 | expect(reflectedWithDuplicateKeys.focus('nest_id').interop().name).toBe('nest.id'); 224 | }); 225 | 226 | test('reflected lens with nested objects resolves correct paths', () => { 227 | type Input = { 228 | password: { 229 | password: string; 230 | passwordConfirm: string; 231 | }; 232 | usernameNest: { 233 | name: string; 234 | }; 235 | }; 236 | 237 | type DataLens = { 238 | password: { 239 | password_base: string; 240 | password_confirm: string; 241 | }; 242 | nest2: { 243 | names: { 244 | name: string; 245 | }; 246 | }; 247 | }; 248 | 249 | const { result } = renderHook(() => { 250 | const form = useForm(); 251 | const lens = useLens({ control: form.control }); 252 | return lens; 253 | }); 254 | 255 | const lens = result.current; 256 | 257 | const reflected: Lens = lens.reflect((_, l) => ({ 258 | password: { 259 | password_base: l.focus('password.password'), 260 | password_confirm: l.focus('password.passwordConfirm'), 261 | }, 262 | nest2: { 263 | names: l.focus('usernameNest'), 264 | }, 265 | })); 266 | 267 | expect(reflected.focus('password.password_base').interop().name).toBe('password.password'); 268 | expect(reflected.focus('password.password_confirm').interop().name).toBe('password.passwordConfirm'); 269 | expect(reflected.focus('nest2.names.name').interop().name).toBe('usernameNest.name'); 270 | }); 271 | 272 | test('reflected lenses without focus do not append paths', () => { 273 | type Input = { 274 | a: { 275 | b: { 276 | c: string; 277 | }; 278 | }; 279 | }; 280 | 281 | type Result = { 282 | x: { 283 | y: string; 284 | }; 285 | }; 286 | 287 | const { result } = renderHook(() => { 288 | const form = useForm(); 289 | const lens = useLens({ control: form.control }); 290 | return lens; 291 | }); 292 | 293 | const lens = result.current; 294 | 295 | const reflected: Lens = lens.reflect((_, l) => ({ 296 | x: l.reflect((_, l2) => { 297 | return { 298 | y: l2.focus('a.b.c'), 299 | }; 300 | }), 301 | })); 302 | 303 | expect(reflected.focus('x.y').interop().name).toBe('a.b.c'); 304 | }); 305 | 306 | test('nested object reflect does not append paths', () => { 307 | type Input = { 308 | a: { 309 | b: { 310 | c: string; 311 | }; 312 | }; 313 | }; 314 | 315 | type Result = { 316 | x: { 317 | y: string; 318 | }; 319 | }; 320 | 321 | const { result } = renderHook(() => { 322 | const form = useForm(); 323 | const lens = useLens({ control: form.control }); 324 | return lens; 325 | }); 326 | 327 | const lens = result.current; 328 | 329 | const reflected: Lens = lens.reflect((_, l) => ({ 330 | x: { 331 | y: l.focus('a.b.c'), 332 | }, 333 | })); 334 | 335 | expect(reflected.focus('x.y').interop().name).toBe('a.b.c'); 336 | }); 337 | 338 | test('reflect with nested object preserve correct path', () => { 339 | const { result } = renderHook(() => { 340 | const form = useForm<{ values: { a: string } }>(); 341 | const lens = useLens({ control: form.control }); 342 | return lens; 343 | }); 344 | 345 | const lens = result.current; 346 | 347 | const reflected = lens.focus('values').reflect((_, l) => { 348 | return { 349 | nested: { 350 | deeper: { 351 | field: l.focus('a'), 352 | }, 353 | }, 354 | }; 355 | }); 356 | 357 | expect(reflected.focus('nested').interop().name).toBe('values'); 358 | expect(reflected.focus('nested.deeper').interop().name).toBe('values'); 359 | expect(reflected.focus('nested.deeper.field').interop().name).toBe('values.a'); 360 | }); 361 | -------------------------------------------------------------------------------- /examples/typing/Toolkit.story.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { type SubmitHandler, useForm, useWatch } from 'react-hook-form'; 3 | import { type Lens, useLens } from '@hookform/lenses'; 4 | import { action } from '@storybook/addon-actions'; 5 | import type { Meta, StoryObj } from '@storybook/react'; 6 | import { fn } from '@storybook/test'; 7 | 8 | const meta = { 9 | title: 'Typing', 10 | component: Playground, 11 | } satisfies Meta; 12 | 13 | export default meta; 14 | 15 | type Story = StoryObj; 16 | 17 | // Union type for demonstrating discriminant-based narrowing 18 | type Animal = { type: 'dog'; breed: string; barks: boolean } | { type: 'cat'; breed: string; meows: boolean }; 19 | 20 | interface FormValues { 21 | // Optional fields for demonstrating defined() and manual narrowing 22 | name?: string; 23 | age?: number; 24 | 25 | // Union type for demonstrating discriminant narrowing and assert 26 | pet?: Animal; 27 | 28 | // For demonstrating cast() with unknown/any data 29 | dynamicData?: unknown; 30 | } 31 | 32 | interface PlaygroundProps { 33 | onSubmit: SubmitHandler; 34 | } 35 | 36 | function Playground({ onSubmit = action('submit') }: PlaygroundProps) { 37 | const { handleSubmit, control, setValue } = useForm({ 38 | defaultValues: { 39 | pet: { type: 'dog', breed: 'Golden Retriever', barks: true }, 40 | }, 41 | }); 42 | const lens = useLens({ control }); 43 | 44 | // Simulate setting dynamic data 45 | React.useEffect(() => { 46 | setValue('dynamicData', { secretValue: 'Hello World!' }); 47 | }, [setValue]); 48 | 49 | return ( 50 |
51 |

Toolkit Methods Demo

52 | 53 |
54 | {/* 1. Manual narrowing: narrow() */} 55 |
56 |

1. Manual Narrowing: narrow<R>()

57 |

Manually narrow optional string to required string:

58 | 59 |
60 | 61 | {/* 2. defined() - exclude null/undefined */} 62 |
63 |

2. Defined: defined()

64 |

Exclude null/undefined from optional field:

65 | 66 |
67 | 68 | {/* 3. Discriminant narrowing: narrow(key, value) */} 69 |
70 |

3. Discriminant Narrowing: narrow(key, value)

71 |

Narrow union type by discriminant property:

72 | 73 |
74 | 75 | {/* 4. Manual assert: assert() */} 76 |
77 |

4. Manual Assert: assert<R>()

78 |

Assert lens type without arguments:

79 | 80 |
81 | 82 | {/* 5. Discriminant assert: assert(key, value) */} 83 |
84 |

5. Discriminant Assert: assert(key, value)

85 |

Assert within conditional blocks:

86 | 87 |
88 | 89 | {/* 6. Cast: cast() */} 90 |
91 |

6. Cast: cast<R>()

92 |

Forcefully cast unknown data to specific type:

93 | 94 |
95 | 96 | 97 | 98 |
99 | ); 100 | } 101 | 102 | export const Toolkit: Story = { 103 | args: { 104 | onSubmit: fn(), 105 | }, 106 | }; 107 | 108 | // 1. Manual narrowing demo 109 | function ManualNarrowingDemo({ lens }: { lens: Lens }) { 110 | const value = useWatch(lens.interop()); 111 | 112 | return ( 113 |
114 |
115 |

Original lens type: Lens<string | undefined>

116 |

Current value: {value || 'undefined'}

117 |
118 | {value && ( 119 |
120 | ()} /> 121 |
122 | )} 123 |
124 | ); 125 | } 126 | 127 | function RequiredStringComponent({ lens }: { lens: Lens }) { 128 | const value = useWatch(lens.interop()); 129 | return ( 130 |
131 |

✅ Successfully narrowed to Lens<string>

132 |

Narrowed value: {value}

133 |
134 | ); 135 | } 136 | 137 | // 2. defined() demo 138 | function DefinedDemo({ lens }: { lens: Lens }) { 139 | const value = useWatch(lens.interop()); 140 | 141 | return ( 142 |
143 |
144 |

Original lens type: Lens<number | undefined>

145 |

Current value: {value !== undefined ? value : 'undefined'}

146 |
147 | {value !== undefined && value !== null && ( 148 |
149 | 150 |
151 | )} 152 |
153 | ); 154 | } 155 | 156 | function NonNullableComponent({ lens }: { lens: Lens }) { 157 | const value = useWatch(lens.interop()); 158 | return ( 159 |
160 |

✅ Successfully narrowed to Lens<number> with defined()

161 |

Defined value: {value}

162 |
163 | ); 164 | } 165 | 166 | // 3. Discriminant narrowing demo 167 | function DiscriminantNarrowingDemo({ lens }: { lens: Lens }) { 168 | const value = useWatch(lens.interop()); 169 | 170 | if (!value) { 171 | return

No pet selected

; 172 | } 173 | 174 | return ( 175 |
176 |

Original lens type: Lens<Animal | undefined>

177 |

Step 1: lens.narrow<Animal>() → Lens<Animal>

178 |

Step 2: Use discriminant narrowing on the result

179 |

Pet type: {value.type}

180 | 181 | {/* For demonstration purposes, showing the concept */} 182 | {value.type === 'dog' && ( 183 |
184 |

✅ Would use: lens.narrow<Animal>().narrow("type", "dog") → Lens<Dog>

185 | } /> 186 |
187 | )} 188 | {value.type === 'cat' && ( 189 |
190 |

✅ Would use: lens.narrow<Animal>().narrow("type", "cat") → Lens<Cat>

191 | } /> 192 |
193 | )} 194 |
195 | ); 196 | } 197 | 198 | function DogComponent({ animal }: { animal: Extract }) { 199 | return ( 200 |
201 |

🐕 Dog breed: {animal.breed}

202 |

Barks: {animal.barks ? 'Yes' : 'No'}

203 |
204 | ); 205 | } 206 | 207 | function CatComponent({ animal }: { animal: Extract }) { 208 | return ( 209 |
210 |

🐱 Cat breed: {animal.breed}

211 |

Meows: {animal.meows ? 'Yes' : 'No'}

212 |
213 | ); 214 | } 215 | 216 | // 4. Manual assert demo 217 | function ManualAssertDemo({ lens: originalLens }: { lens: Lens }) { 218 | const value = useWatch(originalLens.interop()); 219 | 220 | if (!value) { 221 | return

No pet for manual assert demo

; 222 | } 223 | 224 | // We know it's defined, so we can assert it 225 | const lens: Lens = originalLens; 226 | lens.assert(); 227 | 228 | return ( 229 |
230 |

Original lens type: Lens<Animal | undefined>

231 |

After assert<Animal>(): TypeScript now treats it as Lens<Animal>

232 | 233 |
234 | ); 235 | } 236 | 237 | function AssertedAnimalComponent({ lens }: { lens: Lens }) { 238 | const animal = useWatch(lens.interop()); 239 | return

✅ Successfully asserted as Animal: {animal?.type}

; 240 | } 241 | 242 | // 5. Discriminant assert demo 243 | function DiscriminantAssertDemo({ lens: originalLens }: { lens: Lens }) { 244 | const value = useWatch(originalLens.interop()); 245 | 246 | if (!value) { 247 | return

No pet for discriminant assert demo

; 248 | } 249 | 250 | // First assert it's not undefined 251 | const lens: Lens = originalLens; 252 | lens.assert(); 253 | 254 | return ( 255 |
256 |

Original lens type: Lens<Animal | undefined>

257 |

Using assert(key, value) within conditional blocks:

258 | {value.type === 'dog' && } 259 | {value.type === 'cat' && } 260 |
261 | ); 262 | } 263 | 264 | function DogAssertDemo({ lens: animalLens }: { lens: Lens }) { 265 | const lens: Lens = animalLens; 266 | lens.assert('type', 'dog'); 267 | 268 | return ; 269 | } 270 | 271 | function CatAssertDemo({ lens: animalLens }: { lens: Lens }) { 272 | const lens: Lens = animalLens; 273 | lens.assert('type', 'cat'); 274 | 275 | return ; 276 | } 277 | 278 | function AssertedDogComponent({ lens }: { lens: Lens> }) { 279 | const dog = useWatch(lens.interop()); 280 | return ( 281 |
282 |

✅ Successfully asserted as Dog using assert("type", "dog")

283 |

Dog breed: {dog?.breed}

284 |
285 | ); 286 | } 287 | 288 | function AssertedCatComponent({ lens }: { lens: Lens> }) { 289 | const cat = useWatch(lens.interop()); 290 | return ( 291 |
292 |

✅ Successfully asserted as Cat using assert("type", "cat")

293 |

Cat breed: {cat?.breed}

294 |
295 | ); 296 | } 297 | 298 | // 6. Cast demo 299 | function CastDemo({ lens }: { lens: Lens }) { 300 | const value = useWatch(lens.interop()); 301 | 302 | return ( 303 |
304 |

Original lens type: Lens<unknown>

305 |

Unknown data: {JSON.stringify(value)}

306 | ()} /> 307 |
308 | ); 309 | } 310 | 311 | function CastedComponent({ lens }: { lens: Lens<{ secretValue: string }> }) { 312 | const data = useWatch(lens.interop()); 313 | return ( 314 |
315 |

✅ Successfully cast to Lens<{`{ secretValue: string }`}>

316 |

⚠️ Warning: cast() is unsafe - only use when you know the shape!

317 |

Secret value: {data?.secretValue}

318 |
319 | ); 320 | } 321 | --------------------------------------------------------------------------------