React Hook Form Lenses

repository·main·Indexed 40 days ago

https://github.com/react-hook-form/lenses

A TypeScript-first library providing functional lenses for React Hook Form to enable type-safe, composable manipulation of deeply nested form states. It features tools like useLens for instance creation, .focus() for navigating nested paths, .reflect() for transforming data shapes, and an enhanced useFieldArray hook for managing array-based form data with custom transformers.

Tokens
7.7K
Snippets
23
Records
31
Agent score
99%

What's inside React Hook Form Lenses

  1. Core Concepts: How Lenses Work

    main

    Lenses in this library act as carriers for control and name information. They are designed to describe form structure and provide type-safe paths to nested data, rather than acting as containers for the data itself.

    Key Principles

    1. No Setters and Getters: The library does not provide methods to get or set values directly. This prevents bugs where developers might read a value from a lens expecting reactivity, which is not how React Hook Form operates. Always use standard React Hook Form hooks (like useWatch) to access reactive values.

    2. Do Not Operate Values Directly: Avoid consuming raw values from the form state directly within components that rely on lenses. Because these values are not inherently reactive through the lens itself, components may fail to re-render. Instead, consume values via appropriate RHF hooks and pass them down as props.

    3. Interop Pattern: Use .interop() to convert a lens into a format compatible with React Hook Form's internal APIs. For example, when using register, you can pass a callback to interop that receives the controller and the field name: lens.interop((ctrl, name) => ctrl.register(name)).

  2. Quickstart with React Hook Form Lenses

    main

    React Hook Form Lenses allows you to manage deeply nested form structures using composable lenses. You can use useLens to create a lens instance from a RHF control, then use methods like .focus() to drill into nested paths, .reflect() to transform shapes, and .interop() to bridge lenses with standard React Hook Form hooks like useFieldArray or register.

    import { useForm } from 'react-hook-form';
    import { Lens, useLens } from '@hookform/lenses';
    import { useFieldArray } from '@hookform/lenses/rhf';
    
    function FormComponent() {
      const { handleSubmit, control } = useForm<{
        firstName: string;
        lastName: string;
        children: {
          name: string;
          surname: string;
        }[];
      }>({});
    
      const lens = useLens({ control });
    
      return (
        <form onSubmit={handleSubmit(console.log)}>
          <PersonForm
            lens={lens.reflect(({ firstName, lastName }) => ({
              name: firstName,
              surname: lastName,
            }))}
          />
          <ChildForm lens={lens.focus('children')} />
          <input type="submit" />
        </form>
      );
    }
    
    function ChildForm({ lens }: { lens: Lens<{ name: string; surname: string }[]> }) {
      const { fields, append } = useFieldArray(lens.interop());
    
      return (
        <>
          <button type="button" onClick={() => append({ name: '', surname: '' })}>
            Add child
          </button>
          {lens.map(fields, (value, l) => (
            <PersonForm key={value.id} lens={l} />
          ))}
        </>
      );
    }
    
    function PersonForm({ lens }: { lens: Lens<{ name: string; surname: string }> }) {
      return (
        <div>
          <StringInput lens={lens.focus('name')} />
          <StringInput lens={lens.focus('surname')} />
        </div>
      );
    }
    
    function StringInput({ lens }: { lens: Lens<string> }) {
      return <input {...lens.interop((ctrl, name) => ctrl.register(name))} />;
    }
  3. Understand the Lens type and its variants

    main

    A Lens<T> is a specialized object used to navigate and interact with form values. It combines a Toolkit<T> (providing utility methods) with a LensSelector<T>, which determines the lens's behavior based on the type of T:

    • ArrayLens: Used when T is an array.
    • ObjectLens: Used when T is an object (extending FieldValues).
    • PrimitiveLens: Used when T is a primitive value (like string, number, or boolean).

    At runtime, a Lens contains control and name properties for use with react-hook-form. Navigating deeper into a structure using methods like lens.focus('propPath') returns a new lens that maintains the nested path context.

    type LensWithArray = Lens<string[]>;
    type LensWithObject = Lens<{ name: string; age: number }>;
    type LensWithPrimitive = Lens<string>;
  4. Use ArrayLens to navigate and transform array-based form data

    main

    The ArrayLens interface is used to manage and transform arrays within a form using lenses. It provides methods to focus on specific indices or paths, restructure array items via reflection, and map over array fields for rendering.

    Key Methods:

    • focus(path | index): Creates a new lens focused on a specific property path (e.g., '0.name') or a specific numeric index (e.g., 2).
    • reflect(getter): Restructures array items. The getter function must return an array containing exactly one object where each field is a lens. This is useful for mapping complex nested structures to a flatter shape for components.
    • map(fields, mapper): Iterates over the fields provided by useFieldArray and applies a mapper function to produce a new array of values.
    • interop(): Returns a binding object containing getTransformer and setTransformer. This object must be passed to the useFieldArray hook from @hookform/lenses/rhf to bridge the lens logic with React Hook Form's field array state.
    function Component({ lens }: { lens: Lens<{ items: { value: { inside: string } }[] }> }) {
      // Focus on the 'items' array and restructure it so each item has a 'data' field
      const transformedLens = lens.focus('items').reflect(({ value }) => [{
        data: value.focus('inside')
      }]);
    
      return <Items lens={transformedLens} />;
    }
    
    function Items({ lens }: { lens: Lens<{ data: string }[]> }) {
      // Use interop() to connect the lens to useFieldArray
      const { fields } = useFieldArray(lens.interop());
    
      return (
        <div>
          {lens.map(fields, (value, l) => (
            <div key={value.id}>
              {/* Focus the lens on the specific 'data' property of the current item */}
              <StringInput label="Value" lens={l.focus('data')} />
            </div>
          ))}
        </div>
      );
    }
  5. Integrate ArrayLens with useFieldArray via interop()

    main

    To use an ArrayLens with React Hook Form's field array functionality, you must call .interop() on the lens. This returns a LensInteropTransformerBinding which contains the necessary transformers to sync the lens-based state with the underlying form values. This binding should be passed directly to the useFieldArray hook provided by @hookform/lenses/rhf.

    Note: Ensure you are importing useFieldArray from @hookform/lenses/rhf rather than the standard react-hook-form package when using this pattern.

    import { useFieldArray } from '@hookform/lenses/rhf';
    
    function MyComponent({ lens }: { lens: Lens<{ data: string }[]> }) {
      // Pass the result of interop() to useFieldArray
      const { fields } = useFieldArray(lens.interop());
    
      return (
        <div>
          {lens.map(fields, (value, l) => (
            <div key={value.id}>
              <input {...l.focus('data')} />
            </div>
          ))}
        </div>
      );
    }
  6. Configure UseFieldArrayProps with transformers

    main

    When using the enhanced useFieldArray, you can extend the standard UseFieldArrayProps with two optional transformer functions:

    • getTransformer: A function used to transform each item in the fields array. It receives a FieldArrayWithId and returns a transformed version. This affects the fields object returned by the hook.
    • setTransformer: A function used to transform values before they are sent to the underlying React Hook Form methods. This ensures that when you call append, prepend, insert, update, or replace, the data is correctly mapped to the internal form schema.
  7. Forcefully change a Lens type with `cast()`

    main

    The cast<R>() method forcefully changes the Lens type to a new type R, regardless of whether R is a subtype of the original type T.

    Warning: This is an unsafe operation. Unlike narrow(), cast() does not check compatibility. Use it only when you have information the type system lacks and understand that it can lead to runtime errors if the underlying data does not match the asserted type R.

    Example:

    // T is some type, e.g. `any` or `unknown`
    declare const lens: Lens<T>;
    const stringLens = lens.cast<string>(); // Now Lens<string>
  8. Navigate form paths with focus()

    main

    The focus(prop) method allows you to create a new lens that points to a specific property or nested path within the current lens.

    • If prop is a string containing dots (e.g., 'user.address.city'), it will recursively focus through the segments.
    • If prop is a number, it is treated as an array index.

    focus returns a new LensCore instance pointing to the specified path.

    // Assuming rootLens is the root of your form
    const userLens = rootLens.focus('user');
    const addressLens = userLens.focus('address');
    const cityLens = addressLens.focus('city');
    
    // Or using dot notation
    const cityLensDot = rootLens.focus('user.address.city');
    
    // Focusing an array item
    const firstUser = rootLens.focus('users.0');
  9. Integrate with React Hook Form via interop()

    main

    The interop() method provides a way to extract the underlying React Hook Form control and the current lens name (path) in a format compatible with other parts of your application or custom components.

    If a callback is provided, it is executed with the control and name. If no callback is provided, it returns a LensCoreInteropBinding<T> object containing:

    • control: The RHF Control instance.
    • name: The string path of the lens (or undefined if it's the root).
    • getTransformer (optional): A function to transform values coming from the form.
    • setTransformer (optional): A function to transform values being sent to the form.
    const binding = userLens.interop();
    
    if (binding) {
      console.log(binding.control);
      console.log(binding.name); // e.g., 'user'
    }
  10. Initialize a Lens with LensCore.create()

    main

    To start using lenses, use the static LensCore.create method. You must provide the React Hook Form control object. Optionally, you can provide a LensesStorage instance to enable caching of lens instances, which improves performance when navigating deep or repetitive paths.

    LensCore.create returns a Lens<T> object which allows you to navigate your form state.

    import { useForm } from 'react-hook-form';
    import { LensCore } from '@hookform/lenses';
    
    const { control } = useForm();
    
    // Create a root lens for the entire form
    const rootLens = LensCore.create(control);
  11. Define custom data structures with reflect()

    main

    The reflect(getter) method is used to define how a lens should behave when it encounters a specific structure. It allows you to provide a getter function that receives a dictionary (a proxy for navigating properties) and the current lens.

    This is useful for creating 'templates' or 'overrides' that dictate how data is mapped or restructured when accessing certain paths. The getter returns either a single Record<string, LensCore<T>> or an array containing one such record.

    // Example concept: defining a structured override via reflect
    const structuredLens = rootLens.reflect((dictionary, lens) => ({
      userName: dictionary.focus('profile.firstName'),
      userEmail: dictionary.focus('profile.email'),
    }));